feat: add sensitive data redaction to export (Phase C)

Server-side regex redaction masks IPs, emails, bearer/API tokens, and
UNC paths in exported session content. Redaction runs post-generation
and post-variable-resolution with fail-closed error handling. Frontend
gets a "Mask Sensitive Data" toggle in the export preview modal with
a summary of what was redacted. 24 unit tests passing, frontend build clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-14 00:11:20 -05:00
parent 1172c5394f
commit 303570ca2c
9 changed files with 427 additions and 45 deletions

View File

@@ -7,7 +7,7 @@ import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
import { StepRatingModal } from '@/components/session/StepRatingModal'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import type { Session, SessionExport, SaveAsTreeRequest, Step } from '@/types'
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
@@ -34,6 +34,8 @@ export function SessionDetailPage() {
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
const [includeSummary, setIncludeSummary] = useState(false)
const [redactionMode, setRedactionMode] = useState<'none' | 'mask'>('none')
const [redactionSummary, setRedactionSummary] = useState<RedactionSummary | null>(null)
useEffect(() => {
if (id) {
@@ -91,17 +93,22 @@ export function SessionDetailPage() {
return `session-${session.ticket_number || session.id}.${ext}`
}
const buildExportOptions = (overrides?: Partial<SessionExport>): SessionExport => ({
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
redaction_mode: redactionMode,
...overrides,
})
const fetchExportContent = async () => {
if (!session) return null
const options: SessionExport = {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
}
return await sessionsApi.export(session.id, options)
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions())
setRedactionSummary(result.redactionSummary)
return result.content
}
const handlePreview = async () => {
@@ -141,15 +148,7 @@ export function SessionDetailPage() {
const handleCopyForTicket = async () => {
if (!session) return
try {
const options: SessionExport = {
format: 'psa',
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
}
const content = await sessionsApi.export(session.id, options)
const content = await sessionsApi.export(session.id, buildExportOptions({ format: 'psa' }))
if (content) {
await navigator.clipboard.writeText(content)
setCopiedPsa(true)
@@ -178,20 +177,11 @@ export function SessionDetailPage() {
const handleToggleSummary = async (include: boolean) => {
setIncludeSummary(include)
if (!session) return
const options: SessionExport = {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: include,
}
try {
const content = await sessionsApi.export(session.id, options)
if (content) {
setExportContent(content)
toast.success(include ? 'Summary added' : 'Summary removed')
}
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions({ include_summary: include }))
setExportContent(result.content)
setRedactionSummary(result.redactionSummary)
toast.success(include ? 'Summary added' : 'Summary removed')
} catch (err) {
console.error('Failed to re-fetch export:', err)
toast.error('Failed to update export')
@@ -199,6 +189,22 @@ export function SessionDetailPage() {
}
}
const handleToggleRedaction = async (enabled: boolean) => {
const mode = enabled ? 'mask' as const : 'none' as const
setRedactionMode(mode)
if (!session) return
try {
const result = await sessionsApi.exportWithMeta(session.id, buildExportOptions({ redaction_mode: mode }))
setExportContent(result.content)
setRedactionSummary(result.redactionSummary)
toast.success(enabled ? 'Sensitive data masked' : 'Redaction removed')
} catch (err) {
console.error('Failed to re-fetch export:', err)
toast.error('Failed to update export')
setRedactionMode(enabled ? 'none' : 'mask')
}
}
const handleSaveAsTree = async (data: SaveAsTreeRequest) => {
if (!session) return
setIsSavingTree(true)
@@ -564,6 +570,9 @@ export function SessionDetailPage() {
onDownload={handleDownload}
includeSummary={includeSummary}
onToggleSummary={handleToggleSummary}
redactionEnabled={redactionMode === 'mask'}
onToggleRedaction={handleToggleRedaction}
redactionSummary={redactionSummary}
/>
{/* Save as Tree Modal */}