Skip to content

Commit 43841c0

Browse files
feat(guardrails): real image-redaction operator surface (upload→redact→review)
The adapter + governed API route + live Presidio image-redactor (g6) were already built and are live-verified, but the UI was only a status badge — and imageRedactionAvailable was hardcoded false, so even the badge was wrong. This turns it into a real management surface (the CLAUDE.md bar: a signal is not a surface): - ImageRedactionPanel (client): upload a PNG/JPEG KYC doc → POST the governed /api/v1/governance/image-redaction route → review the redacted image + detected entities + tamper-evident receipt; honest error states. - Real availability: the masking page resolves it from the adapter config (resolvePresidioImageRedactorConfig), not a hardcoded flag. - Pure helpers (imageRedactionMediaTypeFor / imageRedactionUploadError / summarizeRedactionResult) isolated + 10 unit tests; same limits as the server. - Capability map corrected honestly: image-redaction upstream+adapter+ui = yes (live evidence: KYC PNG redacted, sha changed 4661→3559, PHONE_NUMBER, audit row), workflow = partial (no app-step/pipeline auto-invokes it yet). The old 'no adapter/route/UI' gate was fully stale. Live-verified 2026-07-24 end-to-end through the console API against the real engine; coverage:check green.
1 parent 1c34538 commit 43841c0

6 files changed

Lines changed: 344 additions & 27 deletions

File tree

src/app/(console)/governance/guardrails/[destination]/page.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
1111
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
1212
import { Input } from '@/components/ui/input';
1313
import { getPii } from '@/lib/adapters/registry';
14+
import { resolvePresidioImageRedactorConfig } from '@/lib/adapters/presidio-image-redaction';
1415
import { guardrailsDestination, type GuardrailsDestination } from '@/lib/guardrails-destinations';
1516
import { listGuardrailRules } from '@/lib/guardrails-rules';
1617
import { readGuardrailsView, type GuardrailsView } from '@/lib/guardrails-view';
@@ -64,13 +65,20 @@ async function destinationContent(
6465
listGuardrailRules(orgId).catch(() => []),
6566
getAnonymizerPolicy(orgId).catch(() => DEFAULT_ANONYMIZER_POLICY),
6667
]);
68+
// Real availability: the image-redactor is usable when its URL + token are configured (the same
69+
// config the adapter resolves), not a hardcoded flag.
70+
const imgCfg = resolvePresidioImageRedactorConfig();
71+
const imageRedactionAvailable = Boolean(imgCfg.url && imgCfg.token);
6772
return (
6873
<div className="space-y-6">
6974
<ManagementCard title="Masking rules">
7075
<GuardrailRules rules={rules} />
7176
</ManagementCard>
7277
<ManagementCard title="Anonymizer operators — how each entity is masked">
73-
<PresidioAnonymizers policy={anonymizerPolicy} imageRedactionAvailable={false} />
78+
<PresidioAnonymizers
79+
policy={anonymizerPolicy}
80+
imageRedactionAvailable={imageRedactionAvailable}
81+
/>
7482
</ManagementCard>
7583
</div>
7684
);
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
'use client';
2+
3+
import { ImageSquare, ShieldCheck, Upload } from '@phosphor-icons/react/dist/ssr';
4+
import { useId, useRef, useState } from 'react';
5+
import { toast } from 'sonner';
6+
import { Badge } from '@/components/ui/badge';
7+
import { Button } from '@/components/ui/button';
8+
import { Input } from '@/components/ui/input';
9+
import { Label } from '@/components/ui/label';
10+
import {
11+
type ImageRedactionEvidence,
12+
imageRedactionMediaTypeFor,
13+
imageRedactionUploadError,
14+
summarizeRedactionResult,
15+
} from '@/lib/image-redaction';
16+
17+
// The OPERATOR-facing image redaction surface: upload a KYC document image (an OVD scan), redact the
18+
// PII regions through the governed /api/v1/governance/image-redaction route (which drives the live
19+
// Presidio image-redactor + Tesseract OCR, records an audited receipt), and REVIEW the redacted image
20+
// + the detected entities + the tamper-evident receipt. This is the real management surface — not a
21+
// status badge. Pure validation/summary (imageRedactionUploadError / summarizeRedactionResult) is
22+
// isolated + unit-tested in lib/image-redaction; this component is the thin upload/render glue.
23+
24+
interface RedactionResponse {
25+
redactedImageBase64: string;
26+
mediaType: string;
27+
evidence: ImageRedactionEvidence;
28+
}
29+
30+
// Read a File to canonical base64 (strip the data: prefix) in the browser.
31+
function fileToBase64(file: File): Promise<string> {
32+
return new Promise((resolve, reject) => {
33+
const reader = new FileReader();
34+
reader.onerror = () => reject(new Error('Could not read the file.'));
35+
reader.onload = () => {
36+
const result = String(reader.result ?? '');
37+
const comma = result.indexOf(',');
38+
resolve(comma >= 0 ? result.slice(comma + 1) : result);
39+
};
40+
reader.readAsDataURL(file);
41+
});
42+
}
43+
44+
export function ImageRedactionPanel({ available }: Readonly<{ available: boolean }>) {
45+
const inputId = useId();
46+
const purposeId = useId();
47+
const fileRef = useRef<HTMLInputElement>(null);
48+
const [purpose, setPurpose] = useState('KYC document redaction');
49+
const [busy, setBusy] = useState(false);
50+
const [result, setResult] = useState<RedactionResponse | null>(null);
51+
const [error, setError] = useState<string | null>(null);
52+
53+
if (!available) {
54+
return (
55+
<div className="space-y-1 rounded-md border border-dashed border-border p-4">
56+
<div className="flex items-center gap-2">
57+
<ImageSquare className="size-4 text-muted-foreground" />
58+
<span className="text-sm font-medium">Document image redaction</span>
59+
<Badge variant="secondary">engine not deployed</Badge>
60+
</div>
61+
<p className="text-xs text-muted-foreground">
62+
Redacting PII from KYC document images (OVDs) needs the Presidio image-redactor service,
63+
which is not part of this deployment. Text masking above is fully live.
64+
</p>
65+
</div>
66+
);
67+
}
68+
69+
async function onRedact(): Promise<void> {
70+
setError(null);
71+
const file = fileRef.current?.files?.[0];
72+
if (!file) {
73+
setError('Choose a PNG or JPEG image first.');
74+
return;
75+
}
76+
const fileError = imageRedactionUploadError({ type: file.type, size: file.size });
77+
if (fileError) {
78+
setError(fileError);
79+
return;
80+
}
81+
if (purpose.trim().length < 3) {
82+
setError('Enter a purpose (why this document is being redacted) — 3 characters or more.');
83+
return;
84+
}
85+
setBusy(true);
86+
setResult(null);
87+
try {
88+
const imageBase64 = await fileToBase64(file);
89+
const res = await fetch('/api/v1/governance/image-redaction', {
90+
method: 'POST',
91+
headers: { 'content-type': 'application/json' },
92+
body: JSON.stringify({
93+
imageBase64,
94+
mediaType: imageRedactionMediaTypeFor(file.type),
95+
purpose: purpose.trim(),
96+
}),
97+
});
98+
const body = (await res.json().catch(() => ({}))) as Partial<RedactionResponse> & {
99+
error?: string;
100+
};
101+
if (!res.ok) {
102+
setError(body.error ?? `Redaction failed (HTTP ${res.status}).`);
103+
return;
104+
}
105+
setResult(body as RedactionResponse);
106+
toast.success('Image redacted');
107+
} catch {
108+
setError('Could not reach the redaction service.');
109+
} finally {
110+
setBusy(false);
111+
}
112+
}
113+
114+
return (
115+
<div className="space-y-4 rounded-md border border-border p-4">
116+
<div className="flex items-center gap-2">
117+
<ImageSquare className="size-4 text-primary" />
118+
<span className="text-sm font-medium">Document image redaction</span>
119+
<Badge>available</Badge>
120+
</div>
121+
<p className="text-xs text-muted-foreground">
122+
Upload a KYC document image (an OVD scan). PII regions are detected by OCR and blacked out
123+
before the file moves; the original never leaves this request. Every redaction records a
124+
tamper-evident receipt in the audit log.
125+
</p>
126+
127+
<div className="grid gap-4 lg:grid-cols-[minmax(0,20rem)_1fr]">
128+
<div className="space-y-3">
129+
<div className="space-y-1.5">
130+
<Label htmlFor={inputId} className="text-xs">
131+
Document image (PNG or JPEG, up to 8 MiB)
132+
</Label>
133+
<Input
134+
id={inputId}
135+
ref={fileRef}
136+
type="file"
137+
accept="image/png,image/jpeg"
138+
className="text-xs"
139+
onChange={() => {
140+
setError(null);
141+
setResult(null);
142+
}}
143+
/>
144+
</div>
145+
<div className="space-y-1.5">
146+
<Label htmlFor={purposeId} className="text-xs">
147+
Purpose
148+
</Label>
149+
<Input
150+
id={purposeId}
151+
value={purpose}
152+
onChange={(e) => setPurpose(e.target.value)}
153+
placeholder="Why is this document being redacted?"
154+
className="text-xs"
155+
/>
156+
</div>
157+
<Button type="button" size="sm" onClick={onRedact} disabled={busy}>
158+
<Upload className="size-4" />
159+
{busy ? 'Redacting…' : 'Redact PII'}
160+
</Button>
161+
{error ? (
162+
<p role="alert" className="text-xs text-destructive">
163+
{error}
164+
</p>
165+
) : null}
166+
</div>
167+
168+
{result ? (
169+
<div className="space-y-3">
170+
<div className="flex items-center gap-2">
171+
<ShieldCheck className="size-4 text-primary" weight="fill" />
172+
<span className="text-sm font-medium">{summarizeRedactionResult(result.evidence)}</span>
173+
</div>
174+
{/* eslint-disable-next-line @next/next/no-img-element */}
175+
<img
176+
src={`data:${result.mediaType};base64,${result.redactedImageBase64}`}
177+
alt="Redacted document — PII regions blacked out"
178+
className="max-h-72 w-auto rounded border border-border"
179+
/>
180+
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
181+
<dt>Engine</dt>
182+
<dd className="text-foreground">
183+
{result.evidence.engine} {result.evidence.engineVersion}
184+
</dd>
185+
<dt>OCR</dt>
186+
<dd className="text-foreground">{result.evidence.ocrEngine}</dd>
187+
<dt>Detected</dt>
188+
<dd className="text-foreground">
189+
{result.evidence.entities.length
190+
? result.evidence.entities
191+
.map((e) => `${e.entityType} ×${e.count}`)
192+
.join(', ')
193+
: 'none'}
194+
</dd>
195+
<dt>Duration</dt>
196+
<dd className="text-foreground">{result.evidence.durationMs} ms</dd>
197+
<dt>Receipt</dt>
198+
<dd className="break-all font-mono text-[11px] text-foreground">
199+
{result.evidence.policy.receiptId}
200+
</dd>
201+
</dl>
202+
</div>
203+
) : (
204+
<div className="flex items-center justify-center rounded border border-dashed border-border p-6 text-xs text-muted-foreground">
205+
The redacted image and its evidence receipt appear here.
206+
</div>
207+
)}
208+
</div>
209+
</div>
210+
);
211+
}

src/components/guardrails/PresidioAnonymizers.tsx

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { ImageSquare, Plus, Trash } from '@phosphor-icons/react/dist/ssr';
3+
import { Plus, Trash } from '@phosphor-icons/react/dist/ssr';
44
import { useRouter } from 'next/navigation';
55
import { useId, useState } from 'react';
66
import { toast } from 'sonner';
@@ -18,6 +18,7 @@ import {
1818
TableRow,
1919
} from '@/components/ui/table';
2020
import { Textarea } from '@/components/ui/textarea';
21+
import { ImageRedactionPanel } from '@/components/guardrails/ImageRedactionPanel';
2122
import {
2223
ANONYMIZE_OPERATORS,
2324
type AnonymizeOperator,
@@ -252,21 +253,8 @@ export function PresidioAnonymizers({
252253
) : null}
253254
</div>
254255

255-
{/* Image redaction — honest capability signal */}
256-
<div className="space-y-1 rounded-md border border-dashed border-border p-4">
257-
<div className="flex items-center gap-2">
258-
<ImageSquare className="size-4 text-muted-foreground" />
259-
<span className="text-sm font-medium">Document image redaction</span>
260-
<Badge variant={imageRedactionAvailable ? 'default' : 'secondary'}>
261-
{imageRedactionAvailable ? 'available' : 'engine not deployed'}
262-
</Badge>
263-
</div>
264-
<p className="text-xs text-muted-foreground">
265-
{imageRedactionAvailable
266-
? 'Upload a KYC document image to redact PII regions before it moves.'
267-
: 'Redacting PII from KYC document IMAGES (OVDs) needs the Presidio image-redactor service, which is not part of this deployment. Text masking above is fully live.'}
268-
</p>
269-
</div>
256+
{/* Image redaction — the real upload → redact → review surface */}
257+
<ImageRedactionPanel available={imageRedactionAvailable} />
270258
</div>
271259
</div>
272260
);

src/lib/image-redaction.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,45 @@ export function summarizeImageRedactionEntities(
285285
.sort(([left], [right]) => left.localeCompare(right))
286286
.map(([entityType, evidence]) => ({ entityType, ...evidence }));
287287
}
288+
289+
// ─── Operator-UI PURE helpers (client-side, zero-IO) ────────────────────────────────────────────
290+
// The upload panel validates a chosen file BEFORE encoding + posting it, so an over-limit or
291+
// wrong-type file is rejected instantly with an honest message instead of a wasted round-trip. Same
292+
// limits the server enforces (IMAGE_REDACTION_LIMITS) — one source of truth, no drift.
293+
294+
/** Map a browser File.type to the supported media type, or null if unsupported. PURE. */
295+
export function imageRedactionMediaTypeFor(fileType: string): ImageRedactionMediaType | null {
296+
if (fileType === 'image/png') return 'image/png';
297+
if (fileType === 'image/jpeg' || fileType === 'image/jpg') return 'image/jpeg';
298+
return null;
299+
}
300+
301+
/**
302+
* Validate a chosen upload against the server's real limits. Returns an honest error string, or null
303+
* when the file is acceptable. PURE — takes the minimal {type,size} shape so it is unit-testable
304+
* without a DOM File.
305+
*/
306+
export function imageRedactionUploadError(file: { type: string; size: number }): string | null {
307+
if (!imageRedactionMediaTypeFor(file.type)) return 'Only PNG and JPEG images are supported.';
308+
if (file.size <= 0) return 'The file is empty.';
309+
if (file.size > IMAGE_REDACTION_LIMITS.maxBytes) {
310+
const mib = Math.round(IMAGE_REDACTION_LIMITS.maxBytes / (1024 * 1024));
311+
return `Image exceeds the ${mib} MiB limit.`;
312+
}
313+
return null;
314+
}
315+
316+
/**
317+
* A short human summary of what a redaction did, for the review panel. PURE.
318+
* • no entities → an honest "screened, nothing detected" (the image is still returned re-encoded).
319+
* • entities → "PAN ×1, PHONE_NUMBER ×2" style, most-frequent first.
320+
*/
321+
export function summarizeRedactionResult(evidence: ImageRedactionEvidence): string {
322+
const list = evidence.entities;
323+
if (!list.length) return 'Screened — no PII detected in the image.';
324+
const parts = [...list]
325+
.sort((a, b) => b.count - a.count || a.entityType.localeCompare(b.entityType))
326+
.map((e) => `${e.entityType} ×${e.count}`);
327+
const total = list.reduce((sum, e) => sum + e.count, 0);
328+
return `Redacted ${total} PII region${total === 1 ? '' : 's'}: ${parts.join(', ')}.`;
329+
}

src/lib/service-capabilities/runtime-governance-operations.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,18 +1670,18 @@ export const RUNTIME_GOVERNANCE_OPERATIONS_AUDITS = [
16701670
'image-redaction',
16711671
'Image PII redaction',
16721672
'Detect and redact PII in images and scanned documents.',
1673-
PRESIDIO_ROUTE,
1674-
'Inspect Presidio status',
1675-
'The deployed analyzer/anonymizer pair has no image-redactor adapter, API route, or review UI.',
1673+
'/governance/guardrails/masking',
1674+
'Redact a document image',
1675+
'Operator upload→redact→review is live and audited; no automatic app-step or pipeline invokes image redaction yet (a manual governed surface, not an in-line workflow step).',
16761676
[
16771677
'yes',
1678-
'Presidio provides an image redactor capability.',
1679-
'no',
1680-
'No image-redactor service or adapter is registered.',
1681-
'no',
1682-
'No image redaction UI exists.',
1683-
'no',
1684-
'No document or media workflow invokes Presidio image redaction.',
1678+
'Presidio image-redactor 0.0.59 deployed on g6 (offgrid-console-presidio-image-redactor-1, :5003), reached via the S1 :8940 tunnel.',
1679+
'yes',
1680+
'createPresidioImageRedactor (src/lib/adapters/presidio-image-redaction.ts) posts to /v1/redact, contract-checks the engine/OCR/media response, and verifies the output bytes differ from the input. Live-verified 2026-07-24: a KYC PNG through /api/v1/governance/image-redaction returned HTTP 200, image sha CHANGED (4661→3559 bytes), PHONE_NUMBER redacted, receipt imgred_… + governance.image.redact audit row (outcome redacted).',
1681+
'yes',
1682+
'ImageRedactionPanel (Governance → Guardrails → Masking): upload a PNG/JPEG, redact via the governed route, review the redacted image + detected entities + tamper-evident receipt. Availability resolves from the real adapter config, not a hardcoded flag.',
1683+
'partial',
1684+
'The governed API route + operator UI are live and audited, but no App step or pipeline stage invokes image redaction automatically on document ingress.',
16851685
],
16861686
),
16871687
],

0 commit comments

Comments
 (0)