feat(privacy): S3 — privacy status surface + per-action disclosure UI (#4437) (#4849)

Co-authored-by: M3gA-Mind <elvin@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-07-14 22:45:00 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent 969b55df49
commit f416693233
32 changed files with 1853 additions and 8 deletions
@@ -0,0 +1,94 @@
import debug from 'debug';
import { useEffect } from 'react';
import { privacyModeLabelKey } from '../features/privacy/disclosureLabels';
import { useT } from '../lib/i18n/I18nContext';
import { useAppSelector } from '../store/hooks';
const pillLog = debug('privacy:pill');
interface PrivacyStatusIndicatorProps {
className?: string;
}
/**
* Persistent privacy-status pill (#4437 / S3). Mirrors {@link ConnectionIndicator}
* — an inline-flex chip with a coloured dot + tiny label. Shows the current
* Privacy Mode plus whether the *active task* is staying on-device or sending
* externally.
*
* The off-device sub-state is driven by `privacy.activeExternalByThread` — a
* flag set when an `external_transfer_pending` disclosure arrives and cleared
* on the turn boundary by ChatRuntimeProvider. It is deliberately NOT derived
* from the dismissible disclosure ledger: reading the ledger let a "Got it"
* dismissal flip the pill on-device mid-transfer, and let a stale historical
* entry pin it off-device during later local turns. "On-device" is therefore
* the ABSENCE of a live external transfer, never a positive "local" signal.
* Renders nothing (a self-nulling leading separator + chip) until the mode is
* hydrated so the pill is never misleading.
*/
const PrivacyStatusIndicator = ({ className = '' }: PrivacyStatusIndicatorProps) => {
const { t } = useT();
// Optional-chain the `privacy` slice — narrow test stores may omit it.
const privacyMode = useAppSelector(state => state.privacy?.privacyMode ?? null);
const selectedThreadId = useAppSelector(state => state.thread?.selectedThreadId ?? null);
const activeExternalByThread = useAppSelector(state => state.privacy?.activeExternalByThread);
// Local-only mode blocks external model calls (enforced core-side by S7), so
// the active task is always on-device there regardless of any stale flag.
const hasActiveExternalTransfer = selectedThreadId
? (activeExternalByThread?.[selectedThreadId] ?? false)
: false;
const localOnlyOverride = privacyMode === 'local_only';
const isExternal = !localOnlyOverride && hasActiveExternalTransfer;
// Diagnostics for the privacy-state flow (grep `privacy:pill`). Log only the
// derived booleans/status transitions — never provider payloads, disclosure
// contents, or user PII. Fires on transitions of the resolved state.
useEffect(() => {
pillLog(
'[privacy:pill] derive hydrated=%s mode=%s hasThread=%s localOnlyOverride=%s activeExternal=%s isExternal=%s',
String(privacyMode != null),
privacyMode ?? 'none',
String(selectedThreadId != null),
String(localOnlyOverride),
String(hasActiveExternalTransfer),
String(isExternal)
);
}, [privacyMode, selectedThreadId, localOnlyOverride, hasActiveExternalTransfer, isExternal]);
if (!privacyMode) return null;
const modeLabel = t(privacyModeLabelKey(privacyMode));
const stateLabel = isExternal ? t('privacy.status.external') : t('privacy.status.local');
const dotColor = isExternal ? 'bg-amber-500' : 'bg-sage-500';
const textColor = isExternal ? 'text-amber-500' : 'text-sage-500';
// The leading separator travels WITH the pill so the sidebar footer never
// renders a dangling `· ·` while the pill is un-hydrated (returns null). See
// AppSidebar — the version item owns the separator that follows the pill.
// Separator + chip are grouped in one inline-flex item so the footer's
// flex-wrap can never split the leading `·` onto its own line — they wrap
// together or not at all.
return (
<span className="inline-flex items-center gap-2">
<span aria-hidden="true" className="text-[10px] text-content-faint">
&middot;
</span>
<div
className={`inline-flex items-center gap-1.5 ${className}`}
role="status"
aria-label={`${t('privacy.status.ariaLabel')}: ${modeLabel} · ${stateLabel}`}
title={`${modeLabel} · ${stateLabel}`}>
<div className={`h-2 w-2 rounded-full ${dotColor} ${isExternal ? 'animate-pulse' : ''}`} />
<span className={`text-[10px] font-medium ${textColor}`}>
{modeLabel}
<span className="text-content-faint"> · </span>
{stateLabel}
</span>
</div>
</span>
);
};
export default PrivacyStatusIndicator;
@@ -0,0 +1,126 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { PrivacyDisclosure } from '../../store/privacySlice';
import { renderWithProviders } from '../../test/test-utils';
import PrivacyStatusIndicator from '../PrivacyStatusIndicator';
function disclosure(over?: Partial<PrivacyDisclosure>): PrivacyDisclosure {
return {
id: 'd1',
providerSlug: 'openai',
service: 'OpenAI',
isExternal: true,
reason: 'inference',
dataKinds: ['prompt'],
riskLevel: 'unknown',
riskCategories: [],
receivedAt: 0,
...over,
};
}
describe('PrivacyStatusIndicator (#4437 / S3)', () => {
it('renders nothing until the privacy mode is hydrated', () => {
const { container } = renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: { privacyMode: null, disclosuresByThread: {}, activeExternalByThread: {} },
},
});
expect(container.firstChild).toBeNull();
});
it('shows the mode + on-device state when no external transfer is active', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: { privacyMode: 'standard', disclosuresByThread: {}, activeExternalByThread: {} },
thread: { selectedThreadId: 'thread-1' },
},
});
const pill = screen.getByRole('status');
expect(pill).toHaveTextContent('Standard');
expect(pill).toHaveTextContent('On-device');
expect(pill).toHaveAttribute('title', 'Standard · On-device');
});
it('shows the off-device state when the active thread has a live external transfer', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: {
privacyMode: 'standard',
disclosuresByThread: {},
activeExternalByThread: { 'thread-1': true },
},
thread: { selectedThreadId: 'thread-1' },
},
});
const pill = screen.getByRole('status');
expect(pill).toHaveTextContent('Off-device');
expect(pill).toHaveAttribute('title', 'Standard · Off-device');
});
it('always reads on-device in local-only mode, even with a live external flag', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: {
privacyMode: 'local_only',
disclosuresByThread: {},
activeExternalByThread: { 'thread-1': true },
},
thread: { selectedThreadId: 'thread-1' },
},
});
const pill = screen.getByRole('status');
expect(pill).toHaveTextContent('Local-only');
expect(pill).toHaveTextContent('On-device');
});
it('ignores a live external transfer that belongs to a different thread', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: {
privacyMode: 'standard',
disclosuresByThread: {},
activeExternalByThread: { 'other-thread': true },
},
thread: { selectedThreadId: 'thread-1' },
},
});
expect(screen.getByRole('status')).toHaveTextContent('On-device');
});
// Regression (#4437 finding 1a): the pill's off-device state is driven by the
// live transfer flag, NOT the dismissible disclosure ledger — so it reads
// off-device even when the ledger has been emptied (e.g. the card dismissed)
// while the transfer is still active.
it('reads off-device from the live flag even when the disclosure ledger is empty', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: {
privacyMode: 'standard',
disclosuresByThread: {},
activeExternalByThread: { 'thread-1': true },
},
thread: { selectedThreadId: 'thread-1' },
},
});
expect(screen.getByRole('status')).toHaveTextContent('Off-device');
});
// Regression (#4437 finding 1b): a stale, un-dismissed ledger entry from an
// earlier turn must NOT keep the pill off-device once the turn boundary
// cleared the live flag. The pill ignores the ledger entirely.
it('stays on-device with a stale ledger entry once the live flag is cleared', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: {
privacyMode: 'standard',
disclosuresByThread: { 'thread-1': [disclosure()] },
activeExternalByThread: {},
},
thread: { selectedThreadId: 'thread-1' },
},
});
expect(screen.getByRole('status')).toHaveTextContent('On-device');
});
});
@@ -0,0 +1,82 @@
import type React from 'react';
import { dataKindLabelKey, reasonLabelKey } from '../../features/privacy/disclosureLabels';
import { useT } from '../../lib/i18n/I18nContext';
import { useAppDispatch } from '../../store/hooks';
import { dismissDisclosureForThread, type PrivacyDisclosure } from '../../store/privacySlice';
import Button from '../ui/Button';
interface Props {
threadId: string;
disclosure: PrivacyDisclosure;
}
/**
* In-chat disclosure card for a pending external transfer (#4437 / S3).
*
* DISCLOSURE ONLY — it tells the user, at the moment of use, exactly what data
* is leaving the device, where to, and why (epic #4256 AC1). There is NO
* approve/deny arm; the only action is dismissal (that gate is S4 #4438). The
* card mirrors {@link ApprovalRequestCard} / `PlanReviewCard`: rendered above
* the composer for the active thread, off the privacy slice.
*/
export const ExternalTransferDisclosureCard: React.FC<Props> = ({ threadId, disclosure }) => {
const { t } = useT();
const dispatch = useAppDispatch();
// Friendly, comma-joined data-kind labels (never the raw enum). An empty
// list (metadata-only transfer) falls back to a generic "data" label so the
// sentence never reads "… send to …".
const kinds =
disclosure.dataKinds.length > 0
? disclosure.dataKinds
.map(kind => t(dataKindLabelKey(kind)))
.join(t('privacy.disclosure.kindSeparator'))
: t('privacy.disclosure.kind.unknown');
// Destination = human service name + the public provider slug for precision.
const destination = `${disclosure.service} (${disclosure.providerSlug})`;
const reason = t(reasonLabelKey(disclosure.reason));
// Single translatable sentence with {placeholders} — the I18n layer has no
// interpolation, so fill them here (keeps word order translatable per-locale).
const body = t('privacy.disclosure.body')
.replace('{kinds}', kinds)
.replace('{destination}', destination)
.replace('{reason}', reason);
const onDismiss = () => {
dispatch(dismissDisclosureForThread({ threadId, id: disclosure.id }));
};
return (
<div
role="status"
aria-label={t('privacy.disclosure.ariaLabel')}
className="rounded-xl border border-primary-200 bg-primary-50 p-3 text-sm shadow-sm dark:border-primary-800 dark:bg-primary-950">
<div className="flex items-start gap-2">
<span aria-hidden className="text-base leading-none text-primary-700 dark:text-primary-200">
🛜
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-primary-900 dark:text-primary-100">
{t('privacy.disclosure.title')}
</p>
<p className="mt-1 break-words text-primary-800/90 dark:text-primary-200/90">{body}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
data-analytics-id="privacy-disclosure-dismiss"
onClick={onDismiss}>
{t('privacy.disclosure.dismiss')}
</Button>
</div>
</div>
</div>
</div>
);
};
export default ExternalTransferDisclosureCard;
@@ -0,0 +1,73 @@
import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { PrivacyDisclosure } from '../../../store/privacySlice';
import { renderWithProviders } from '../../../test/test-utils';
import { ExternalTransferDisclosureCard } from '../ExternalTransferDisclosureCard';
function disclosure(over?: Partial<PrivacyDisclosure>): PrivacyDisclosure {
return {
id: 'd1',
providerSlug: 'openai',
service: 'OpenAI',
isExternal: true,
reason: 'inference',
dataKinds: ['prompt'],
riskLevel: 'unknown',
riskCategories: [],
receivedAt: 0,
...over,
};
}
function renderCard(d: PrivacyDisclosure) {
return renderWithProviders(
<ExternalTransferDisclosureCard threadId="thread-1" disclosure={d} />,
{
preloadedState: {
privacy: { privacyMode: 'standard', disclosuresByThread: { 'thread-1': [d] } },
},
}
);
}
describe('ExternalTransferDisclosureCard (#4437 / S3)', () => {
it('renders the title and a friendly what/where/why sentence', () => {
renderCard(disclosure());
const card = screen.getByRole('status');
expect(card).toHaveTextContent('Leaving your device');
expect(card).toHaveTextContent(
'This will send your message to OpenAI (openai) because the AI model needs to process it.'
);
});
it('joins multiple data kinds with friendly labels (never raw enums)', () => {
renderCard(disclosure({ dataKinds: ['prompt', 'metadata'] }));
const card = screen.getByRole('status');
expect(card).toHaveTextContent('your message, request metadata');
expect(card).not.toHaveTextContent('tool_arguments');
});
it('falls back to a generic label when data kinds are empty', () => {
renderCard(disclosure({ dataKinds: [] }));
expect(screen.getByRole('status')).toHaveTextContent('This will send data to OpenAI (openai)');
});
it('maps each reason to friendly copy', () => {
renderCard(disclosure({ reason: 'network_fetch' }));
expect(screen.getByRole('status')).toHaveTextContent('because a web request needs it');
});
it('is disclosure-only — no approve/deny buttons', () => {
renderCard(disclosure());
expect(screen.queryByRole('button', { name: /approve/i })).toBeNull();
expect(screen.queryByRole('button', { name: /deny/i })).toBeNull();
expect(screen.getByRole('button', { name: 'Got it' })).toBeInTheDocument();
});
it('dismisses the disclosure from the store on "Got it"', () => {
const { store } = renderCard(disclosure());
fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
expect(store.getState().privacy.disclosuresByThread['thread-1']).toBeUndefined();
});
});
@@ -4,6 +4,7 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
import { APP_VERSION } from '../../../utils/config';
import ConnectionIndicator from '../../ConnectionIndicator';
import PrivacyStatusIndicator from '../../PrivacyStatusIndicator';
import { NavIcon } from './navIcons';
import SidebarAppRail from './SidebarAppRail';
import SidebarHeader from './SidebarHeader';
@@ -84,9 +85,14 @@ export default function AppSidebar() {
<span className="min-w-0 truncate">{t('nav.feedback')}</span>
</button>
{/* App-wide footer: connectivity status + build/version, pinned to the
bottom of the sidebar. */}
<div className="flex flex-shrink-0 items-center justify-center gap-2 border-t border-line px-2 py-0.5">
bottom of the sidebar. The privacy pill is un-hydrated (renders null)
on cold boot, so its leading separator travels WITH the pill (owned by
PrivacyStatusIndicator) rather than sitting here — otherwise a null
pill would leave a dangling `Connection · · version`. The version
keeps the separator that precedes IT, which is always present. */}
<div className="flex flex-shrink-0 flex-wrap items-center justify-center gap-x-2 gap-y-0.5 border-t border-line px-2 py-0.5">
<ConnectionIndicator />
<PrivacyStatusIndicator />
&middot;
<span className="text-[10px] text-content-faint">
{t('settings.betaBuild').replace('{version}', APP_VERSION)}
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import PrivacyModeSection from './PrivacyModeSection';
const callCoreRpc = vi.fn();
@@ -23,7 +24,7 @@ beforeEach(() => {
describe('PrivacyModeSection', () => {
it('renders the three privacy mode options', async () => {
render(<PrivacyModeSection />);
renderWithProviders(<PrivacyModeSection />);
await waitFor(() =>
expect(screen.getByTestId('privacy-mode-option-standard')).toBeInTheDocument()
);
@@ -33,7 +34,7 @@ describe('PrivacyModeSection', () => {
});
it('marks the loaded mode as selected', async () => {
render(<PrivacyModeSection />);
renderWithProviders(<PrivacyModeSection />);
await waitFor(() =>
expect(screen.getByTestId('privacy-mode-option-standard')).toHaveAttribute(
'aria-checked',
@@ -47,7 +48,7 @@ describe('PrivacyModeSection', () => {
});
it('calls the set RPC with the chosen mode on selection', async () => {
render(<PrivacyModeSection />);
renderWithProviders(<PrivacyModeSection />);
await waitFor(() =>
expect(screen.getByTestId('privacy-mode-option-local_only')).toBeInTheDocument()
);
@@ -70,8 +71,26 @@ describe('PrivacyModeSection', () => {
);
});
// Finding 2 (#4437): saving a new mode must also push it into the Redux
// privacy slice so the persistent status pill updates live, instead of
// showing the stale mode until the next app reload.
it('dispatches the new mode into the privacy slice on a successful save', async () => {
const { store } = renderWithProviders(<PrivacyModeSection />);
await waitFor(() =>
expect(screen.getByTestId('privacy-mode-option-local_only')).toBeInTheDocument()
);
fireEvent.click(screen.getByTestId('privacy-mode-option-local_only'));
await waitFor(() =>
expect(
(store.getState() as { privacy: { privacyMode: string | null } }).privacy.privacyMode
).toBe('local_only')
);
});
it('does not re-issue the set RPC when the current mode is clicked', async () => {
render(<PrivacyModeSection />);
renderWithProviders(<PrivacyModeSection />);
await waitFor(() =>
expect(screen.getByTestId('privacy-mode-option-standard')).toHaveAttribute(
'aria-checked',
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import { CORE_RPC_METHODS } from '../../../services/rpcMethods';
import { useAppDispatch } from '../../../store/hooks';
import { setPrivacyMode } from '../../../store/privacySlice';
import { SettingsSection, SettingsStatusLine } from '../controls';
const log = debug('privacy-mode');
@@ -35,6 +37,7 @@ const MODES: { value: PrivacyMode; labelKey: string; descKey: string }[] = [
*/
const PrivacyModeSection = () => {
const { t } = useT();
const dispatch = useAppDispatch();
const [mode, setMode] = useState<PrivacyMode | null>(null);
const [status, setStatus] = useState<Status>('loading');
const [error, setError] = useState<string | null>(null);
@@ -75,6 +78,11 @@ const PrivacyModeSection = () => {
params: { mode: next },
});
setMode(resp.result.mode);
// Keep the Redux privacy slice (and therefore the persistent status
// pill) in lock-step with the setting. Without this the pill shows the
// stale mode until the next app reload — the provider only hydrates the
// slice once on mount (#4437).
dispatch(setPrivacyMode(resp.result.mode));
setStatus('saved');
setTimeout(() => setStatus('idle'), 2000);
} catch (err) {
@@ -83,7 +91,7 @@ const PrivacyModeSection = () => {
setStatus('error');
}
},
[mode]
[mode, dispatch]
);
return (
@@ -11,6 +11,7 @@ import ChatComposer from '../../components/chat/ChatComposer';
import ChatFilesChip from '../../components/chat/ChatFilesChip';
import ChatNewWindowHero from '../../components/chat/ChatNewWindowHero';
import ComposerTokenStats from '../../components/chat/ComposerTokenStats';
import { ExternalTransferDisclosureCard } from '../../components/chat/ExternalTransferDisclosureCard';
import { FlowApprovalRequestCard } from '../../components/chat/FlowApprovalRequestCard';
import IntegrationConnectCard from '../../components/chat/IntegrationConnectCard';
import QueuedFollowups from '../../components/chat/QueuedFollowups';
@@ -113,6 +114,7 @@ import {
type ToolTimelineEntry,
} from '../../store/chatRuntimeSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import type { PrivacyDisclosure } from '../../store/privacySlice';
import { selectSocketStatus } from '../../store/socketSelectors';
import {
addMessageLocal,
@@ -230,6 +232,11 @@ interface ConversationsProps {
// avoiding spurious re-renders.
const EMPTY_ACTIVE_THREADS: Record<string, true> = {};
// Stable empty reference for the privacy disclosure map — the `privacy` slice
// may be absent from narrow test stores, so default to this shared object
// identity instead of throwing / re-rendering.
const EMPTY_DISCLOSURES: Record<string, PrivacyDisclosure[]> = {};
// Stable empty reference for the queued-follow-ups map, so the selector keeps
// the same identity when the slice field is absent (narrow test stores).
const EMPTY_QUEUED_FOLLOWUPS: Record<string, QueuedFollowup[]> = {};
@@ -405,6 +412,12 @@ const Conversations = ({
const pendingApprovalByThread = useAppSelector(
state => state.chatRuntime.pendingApprovalByThread
);
// External-transfer disclosures per thread (#4437 / S3). Read-only surface —
// the card discloses what's leaving the device; dismissal is the only action.
// Optional-chain + default: narrow test stores may omit the `privacy` slice.
const disclosuresByThread = useAppSelector(
state => state.privacy?.disclosuresByThread ?? EMPTY_DISCLOSURES
);
// Flow-approval surface (chat): a paused tinyflows run's gate, pushed via
// the `flow_approval_request` socket event. Not thread-scoped — the
// payload carries no `thread_id` — so it's tracked independently of the
@@ -2797,6 +2810,28 @@ const Conversations = ({
);
})()}
{(() => {
// External-transfer disclosure (#4437 / S3). Surface the most recent
// pending disclosure for the shown thread just above the composer,
// mirroring the approval-card placement so it's visible without
// scrolling. DISCLOSURE ONLY — dismissal is the only action.
const disclosureThreadId = selectedThreadId ?? firstActiveThreadId;
const disclosures = disclosureThreadId
? disclosuresByThread[disclosureThreadId]
: undefined;
const latest = disclosures?.[disclosures.length - 1];
if (!latest || !disclosureThreadId) return null;
return (
<div className="mb-2">
<ExternalTransferDisclosureCard
key={latest.id}
threadId={disclosureThreadId}
disclosure={latest}
/>
</div>
);
})()}
{/* Flow-approval surface (chat): actionable banner(s) for paused
tinyflows runs, pushed via the `flow_approval_request` socket
event (issue: flow-approval surfacing). Not gated on the selected
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import type { EgressDataKind, EgressReason } from '../../../services/chatService';
import type { PrivacyMode } from '../../../store/privacySlice';
import { dataKindLabelKey, privacyModeLabelKey, reasonLabelKey } from '../disclosureLabels';
describe('disclosureLabels', () => {
it('maps every data kind to a distinct friendly i18n key', () => {
const kinds: EgressDataKind[] = [
'prompt',
'tool_arguments',
'embedding_input',
'file_content',
'url',
'metadata',
];
const keys = kinds.map(dataKindLabelKey);
expect(keys).toEqual([
'privacy.disclosure.kind.prompt',
'privacy.disclosure.kind.toolArguments',
'privacy.disclosure.kind.embeddingInput',
'privacy.disclosure.kind.fileContent',
'privacy.disclosure.kind.url',
'privacy.disclosure.kind.metadata',
]);
// No collisions.
expect(new Set(keys).size).toBe(keys.length);
});
it('falls back to the generic kind key for an unknown value', () => {
expect(dataKindLabelKey('some_future_kind')).toBe('privacy.disclosure.kind.unknown');
});
it('maps every reason to a distinct friendly i18n key', () => {
const reasons: EgressReason[] = [
'inference',
'tool_call',
'integration',
'embedding',
'network_fetch',
];
const keys = reasons.map(reasonLabelKey);
expect(keys).toEqual([
'privacy.disclosure.reason.inference',
'privacy.disclosure.reason.toolCall',
'privacy.disclosure.reason.integration',
'privacy.disclosure.reason.embedding',
'privacy.disclosure.reason.networkFetch',
]);
expect(new Set(keys).size).toBe(keys.length);
});
it('falls back to the generic reason key for an unknown value', () => {
expect(reasonLabelKey('some_future_reason')).toBe('privacy.disclosure.reason.unknown');
});
it('maps every privacy mode to its label key', () => {
const modes: PrivacyMode[] = ['local_only', 'standard', 'sensitive'];
expect(modes.map(privacyModeLabelKey)).toEqual([
'privacy.mode.localOnly',
'privacy.mode.standard',
'privacy.mode.sensitive',
]);
});
it('falls back to the standard mode key for an unexpected mode value', () => {
// Guards the always-visible pill against `t(undefined)` if the core ever
// emits a mode string the client does not yet know (#4437 finding 5).
expect(privacyModeLabelKey('some_future_mode')).toBe('privacy.mode.standard');
});
});
@@ -0,0 +1,61 @@
import type { EgressDataKind, EgressReason } from '../../services/chatService';
import type { PrivacyMode } from '../../store/privacySlice';
/**
* Map the snake_case egress enums to friendly, human-readable i18n keys so the
* disclosure surface never renders raw wire strings (#4437 / S3). Each mapper
* tolerates unknown/future values by falling back to a generic key.
*/
export function dataKindLabelKey(kind: EgressDataKind | string): string {
switch (kind) {
case 'prompt':
return 'privacy.disclosure.kind.prompt';
case 'tool_arguments':
return 'privacy.disclosure.kind.toolArguments';
case 'embedding_input':
return 'privacy.disclosure.kind.embeddingInput';
case 'file_content':
return 'privacy.disclosure.kind.fileContent';
case 'url':
return 'privacy.disclosure.kind.url';
case 'metadata':
return 'privacy.disclosure.kind.metadata';
default:
return 'privacy.disclosure.kind.unknown';
}
}
export function reasonLabelKey(reason: EgressReason | string): string {
switch (reason) {
case 'inference':
return 'privacy.disclosure.reason.inference';
case 'tool_call':
return 'privacy.disclosure.reason.toolCall';
case 'integration':
return 'privacy.disclosure.reason.integration';
case 'embedding':
return 'privacy.disclosure.reason.embedding';
case 'network_fetch':
return 'privacy.disclosure.reason.networkFetch';
default:
return 'privacy.disclosure.reason.unknown';
}
}
export function privacyModeLabelKey(mode: PrivacyMode | string): string {
switch (mode) {
case 'local_only':
return 'privacy.mode.localOnly';
case 'standard':
return 'privacy.mode.standard';
case 'sensitive':
return 'privacy.mode.sensitive';
// The pill is always-visible, so an unexpected/future mode string must
// still resolve to a real i18n key — never `undefined`, which `t()` would
// choke on. Fall back to the neutral "Standard" label, matching the sibling
// dataKind/reason mappers.
default:
return 'privacy.mode.standard';
}
}
+23
View File
@@ -7070,6 +7070,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'حذف',
'flows.delete.deleting': 'جارٍ الحذف…',
'flows.canvas.renameLabel': 'إعادة تسمية سير العمل',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'حالة الخصوصية',
'privacy.status.external': 'خارج الجهاز',
'privacy.status.local': 'على الجهاز',
'privacy.disclosure.title': 'يغادر جهازك',
'privacy.disclosure.body': 'سيؤدي هذا إلى إرسال {kinds} إلى {destination} لأن {reason}.',
'privacy.disclosure.dismiss': 'حسنًا',
'privacy.disclosure.ariaLabel': 'إفصاح عن البيانات الخارجية',
'privacy.disclosure.kindSeparator': '، ',
'privacy.disclosure.kind.prompt': 'رسالتك',
'privacy.disclosure.kind.toolArguments': 'مدخلات الأداة',
'privacy.disclosure.kind.embeddingInput': 'نص للفهرسة',
'privacy.disclosure.kind.fileContent': 'محتويات الملف',
'privacy.disclosure.kind.url': 'عنوان ويب',
'privacy.disclosure.kind.metadata': 'بيانات وصفية للطلب',
'privacy.disclosure.kind.unknown': 'بيانات',
'privacy.disclosure.reason.inference': 'نموذج الذكاء الاصطناعي يحتاج إلى معالجته',
'privacy.disclosure.reason.toolCall': 'أداة تحتاج إليه',
'privacy.disclosure.reason.integration': 'تكامل متصل يحتاج إليه',
'privacy.disclosure.reason.embedding': 'يجب فهرسته للبحث',
'privacy.disclosure.reason.networkFetch': 'طلب ويب يحتاج إليه',
'privacy.disclosure.reason.unknown': 'إنه مطلوب لهذا الإجراء',
};
export default messages;
+23
View File
@@ -7234,6 +7234,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'মুছুন',
'flows.delete.deleting': 'মুছে ফেলা হচ্ছে…',
'flows.canvas.renameLabel': 'ওয়ার্কফ্লো পুনঃনামকরণ করুন',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'গোপনীয়তা স্থিতি',
'privacy.status.external': 'ডিভাইসের বাইরে',
'privacy.status.local': 'ডিভাইসে',
'privacy.disclosure.title': 'আপনার ডিভাইস ছেড়ে যাচ্ছে',
'privacy.disclosure.body': 'এটি {kinds} কে {destination}-এ পাঠাবে কারণ {reason}।',
'privacy.disclosure.dismiss': 'বুঝেছি',
'privacy.disclosure.ariaLabel': 'বাহ্যিক ডেটা প্রকাশ',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'আপনার বার্তা',
'privacy.disclosure.kind.toolArguments': 'টুল ইনপুট',
'privacy.disclosure.kind.embeddingInput': 'সূচিবদ্ধ করার পাঠ্য',
'privacy.disclosure.kind.fileContent': 'ফাইলের বিষয়বস্তু',
'privacy.disclosure.kind.url': 'একটি ওয়েব ঠিকানা',
'privacy.disclosure.kind.metadata': 'অনুরোধের মেটাডেটা',
'privacy.disclosure.kind.unknown': 'ডেটা',
'privacy.disclosure.reason.inference': 'AI মডেলটিকে এটি প্রক্রিয়া করতে হবে',
'privacy.disclosure.reason.toolCall': 'একটি টুলের এটি প্রয়োজন',
'privacy.disclosure.reason.integration': 'একটি সংযুক্ত ইন্টিগ্রেশনের এটি প্রয়োজন',
'privacy.disclosure.reason.embedding': 'এটি অনুসন্ধানের জন্য সূচিবদ্ধ করা প্রয়োজন',
'privacy.disclosure.reason.networkFetch': 'একটি ওয়েব অনুরোধের এটি প্রয়োজন',
'privacy.disclosure.reason.unknown': 'এটি এই ক্রিয়ার জন্য প্রয়োজন',
};
export default messages;
+23
View File
@@ -7449,6 +7449,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Löschen',
'flows.delete.deleting': 'Wird gelöscht…',
'flows.canvas.renameLabel': 'Workflow umbenennen',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'Datenschutzstatus',
'privacy.status.external': 'Außerhalb des Geräts',
'privacy.status.local': 'Auf dem Gerät',
'privacy.disclosure.title': 'Verlässt Ihr Gerät',
'privacy.disclosure.body': 'Dadurch werden {kinds} an {destination} gesendet, weil {reason}.',
'privacy.disclosure.dismiss': 'Verstanden',
'privacy.disclosure.ariaLabel': 'Offenlegung externer Daten',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'Ihre Nachricht',
'privacy.disclosure.kind.toolArguments': 'Werkzeug-Eingaben',
'privacy.disclosure.kind.embeddingInput': 'zu indexierender Text',
'privacy.disclosure.kind.fileContent': 'Dateiinhalte',
'privacy.disclosure.kind.url': 'eine Webadresse',
'privacy.disclosure.kind.metadata': 'Anfrage-Metadaten',
'privacy.disclosure.kind.unknown': 'Daten',
'privacy.disclosure.reason.inference': 'das KI-Modell es verarbeiten muss',
'privacy.disclosure.reason.toolCall': 'ein Werkzeug es benötigt',
'privacy.disclosure.reason.integration': 'eine verbundene Integration es benötigt',
'privacy.disclosure.reason.embedding': 'es für die Suche indexiert werden muss',
'privacy.disclosure.reason.networkFetch': 'eine Webanfrage es benötigt',
'privacy.disclosure.reason.unknown': 'es für diese Aktion erforderlich ist',
};
export default messages;
+23
View File
@@ -1713,6 +1713,29 @@ const en: TranslationMap = {
'privacy.mode.saved': 'Saved',
'privacy.mode.saveError': 'Could not update privacy mode.',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'Privacy status',
'privacy.status.external': 'Off-device',
'privacy.status.local': 'On-device',
'privacy.disclosure.title': 'Leaving your device',
'privacy.disclosure.body': 'This will send {kinds} to {destination} because {reason}.',
'privacy.disclosure.dismiss': 'Got it',
'privacy.disclosure.ariaLabel': 'External data disclosure',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'your message',
'privacy.disclosure.kind.toolArguments': 'tool inputs',
'privacy.disclosure.kind.embeddingInput': 'text to index',
'privacy.disclosure.kind.fileContent': 'file contents',
'privacy.disclosure.kind.url': 'a web address',
'privacy.disclosure.kind.metadata': 'request metadata',
'privacy.disclosure.kind.unknown': 'data',
'privacy.disclosure.reason.inference': 'the AI model needs to process it',
'privacy.disclosure.reason.toolCall': 'a tool needs it',
'privacy.disclosure.reason.integration': 'a connected integration needs it',
'privacy.disclosure.reason.embedding': 'it needs to be indexed for search',
'privacy.disclosure.reason.networkFetch': 'a web request needs it',
'privacy.disclosure.reason.unknown': 'it is required for this action',
// Settings: About
'settings.about.version': 'Version',
'settings.about.updateAvailable': 'is available',
+23
View File
@@ -7383,6 +7383,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Eliminar',
'flows.delete.deleting': 'Eliminando…',
'flows.canvas.renameLabel': 'Cambiar el nombre del flujo de trabajo',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'Estado de privacidad',
'privacy.status.external': 'Fuera del dispositivo',
'privacy.status.local': 'En el dispositivo',
'privacy.disclosure.title': 'Sale de tu dispositivo',
'privacy.disclosure.body': 'Esto enviará {kinds} a {destination} porque {reason}.',
'privacy.disclosure.dismiss': 'Entendido',
'privacy.disclosure.ariaLabel': 'Divulgación de datos externos',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'tu mensaje',
'privacy.disclosure.kind.toolArguments': 'entradas de la herramienta',
'privacy.disclosure.kind.embeddingInput': 'texto para indexar',
'privacy.disclosure.kind.fileContent': 'contenido del archivo',
'privacy.disclosure.kind.url': 'una dirección web',
'privacy.disclosure.kind.metadata': 'metadatos de la solicitud',
'privacy.disclosure.kind.unknown': 'datos',
'privacy.disclosure.reason.inference': 'el modelo de IA necesita procesarlo',
'privacy.disclosure.reason.toolCall': 'una herramienta lo necesita',
'privacy.disclosure.reason.integration': 'una integración conectada lo necesita',
'privacy.disclosure.reason.embedding': 'necesita indexarse para la búsqueda',
'privacy.disclosure.reason.networkFetch': 'una solicitud web lo necesita',
'privacy.disclosure.reason.unknown': 'es necesario para esta acción',
};
export default messages;
+23
View File
@@ -7417,6 +7417,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Supprimer',
'flows.delete.deleting': 'Suppression…',
'flows.canvas.renameLabel': 'Renommer le workflow',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'État de confidentialité',
'privacy.status.external': 'Hors de lappareil',
'privacy.status.local': 'Sur lappareil',
'privacy.disclosure.title': 'Quitte votre appareil',
'privacy.disclosure.body': 'Ceci enverra {kinds} vers {destination} car {reason}.',
'privacy.disclosure.dismiss': 'Compris',
'privacy.disclosure.ariaLabel': 'Divulgation de données externes',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'votre message',
'privacy.disclosure.kind.toolArguments': 'les entrées de loutil',
'privacy.disclosure.kind.embeddingInput': 'le texte à indexer',
'privacy.disclosure.kind.fileContent': 'le contenu du fichier',
'privacy.disclosure.kind.url': 'une adresse web',
'privacy.disclosure.kind.metadata': 'les métadonnées de la requête',
'privacy.disclosure.kind.unknown': 'des données',
'privacy.disclosure.reason.inference': 'le modèle dIA doit le traiter',
'privacy.disclosure.reason.toolCall': 'un outil en a besoin',
'privacy.disclosure.reason.integration': 'une intégration connectée en a besoin',
'privacy.disclosure.reason.embedding': 'il doit être indexé pour la recherche',
'privacy.disclosure.reason.networkFetch': 'une requête web en a besoin',
'privacy.disclosure.reason.unknown': 'il est requis pour cette action',
};
export default messages;
+23
View File
@@ -7232,6 +7232,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'हटाएं',
'flows.delete.deleting': 'हटाया जा रहा है…',
'flows.canvas.renameLabel': 'वर्कफ़्लो का नाम बदलें',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'गोपनीयता स्थिति',
'privacy.status.external': 'डिवाइस के बाहर',
'privacy.status.local': 'डिवाइस पर',
'privacy.disclosure.title': 'आपके डिवाइस से बाहर जा रहा है',
'privacy.disclosure.body': 'यह {kinds} को {destination} पर भेजेगा क्योंकि {reason}।',
'privacy.disclosure.dismiss': 'समझ गया',
'privacy.disclosure.ariaLabel': 'बाहरी डेटा प्रकटीकरण',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'आपका संदेश',
'privacy.disclosure.kind.toolArguments': 'टूल इनपुट',
'privacy.disclosure.kind.embeddingInput': 'अनुक्रमित करने के लिए पाठ',
'privacy.disclosure.kind.fileContent': 'फ़ाइल सामग्री',
'privacy.disclosure.kind.url': 'एक वेब पता',
'privacy.disclosure.kind.metadata': 'अनुरोध मेटाडेटा',
'privacy.disclosure.kind.unknown': 'डेटा',
'privacy.disclosure.reason.inference': 'AI मॉडल को इसे संसाधित करना है',
'privacy.disclosure.reason.toolCall': 'एक टूल को इसकी ज़रूरत है',
'privacy.disclosure.reason.integration': 'एक कनेक्टेड इंटीग्रेशन को इसकी ज़रूरत है',
'privacy.disclosure.reason.embedding': 'इसे खोज के लिए अनुक्रमित करना आवश्यक है',
'privacy.disclosure.reason.networkFetch': 'एक वेब अनुरोध को इसकी ज़रूरत है',
'privacy.disclosure.reason.unknown': 'यह इस क्रिया के लिए आवश्यक है',
};
export default messages;
+23
View File
@@ -7266,6 +7266,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Hapus',
'flows.delete.deleting': 'Menghapus…',
'flows.canvas.renameLabel': 'Ganti nama alur kerja',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'Status privasi',
'privacy.status.external': 'Di luar perangkat',
'privacy.status.local': 'Di perangkat',
'privacy.disclosure.title': 'Meninggalkan perangkat Anda',
'privacy.disclosure.body': 'Ini akan mengirim {kinds} ke {destination} karena {reason}.',
'privacy.disclosure.dismiss': 'Mengerti',
'privacy.disclosure.ariaLabel': 'Pengungkapan data eksternal',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'pesan Anda',
'privacy.disclosure.kind.toolArguments': 'masukan alat',
'privacy.disclosure.kind.embeddingInput': 'teks untuk diindeks',
'privacy.disclosure.kind.fileContent': 'isi berkas',
'privacy.disclosure.kind.url': 'alamat web',
'privacy.disclosure.kind.metadata': 'metadata permintaan',
'privacy.disclosure.kind.unknown': 'data',
'privacy.disclosure.reason.inference': 'model AI perlu memprosesnya',
'privacy.disclosure.reason.toolCall': 'sebuah alat memerlukannya',
'privacy.disclosure.reason.integration': 'integrasi yang terhubung memerlukannya',
'privacy.disclosure.reason.embedding': 'perlu diindeks untuk pencarian',
'privacy.disclosure.reason.networkFetch': 'permintaan web memerlukannya',
'privacy.disclosure.reason.unknown': 'diperlukan untuk tindakan ini',
};
export default messages;
+23
View File
@@ -7373,6 +7373,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Elimina',
'flows.delete.deleting': 'Eliminazione…',
'flows.canvas.renameLabel': 'Rinomina flusso di lavoro',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'Stato privacy',
'privacy.status.external': 'Fuori dal dispositivo',
'privacy.status.local': 'Sul dispositivo',
'privacy.disclosure.title': 'Sta lasciando il tuo dispositivo',
'privacy.disclosure.body': 'Questo invierà {kinds} a {destination} perché {reason}.',
'privacy.disclosure.dismiss': 'Ho capito',
'privacy.disclosure.ariaLabel': 'Informativa sul trasferimento di dati esterni',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'il tuo messaggio',
'privacy.disclosure.kind.toolArguments': 'input dello strumento',
'privacy.disclosure.kind.embeddingInput': 'testo da indicizzare',
'privacy.disclosure.kind.fileContent': 'contenuto del file',
'privacy.disclosure.kind.url': 'un indirizzo web',
'privacy.disclosure.kind.metadata': 'metadati della richiesta',
'privacy.disclosure.kind.unknown': 'dati',
'privacy.disclosure.reason.inference': 'il modello di IA deve elaborare i dati',
'privacy.disclosure.reason.toolCall': 'uno strumento ne ha bisogno',
'privacy.disclosure.reason.integration': 'unintegrazione collegata ne ha bisogno',
'privacy.disclosure.reason.embedding': 'i dati devono essere indicizzati per la ricerca',
'privacy.disclosure.reason.networkFetch': 'una richiesta web ne ha bisogno',
'privacy.disclosure.reason.unknown': 'è necessario per questa azione',
};
export default messages;
+24
View File
@@ -7152,6 +7152,30 @@ const messages: TranslationMap = {
'flows.delete.confirm': '삭제',
'flows.delete.deleting': '삭제 중…',
'flows.canvas.renameLabel': '워크플로 이름 바꾸기',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': '개인정보 상태',
'privacy.status.external': '기기 외',
'privacy.status.local': '기기 내',
'privacy.disclosure.title': '기기에서 나가는 중',
'privacy.disclosure.body':
'이 작업은 {destination}(으)로 다음 데이터를 보냅니다: {kinds}. ({reason})',
'privacy.disclosure.dismiss': '확인',
'privacy.disclosure.ariaLabel': '외부 데이터 공개',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': '메시지',
'privacy.disclosure.kind.toolArguments': '도구 입력값',
'privacy.disclosure.kind.embeddingInput': '색인할 텍스트',
'privacy.disclosure.kind.fileContent': '파일 내용',
'privacy.disclosure.kind.url': '웹 주소',
'privacy.disclosure.kind.metadata': '요청 메타데이터',
'privacy.disclosure.kind.unknown': '데이터',
'privacy.disclosure.reason.inference': 'AI 모델이 이를 처리해야 하기 때문에',
'privacy.disclosure.reason.toolCall': '도구가 이를 필요로 하기 때문에',
'privacy.disclosure.reason.integration': '연결된 통합 기능이 이를 필요로 하기 때문에',
'privacy.disclosure.reason.embedding': '검색을 위해 색인해야 하기 때문에',
'privacy.disclosure.reason.networkFetch': '웹 요청이 이를 필요로 하기 때문에',
'privacy.disclosure.reason.unknown': '이 작업에 필요하기 때문에',
};
export default messages;
+24
View File
@@ -7342,6 +7342,30 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Usuń',
'flows.delete.deleting': 'Usuwanie…',
'flows.canvas.renameLabel': 'Zmień nazwę przepływu pracy',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'Stan prywatności',
'privacy.status.external': 'Poza urządzeniem',
'privacy.status.local': 'Na urządzeniu',
'privacy.disclosure.title': 'Opuszcza Twoje urządzenie',
'privacy.disclosure.body':
'Spowoduje to wysłanie następujących danych do {destination}: {kinds}. Powód: {reason}.',
'privacy.disclosure.dismiss': 'Rozumiem',
'privacy.disclosure.ariaLabel': 'Ujawnienie danych zewnętrznych',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'Twoja wiadomość',
'privacy.disclosure.kind.toolArguments': 'dane wejściowe narzędzia',
'privacy.disclosure.kind.embeddingInput': 'tekst do zindeksowania',
'privacy.disclosure.kind.fileContent': 'zawartość pliku',
'privacy.disclosure.kind.url': 'adres internetowy',
'privacy.disclosure.kind.metadata': 'metadane żądania',
'privacy.disclosure.kind.unknown': 'dane',
'privacy.disclosure.reason.inference': 'model AI musi przetworzyć te dane',
'privacy.disclosure.reason.toolCall': 'narzędzie potrzebuje tych danych',
'privacy.disclosure.reason.integration': 'połączona integracja potrzebuje tych danych',
'privacy.disclosure.reason.embedding': 'te dane muszą zostać zindeksowane do wyszukiwania',
'privacy.disclosure.reason.networkFetch': 'żądanie sieciowe potrzebuje tych danych',
'privacy.disclosure.reason.unknown': 'jest to wymagane do tej akcji',
};
export default messages;
+23
View File
@@ -7356,6 +7356,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Excluir',
'flows.delete.deleting': 'Excluindo…',
'flows.canvas.renameLabel': 'Renomear fluxo de trabalho',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'Estado de privacidade',
'privacy.status.external': 'Fora do dispositivo',
'privacy.status.local': 'No dispositivo',
'privacy.disclosure.title': 'A sair do seu dispositivo',
'privacy.disclosure.body': 'Isto irá enviar {kinds} para {destination} porque {reason}.',
'privacy.disclosure.dismiss': 'Entendido',
'privacy.disclosure.ariaLabel': 'Divulgação de dados externos',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'a sua mensagem',
'privacy.disclosure.kind.toolArguments': 'entradas da ferramenta',
'privacy.disclosure.kind.embeddingInput': 'texto para indexar',
'privacy.disclosure.kind.fileContent': 'conteúdo do ficheiro',
'privacy.disclosure.kind.url': 'um endereço web',
'privacy.disclosure.kind.metadata': 'metadados do pedido',
'privacy.disclosure.kind.unknown': 'dados',
'privacy.disclosure.reason.inference': 'o modelo de IA precisa de processar estes dados',
'privacy.disclosure.reason.toolCall': 'uma ferramenta precisa destes dados',
'privacy.disclosure.reason.integration': 'uma integração ligada precisa destes dados',
'privacy.disclosure.reason.embedding': 'estes dados precisam de ser indexados para pesquisa',
'privacy.disclosure.reason.networkFetch': 'um pedido web precisa destes dados',
'privacy.disclosure.reason.unknown': 'é necessário para esta ação',
};
export default messages;
+23
View File
@@ -7312,6 +7312,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': 'Удалить',
'flows.delete.deleting': 'Удаление…',
'flows.canvas.renameLabel': 'Переименовать рабочий процесс',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': 'Состояние конфиденциальности',
'privacy.status.external': 'Вне устройства',
'privacy.status.local': 'На устройстве',
'privacy.disclosure.title': 'Покидает ваше устройство',
'privacy.disclosure.body': 'Это отправит {kinds} на {destination}, потому что {reason}.',
'privacy.disclosure.dismiss': 'Понятно',
'privacy.disclosure.ariaLabel': 'Раскрытие внешних данных',
'privacy.disclosure.kindSeparator': ', ',
'privacy.disclosure.kind.prompt': 'ваше сообщение',
'privacy.disclosure.kind.toolArguments': 'входные данные инструмента',
'privacy.disclosure.kind.embeddingInput': 'текст для индексации',
'privacy.disclosure.kind.fileContent': 'содержимое файла',
'privacy.disclosure.kind.url': 'веб-адрес',
'privacy.disclosure.kind.metadata': 'метаданные запроса',
'privacy.disclosure.kind.unknown': 'данные',
'privacy.disclosure.reason.inference': 'модели ИИ нужно обработать эти данные',
'privacy.disclosure.reason.toolCall': 'инструменту это нужно',
'privacy.disclosure.reason.integration': 'подключённой интеграции это нужно',
'privacy.disclosure.reason.embedding': 'эти данные нужно проиндексировать для поиска',
'privacy.disclosure.reason.networkFetch': 'веб-запросу это нужно',
'privacy.disclosure.reason.unknown': 'это требуется для данного действия',
};
export default messages;
+23
View File
@@ -6843,6 +6843,29 @@ const messages: TranslationMap = {
'flows.delete.confirm': '删除',
'flows.delete.deleting': '正在删除…',
'flows.canvas.renameLabel': '重命名工作流',
// Privacy status pill + per-action egress disclosure (#4437 / S3)
'privacy.status.ariaLabel': '隐私状态',
'privacy.status.external': '设备外',
'privacy.status.local': '本地设备',
'privacy.disclosure.title': '正在离开你的设备',
'privacy.disclosure.body': '这将把 {kinds} 发送到 {destination},因为{reason}。',
'privacy.disclosure.dismiss': '知道了',
'privacy.disclosure.ariaLabel': '外部数据披露',
'privacy.disclosure.kindSeparator': '、',
'privacy.disclosure.kind.prompt': '你的消息',
'privacy.disclosure.kind.toolArguments': '工具输入',
'privacy.disclosure.kind.embeddingInput': '待索引的文本',
'privacy.disclosure.kind.fileContent': '文件内容',
'privacy.disclosure.kind.url': '一个网址',
'privacy.disclosure.kind.metadata': '请求元数据',
'privacy.disclosure.kind.unknown': '数据',
'privacy.disclosure.reason.inference': 'AI 模型需要处理它',
'privacy.disclosure.reason.toolCall': '某个工具需要它',
'privacy.disclosure.reason.integration': '已连接的集成需要它',
'privacy.disclosure.reason.embedding': '它需要被索引以供搜索',
'privacy.disclosure.reason.networkFetch': '网络请求需要它',
'privacy.disclosure.reason.unknown': '此操作需要它',
};
export default messages;
@@ -31,6 +31,7 @@ import chatRuntimeReducer, {
setTurnTimelinesForThread,
} from '../../store/chatRuntimeSlice';
import layoutReducer from '../../store/layoutSlice';
import privacyReducer, { type PrivacyDisclosure } from '../../store/privacySlice';
import socketReducer from '../../store/socketSlice';
import themeReducer from '../../store/themeSlice';
import threadReducer, { setSelectedThread } from '../../store/threadSlice';
@@ -202,6 +203,7 @@ function buildStore(preload: Record<string, unknown> = {}) {
chatRuntime: chatRuntimeReducer,
agentProfiles: agentProfileReducer,
theme: themeReducer,
privacy: privacyReducer,
}),
preloadedState: preload as never,
});
@@ -2712,3 +2714,105 @@ describe('Conversations — message list reserves room for the floating composer
}
});
});
// External-transfer disclosure surface (#4437 / S3). The card's own unit test
// covers the component in isolation; these drive the SELECTION path in
// Conversations — latest-per-thread, `firstActiveThreadId` fallback, and
// clear-after-dismissal — which the isolated card test cannot reach.
describe('Conversations — external-transfer disclosure surface (#4437 / S3)', () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
});
function disclosure(over: Partial<PrivacyDisclosure> = {}): PrivacyDisclosure {
return {
id: 'd1',
providerSlug: 'openai',
service: 'OpenAI',
isExternal: true,
reason: 'inference',
dataKinds: ['prompt'],
riskLevel: 'unknown',
riskCategories: [],
receivedAt: 0,
...over,
};
}
it('surfaces the latest disclosure for the selected thread', async () => {
const thread = makeThread({ id: 't-sel' });
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
await act(async () => {
await renderConversations({
thread: selectedThreadState(thread),
socket: socketState('connected'),
privacy: {
privacyMode: 'standard',
disclosuresByThread: {
't-sel': [
disclosure({ id: 'old', service: 'OldService' }),
disclosure({ id: 'new', service: 'OpenAI' }),
],
},
},
});
});
const title = await screen.findByText('Leaving your device');
const card = title.closest('[role="status"]');
expect(card).not.toBeNull();
// Latest wins — the newest disclosure's destination, not the older one.
expect(card).toHaveTextContent('OpenAI');
expect(card).not.toHaveTextContent('OldService');
});
it('falls back to firstActiveThreadId when no thread is selected', async () => {
const thread = makeThread({ id: 't-act' });
// Keep thread loading pending so the mount flow's auto-select can't fire. With a
// resolved load the sole thread `t-act` would be auto-selected, and the assertion
// would then pass through the normal selected-thread path instead of the intended
// `selectedThreadId ?? firstActiveThreadId` fallback (CR #4849). A pending load
// leaves `selectedThreadId` null, so the fallback is the ONLY way the card shows.
mockGetThreads.mockReturnValue(new Promise(() => {}));
let store: Awaited<ReturnType<typeof renderConversations>>;
await act(async () => {
store = await renderConversations({
// No selectedThreadId — only an active thread present.
thread: { ...emptyThreadState, threads: [thread], activeThreadIds: { 't-act': true } },
socket: socketState('connected'),
privacy: {
privacyMode: 'standard',
disclosuresByThread: { 't-act': [disclosure({ service: 'Anthropic' })] },
},
});
});
// Auto-selection must NOT have fired — the fallback branch is what surfaces the
// card, not the normal selected-thread path.
expect(store!.getState().thread.selectedThreadId).toBeNull();
const title = await screen.findByText('Leaving your device');
expect(title.closest('[role="status"]')).toHaveTextContent('Anthropic');
});
it('clears the card after dismissal', async () => {
const thread = makeThread({ id: 't-sel' });
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
await act(async () => {
await renderConversations({
thread: selectedThreadState(thread),
socket: socketState('connected'),
privacy: { privacyMode: 'standard', disclosuresByThread: { 't-sel': [disclosure()] } },
});
});
const title = await screen.findByText('Leaving your device');
const card = title.closest('[role="status"]') as HTMLElement;
await act(async () => {
fireEvent.click(within(card).getByRole('button', { name: 'Got it' }));
});
expect(screen.queryByText('Leaving your device')).toBeNull();
});
});
+44
View File
@@ -23,6 +23,7 @@ import {
type ChatTaskBoardUpdatedEvent,
type ChatToolCallEvent,
type ChatToolResultEvent,
type ExternalTransferPendingEvent,
type ProactiveMessageEvent,
segmentText,
subscribeChatEvents,
@@ -66,6 +67,12 @@ import {
type WorkflowProposal,
} from '../store/chatRuntimeSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
clearActiveExternalForThread,
disclosureFromEvent,
hydratePrivacyMode,
pushDisclosureForThread,
} from '../store/privacySlice';
import { selectSocketStatus } from '../store/socketSelectors';
import {
addInferenceResponse,
@@ -356,6 +363,13 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
// only — it never gates or cancels a turn.
const skillLatencyRef = useRef(createSkillToolChainLatencyTracker());
// Hydrate the current Privacy Mode once on mount so the persistent status
// pill can show the posture immediately (#4437 / S3). Failures resolve to
// null inside the thunk — the pill degrades gracefully.
useEffect(() => {
void dispatch(hydratePrivacyMode());
}, [dispatch]);
useEffect(() => {
toolTimelineRef.current = toolTimelineByThread;
}, [toolTimelineByThread]);
@@ -1057,6 +1071,25 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
})
);
},
onExternalTransferPending: (event: ExternalTransferPendingEvent) => {
// #4437 / S3 — DISCLOSURE ONLY. Project the egress descriptor onto the
// thread's privacy ledger so the in-chat card + status pill can render
// what/where/why. No approve/deny here (that's S4 #4438).
rtLog('external_transfer_pending', {
thread: event.thread_id,
provider: event.provider_slug,
service: event.service,
reason: event.reason,
kinds: event.data_kinds.length,
external: String(event.is_external),
});
dispatch(
pushDisclosureForThread({
threadId: event.thread_id,
disclosure: disclosureFromEvent(event),
})
);
},
onApprovalRequest: (event: ChatApprovalRequestEvent) => {
rtLog('approval_request', {
thread: event.thread_id,
@@ -1188,6 +1221,10 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
dispatch(clearStreamingAssistantForThread({ threadId: event.thread_id }));
dispatch(clearPendingApprovalForThread({ threadId: event.thread_id }));
dispatch(clearPendingPlanReviewForThread({ threadId: event.thread_id }));
// Turn boundary: this turn's external transfers are done, so return the
// privacy pill to on-device. The (un-dismissed) disclosure ledger stays
// for the in-chat card's history — only the live flag clears (#4437).
dispatch(clearActiveExternalForThread({ threadId: event.thread_id }));
const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
if (existing.length > 0) {
@@ -1340,6 +1377,9 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
dispatch(clearStreamingAssistantForThread({ threadId: event.thread_id }));
dispatch(clearPendingApprovalForThread({ threadId: event.thread_id }));
dispatch(clearPendingPlanReviewForThread({ threadId: event.thread_id }));
// Turn boundary (error path): clear the live external-transfer flag so
// the privacy pill resets to on-device, mirroring the done path (#4437).
dispatch(clearActiveExternalForThread({ threadId: event.thread_id }));
const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
if (existing.length > 0) {
@@ -1427,6 +1467,10 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
// can't complete.
dispatch(clearPendingApprovalForThread({ threadId }));
dispatch(clearPendingPlanReviewForThread({ threadId }));
// A disconnect tears the turn down without a done/error event, so clear
// the live external-transfer flag here too — otherwise the privacy pill
// would stay off-device for a turn that can never complete (#4437).
dispatch(clearActiveExternalForThread({ threadId }));
dispatch(endInferenceTurn({ threadId }));
}
// A disconnect kills every in-flight turn on the dead session, so clear all
@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { subscribeChatEvents } from '../chatService';
import { socketService } from '../socketService';
vi.mock('../socketService', () => ({
socketService: { getSocket: vi.fn(), on: vi.fn(), off: vi.fn() },
}));
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
type Handler = (...args: unknown[]) => void;
function createMockSocket() {
const handlers = new Map<string, Handler[]>();
const on = vi.fn((event: string, cb: Handler) => {
const existing = handlers.get(event) ?? [];
existing.push(cb);
handlers.set(event, existing);
});
const off = vi.fn((event: string, cb: Handler) => {
const existing = handlers.get(event) ?? [];
handlers.set(
event,
existing.filter(handler => handler !== cb)
);
});
const emit = (event: string, payload: unknown) => {
for (const handler of handlers.get(event) ?? []) handler(payload);
};
return { id: 'socket-1', on, off, emit };
}
function bindMockSocket(socket: ReturnType<typeof createMockSocket>) {
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
vi.mocked(socketService.on).mockImplementation((event, cb) => socket.on(event, cb as Handler));
vi.mocked(socketService.off).mockImplementation((event, cb) => socket.off(event, cb as Handler));
}
describe('chatService — external_transfer_pending handler (#4437 / S3)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('subscribes under the canonical snake_case event name', () => {
const socket = createMockSocket();
bindMockSocket(socket);
subscribeChatEvents({ onExternalTransferPending: () => {} });
const events = socket.on.mock.calls.map(call => call[0]);
expect(events).toEqual(['external_transfer_pending']);
});
it('flattens the wire envelope into a typed ExternalTransferPendingEvent', () => {
const socket = createMockSocket();
bindMockSocket(socket);
const onExternalTransferPending = vi.fn();
subscribeChatEvents({ onExternalTransferPending });
socket.emit('external_transfer_pending', {
thread_id: 'thread-1',
client_id: 'web-x',
args: {
provider_slug: 'openai',
service: 'OpenAI',
is_external: true,
reason: 'inference',
data_kinds: ['prompt', 'tool_arguments'],
risk_level: 'unknown',
risk_categories: [],
},
});
expect(onExternalTransferPending).toHaveBeenCalledTimes(1);
expect(onExternalTransferPending).toHaveBeenCalledWith({
thread_id: 'thread-1',
client_id: 'web-x',
provider_slug: 'openai',
service: 'OpenAI',
is_external: true,
reason: 'inference',
data_kinds: ['prompt', 'tool_arguments'],
risk_level: 'unknown',
risk_categories: [],
});
});
it('accepts an empty data_kinds array (metadata-only transfer)', () => {
const socket = createMockSocket();
bindMockSocket(socket);
const onExternalTransferPending = vi.fn();
subscribeChatEvents({ onExternalTransferPending });
socket.emit('external_transfer_pending', {
thread_id: 'thread-1',
args: {
provider_slug: 'composio',
service: 'Gmail',
is_external: true,
reason: 'integration',
data_kinds: [],
risk_level: 'unknown',
risk_categories: [],
},
});
expect(onExternalTransferPending).toHaveBeenCalledTimes(1);
expect(onExternalTransferPending.mock.calls[0]![0]).toMatchObject({
service: 'Gmail',
data_kinds: [],
});
});
it('defaults is_external to true and risk_level to unknown when absent/invalid', () => {
const socket = createMockSocket();
bindMockSocket(socket);
const onExternalTransferPending = vi.fn();
subscribeChatEvents({ onExternalTransferPending });
socket.emit('external_transfer_pending', {
thread_id: 'thread-1',
args: {
provider_slug: 'openai',
service: 'OpenAI',
// is_external omitted
reason: 'inference',
data_kinds: ['prompt'],
risk_level: 'not-a-real-level',
// risk_categories omitted
},
});
const event = onExternalTransferPending.mock.calls[0]![0] as {
is_external: boolean;
risk_level: string;
risk_categories: string[];
};
expect(event.is_external).toBe(true);
expect(event.risk_level).toBe('unknown');
expect(event.risk_categories).toEqual([]);
});
it('drops payloads missing load-bearing fields', () => {
const socket = createMockSocket();
bindMockSocket(socket);
const onExternalTransferPending = vi.fn();
subscribeChatEvents({ onExternalTransferPending });
// No args → bad envelope.
socket.emit('external_transfer_pending', { thread_id: 'thread-1' });
// Missing provider_slug.
socket.emit('external_transfer_pending', {
thread_id: 'thread-1',
args: { service: 'OpenAI', reason: 'inference', data_kinds: [] },
});
// Missing service.
socket.emit('external_transfer_pending', {
thread_id: 'thread-1',
args: { provider_slug: 'openai', reason: 'inference', data_kinds: [] },
});
// Missing reason.
socket.emit('external_transfer_pending', {
thread_id: 'thread-1',
args: { provider_slug: 'openai', service: 'OpenAI', data_kinds: [] },
});
// data_kinds not an array.
socket.emit('external_transfer_pending', {
thread_id: 'thread-1',
args: {
provider_slug: 'openai',
service: 'OpenAI',
reason: 'inference',
data_kinds: 'prompt',
},
});
// Missing thread_id → bad envelope.
socket.emit('external_transfer_pending', {
args: { provider_slug: 'openai', service: 'OpenAI', reason: 'inference', data_kinds: [] },
});
expect(onExternalTransferPending).not.toHaveBeenCalled();
});
it('removes the handler on cleanup', () => {
const socket = createMockSocket();
bindMockSocket(socket);
const cleanup = subscribeChatEvents({ onExternalTransferPending: () => {} });
cleanup();
const offEvents = socket.off.mock.calls.map(call => call[0]);
expect(offEvents).toEqual(['external_transfer_pending']);
});
});
+140
View File
@@ -291,6 +291,71 @@ export interface ArtifactPendingEvent {
path: string;
}
/**
* Reason an external transfer is happening — the "because" clause of the
* disclosure. Mirrors the Rust `EgressReason` serialized (snake_case) on the
* S2 egress spine's `external_transfer_pending` event descriptor. Unknown
* future variants are tolerated by the UI (mapped to a generic fallback label).
*/
export type EgressReason =
| 'inference'
| 'tool_call'
| 'integration'
| 'embedding'
| 'network_fetch';
/**
* A category of user data leaving the device on an external transfer. Mirrors
* the Rust `EgressDataKind` (snake_case). Rendered as friendly labels — never
* the raw enum string.
*/
export type EgressDataKind =
| 'prompt'
| 'tool_arguments'
| 'embedding_input'
| 'file_content'
| 'url'
| 'metadata';
/**
* Risk grade the core assigned to the transfer. Always `"unknown"` today
* (the S2 spine does not classify yet); typed for forward-compatibility so the
* S3 disclosure UI can surface a badge once the core starts grading.
*/
export type EgressRiskLevel = 'unknown' | 'none' | 'low' | 'medium' | 'high';
/**
* Emitted by the S2 egress spine (`external_transfer_pending` socket event)
* immediately before an external transfer that is routed through a chat turn
* (the bridge only emits when BOTH `thread_id` and `client_id` are present —
* background egress never surfaces). The Rust side de-dupes per turn so a
* repeated destination fires once per turn.
*
* This slice is DISCLOSURE ONLY (epic #4256 AC1 / issue #4437 / S3) — the S4
* approve/deny arm (#4438) is a separate follow-up. The descriptor is packed
* into the generic `args` field of the web-channel wire envelope, exactly like
* the artifact-lifecycle events; the `subscribeChatEvents` handler flattens it
* back into this typed shape.
*/
export interface ExternalTransferPendingEvent {
thread_id: string;
client_id?: string;
/** Provider identifier (e.g. `openai`, `composio`). Public, not PII. */
provider_slug: string;
/** Human-facing destination service name (e.g. `OpenAI`, `Gmail`). */
service: string;
/** Whether this transfer actually leaves the device (always true in practice). */
is_external: boolean;
/** Why the transfer is happening — drives the "because …" clause. */
reason: EgressReason;
/** Categories of user data being sent. */
data_kinds: EgressDataKind[];
/** Core-assigned risk grade. `"unknown"` today. */
risk_level: EgressRiskLevel;
/** Named risk categories. Empty today. */
risk_categories: string[];
}
/** Emitted when the agent turn begins (before the first LLM call). */
export interface ChatInferenceStartEvent {
thread_id: string;
@@ -558,6 +623,7 @@ export interface ChatEventListeners {
onArtifactPending?: (event: ArtifactPendingEvent) => void;
onArtifactReady?: (event: ArtifactReadyEvent) => void;
onArtifactFailed?: (event: ArtifactFailedEvent) => void;
onExternalTransferPending?: (event: ExternalTransferPendingEvent) => void;
onDone?: (event: ChatDoneEvent) => void;
onError?: (event: ChatErrorEvent) => void;
}
@@ -605,6 +671,7 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
artifactPending: 'artifact_pending',
artifactReady: 'artifact_ready',
artifactFailed: 'artifact_failed',
externalTransferPending: 'external_transfer_pending',
done: 'chat_done',
error: 'chat_error',
} as const;
@@ -1142,6 +1209,79 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
handlers.push([EVENTS.artifactFailed, cb]);
}
// External-transfer disclosure (#4437 / S3). Same envelope shape as the
// artifact events — the S2 egress spine packs the `EgressDescriptor` into
// the generic `args` field. Flatten it back into the typed
// `ExternalTransferPendingEvent` after narrowing every field, so a
// malformed / partial payload can never reach the disclosure card.
const isStringArray = (v: unknown): v is string[] =>
Array.isArray(v) && v.every(item => typeof item === 'string');
const validRiskLevels: ReadonlySet<EgressRiskLevel> = new Set([
'unknown',
'none',
'low',
'medium',
'high',
]);
const readRiskLevel = (v: unknown): EgressRiskLevel =>
typeof v === 'string' && validRiskLevels.has(v as EgressRiskLevel)
? (v as EgressRiskLevel)
: 'unknown';
if (listeners.onExternalTransferPending) {
const cb = (payload: unknown) => {
const env = readEnvelope(payload);
if (!env) {
chatLog('%s — skipping malformed payload (bad envelope)', EVENTS.externalTransferPending);
return;
}
const { args } = env;
// `provider_slug`, `service` and `reason` are the load-bearing display
// fields; without them the card can't say what/where/why, so drop the
// event rather than render blanks. `data_kinds` may legitimately be empty
// for a metadata-only transfer, so only require it to be an array.
if (
!isNonEmptyString(args.provider_slug) ||
!isNonEmptyString(args.service) ||
!isNonEmptyString(args.reason) ||
!isStringArray(args.data_kinds)
) {
chatLog(
'%s thread_id=%s — skipping malformed payload (bad args)',
EVENTS.externalTransferPending,
env.thread_id
);
return;
}
const event: ExternalTransferPendingEvent = {
thread_id: env.thread_id,
client_id: env.client_id,
provider_slug: args.provider_slug,
service: args.service,
// Defaults to true — the spine only emits for genuinely-external
// transfers, but tolerate a missing/non-boolean flag.
is_external: typeof args.is_external === 'boolean' ? args.is_external : true,
reason: args.reason as EgressReason,
data_kinds: args.data_kinds as EgressDataKind[],
risk_level: readRiskLevel(args.risk_level),
risk_categories: isStringArray(args.risk_categories) ? args.risk_categories : [],
};
chatLog(
'%s thread_id=%s provider=%s service=%s reason=%s kinds=%d external=%s',
EVENTS.externalTransferPending,
event.thread_id,
event.provider_slug,
event.service,
event.reason,
event.data_kinds.length,
event.is_external
);
listeners.onExternalTransferPending?.(event);
};
socket.on(EVENTS.externalTransferPending, cb);
handlers.push([EVENTS.externalTransferPending, cb]);
}
if (listeners.onTaskBoardUpdated) {
const cb = (payload: unknown) => {
const e = payload as ChatTaskBoardUpdatedEvent;
@@ -0,0 +1,226 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalTransferPendingEvent } from '../../services/chatService';
import { callCoreRpc } from '../../services/coreRpcClient';
import privacyReducer, {
clearActiveExternalForThread,
clearDisclosuresForThread,
disclosureFromEvent,
dismissDisclosureForThread,
hydratePrivacyMode,
type PrivacyDisclosure,
pushDisclosureForThread,
setPrivacyMode,
} from '../privacySlice';
import { resetUserScopedState } from '../resetActions';
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
const EVENT: ExternalTransferPendingEvent = {
thread_id: 'thread-1',
provider_slug: 'openai',
service: 'OpenAI',
is_external: true,
reason: 'inference',
data_kinds: ['prompt'],
risk_level: 'unknown',
risk_categories: [],
};
function initial() {
return privacyReducer(undefined, { type: '@@INIT' });
}
describe('privacySlice — reducers', () => {
it('has the expected initial state', () => {
expect(initial()).toEqual({
privacyMode: null,
disclosuresByThread: {},
activeExternalByThread: {},
});
});
it('setPrivacyMode updates the mode', () => {
const state = privacyReducer(initial(), setPrivacyMode('local_only'));
expect(state.privacyMode).toBe('local_only');
});
it('pushDisclosureForThread appends per thread', () => {
const d = disclosureFromEvent(EVENT);
const state = privacyReducer(
initial(),
pushDisclosureForThread({ threadId: 'thread-1', disclosure: d })
);
expect(state.disclosuresByThread['thread-1']).toHaveLength(1);
expect(state.disclosuresByThread['thread-1'][0]).toMatchObject({
providerSlug: 'openai',
service: 'OpenAI',
reason: 'inference',
dataKinds: ['prompt'],
isExternal: true,
});
});
it('caps the per-thread ledger at 20 entries (drops oldest)', () => {
let state = initial();
let firstId = '';
for (let i = 0; i < 25; i += 1) {
const d = disclosureFromEvent(EVENT);
if (i === 0) firstId = d.id;
state = privacyReducer(state, pushDisclosureForThread({ threadId: 't', disclosure: d }));
}
const list = state.disclosuresByThread['t'];
expect(list).toHaveLength(20);
// The very first (oldest) disclosure was evicted.
expect(list.some(entry => entry.id === firstId)).toBe(false);
});
it('dismissDisclosureForThread removes one entry by id', () => {
const a = disclosureFromEvent(EVENT);
const b = disclosureFromEvent(EVENT);
let state = privacyReducer(
initial(),
pushDisclosureForThread({ threadId: 'thread-1', disclosure: a })
);
state = privacyReducer(state, pushDisclosureForThread({ threadId: 'thread-1', disclosure: b }));
state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: a.id }));
expect(state.disclosuresByThread['thread-1']).toHaveLength(1);
expect(state.disclosuresByThread['thread-1'][0].id).toBe(b.id);
});
it('dismissing the last entry removes the thread key entirely', () => {
const a = disclosureFromEvent(EVENT);
let state = privacyReducer(
initial(),
pushDisclosureForThread({ threadId: 'thread-1', disclosure: a })
);
state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: a.id }));
expect(state.disclosuresByThread['thread-1']).toBeUndefined();
});
it('clearDisclosuresForThread drops all disclosures for a thread', () => {
const a = disclosureFromEvent(EVENT);
let state = privacyReducer(
initial(),
pushDisclosureForThread({ threadId: 'thread-1', disclosure: a })
);
state = privacyReducer(state, clearDisclosuresForThread({ threadId: 'thread-1' }));
expect(state.disclosuresByThread['thread-1']).toBeUndefined();
});
it('resetUserScopedState wipes disclosures, mode, and the active-external flags', () => {
let state = privacyReducer(initial(), setPrivacyMode('standard'));
state = privacyReducer(
state,
pushDisclosureForThread({ threadId: 't', disclosure: disclosureFromEvent(EVENT) })
);
expect(state.activeExternalByThread['t']).toBe(true);
state = privacyReducer(state, resetUserScopedState());
expect(state).toEqual({
privacyMode: null,
disclosuresByThread: {},
activeExternalByThread: {},
});
});
});
describe('privacySlice — active external-transfer flag (#4437 finding 1)', () => {
it('pushing an external disclosure marks the thread active-external', () => {
const state = privacyReducer(
initial(),
pushDisclosureForThread({ threadId: 'thread-1', disclosure: disclosureFromEvent(EVENT) })
);
expect(state.activeExternalByThread['thread-1']).toBe(true);
});
it('a non-external disclosure does NOT mark the thread active-external', () => {
const localEvent: ExternalTransferPendingEvent = { ...EVENT, is_external: false };
const state = privacyReducer(
initial(),
pushDisclosureForThread({ threadId: 'thread-1', disclosure: disclosureFromEvent(localEvent) })
);
expect(state.activeExternalByThread['thread-1']).toBeUndefined();
});
// Finding 1a: dismissing the card must NOT flip the pill off while the
// transfer is still active — the active flag survives dismissal.
it('dismissing the disclosure card leaves the active-external flag set', () => {
const d = disclosureFromEvent(EVENT);
let state = privacyReducer(
initial(),
pushDisclosureForThread({ threadId: 'thread-1', disclosure: d })
);
state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: d.id }));
// Ledger entry gone…
expect(state.disclosuresByThread['thread-1']).toBeUndefined();
// …but the live transfer flag remains until the turn boundary clears it.
expect(state.activeExternalByThread['thread-1']).toBe(true);
});
// Finding 1b: the turn boundary clears the active flag even though the
// (un-dismissed) ledger entry is still there for the card's history.
it('clearActiveExternalForThread clears the flag but keeps the ledger', () => {
const d = disclosureFromEvent(EVENT);
let state = privacyReducer(
initial(),
pushDisclosureForThread({ threadId: 'thread-1', disclosure: d })
);
state = privacyReducer(state, clearActiveExternalForThread({ threadId: 'thread-1' }));
expect(state.activeExternalByThread['thread-1']).toBeUndefined();
// The disclosure history is untouched — only the live flag was cleared.
expect(state.disclosuresByThread['thread-1']).toHaveLength(1);
});
it('clearActiveExternalForThread is a no-op for an unknown thread', () => {
const state = privacyReducer(initial(), clearActiveExternalForThread({ threadId: 'nope' }));
expect(state.activeExternalByThread).toEqual({});
});
});
describe('privacySlice — disclosureFromEvent', () => {
it('maps wire snake_case fields onto the disclosure and assigns unique ids', () => {
const a = disclosureFromEvent(EVENT);
const b = disclosureFromEvent(EVENT);
expect(a.id).not.toBe(b.id);
expect(a).toMatchObject({
providerSlug: 'openai',
service: 'OpenAI',
isExternal: true,
reason: 'inference',
dataKinds: ['prompt'],
riskLevel: 'unknown',
riskCategories: [],
});
expect(typeof a.receivedAt).toBe('number');
});
});
describe('privacySlice — hydratePrivacyMode thunk', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('sets the mode from the double-wrapped RPC result on success', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { mode: 'sensitive' } } as never);
const action = await hydratePrivacyMode()(vi.fn(), vi.fn(), undefined);
expect(action.payload).toBe('sensitive');
const state = privacyReducer(initial(), action as never);
expect(state.privacyMode).toBe('sensitive');
});
it('resolves to null (and leaves mode untouched) on RPC failure', async () => {
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('core down'));
const action = await hydratePrivacyMode()(vi.fn(), vi.fn(), undefined);
expect(action.payload).toBeNull();
const seeded = privacyReducer(initial(), setPrivacyMode('standard'));
const state = privacyReducer(seeded, action as never);
// Null payload must not clobber an already-known mode.
expect(state.privacyMode).toBe('standard');
});
});
// Type guard: exported PrivacyDisclosure stays structurally stable.
const _typecheck: PrivacyDisclosure = disclosureFromEvent(EVENT);
void _typecheck;
+5
View File
@@ -32,6 +32,7 @@ import localeReducer from './localeSlice';
import mascotReducer from './mascotSlice';
import notificationReducer from './notificationSlice';
import personaReducer from './personaSlice';
import privacyReducer from './privacySlice';
import providerSurfacesReducer from './providerSurfaceSlice';
import { pttReducer } from './pttSlice';
import socketReducer from './socketSlice';
@@ -237,6 +238,10 @@ export const store = configureStore({
locale: persistedLocaleReducer,
mascot: persistedMascotReducer,
persona: persistedPersonaReducer,
// Privacy disclosure surface (#4437 / S3). In-memory only: disclosures are
// ephemeral per-turn signals and the mode is re-hydrated from the core on
// boot, so nothing here should survive a restart.
privacy: privacyReducer,
theme: persistedThemeReducer,
ptt: persistedPttReducer,
announcement: persistedAnnouncementReducer,
+227
View File
@@ -0,0 +1,227 @@
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import debug from 'debug';
import type {
EgressDataKind,
EgressReason,
EgressRiskLevel,
ExternalTransferPendingEvent,
} from '../services/chatService';
import { callCoreRpc } from '../services/coreRpcClient';
import { CORE_RPC_METHODS } from '../services/rpcMethods';
import { resetUserScopedState } from './resetActions';
const privacyLog = debug('privacy:slice');
/**
* Privacy Mode values as serialized by the Rust core (snake_case). Mirrors the
* `PrivacyMode` type in {@link ../components/settings/panels/PrivacyModeSection}.
* The disclosure surface reads this to show the *current posture* alongside the
* per-action egress state — it does NOT own the setting (that stays in
* PrivacyModeSection); the slice is hydrated on boot and kept loosely in sync.
*/
export type PrivacyMode = 'local_only' | 'standard' | 'sensitive';
/**
* One external-transfer disclosure projected onto a thread (#4437 / S3). Built
* from an `external_transfer_pending` socket event. DISCLOSURE ONLY — there is
* no approve/deny decision here (that is S4 #4438); the only user action is
* dismissal.
*/
export interface PrivacyDisclosure {
/** Client-generated id — the dismissal handle and React key. */
id: string;
/** Provider identifier (e.g. `openai`). Public, not PII. */
providerSlug: string;
/** Human-facing destination service (e.g. `OpenAI`, `Gmail`). */
service: string;
/** Whether the transfer leaves the device (always true in practice). */
isExternal: boolean;
/** Why the transfer is happening. */
reason: EgressReason;
/** Categories of user data being sent. */
dataKinds: EgressDataKind[];
/** Core-assigned risk grade. `"unknown"` today. */
riskLevel: EgressRiskLevel;
/** Named risk categories. Empty today. */
riskCategories: string[];
/** When the disclosure was received, milliseconds since epoch. */
receivedAt: number;
}
interface PrivacyState {
/**
* Current data-egress posture. `null` until hydrated from the core (or if the
* RPC fails). Kept for the persistent status pill; not authoritative — the
* setting lives in the core and is edited via PrivacyModeSection.
*/
privacyMode: PrivacyMode | null;
/**
* Per-thread disclosure ledger, newest last. The disclosure card renders the
* most recent entry for the active thread; dismissal removes one entry by id.
*
* IMPORTANT: this ledger is user-DISMISSIBLE history for the in-chat card
* only. It MUST NOT drive the status pill's on/off-device sub-state — a
* dismissal removing the last entry would otherwise flip the pill to
* "on-device" while the transfer is still in flight, and an un-dismissed
* historical entry would keep it "off-device" during later purely-local
* turns. The pill reads {@link activeExternalByThread} instead.
*/
disclosuresByThread: Record<string, PrivacyDisclosure[]>;
/**
* Per-thread "an external transfer is active on the current turn" flag. Set
* true when an external disclosure is pushed, and CLEARED on the turn
* boundary (chat_done / chat_error / socket-disconnect reconcile) by
* ChatRuntimeProvider — the same turn-completion signals the approval/plan
* flows use. This is the SOLE source of truth for the pill's off-device
* state, kept deliberately separate from the dismissible ledger above so the
* pill reflects the live transfer, not the card's history.
*/
activeExternalByThread: Record<string, boolean>;
}
const initialState: PrivacyState = {
privacyMode: null,
disclosuresByThread: {},
activeExternalByThread: {},
};
/** Cap the per-thread ledger so a chatty turn can't grow it unbounded. */
const MAX_DISCLOSURES_PER_THREAD = 20;
let disclosureSeq = 0;
/**
* Build a {@link PrivacyDisclosure} from a socket event. Exported so the store
* wiring in {@link ../providers/ChatRuntimeProvider} and unit tests share one
* mapping. `id` is a monotonically-increasing client id (stable within a
* session, never sent anywhere).
*/
export function disclosureFromEvent(event: ExternalTransferPendingEvent): PrivacyDisclosure {
disclosureSeq += 1;
return {
id: `disclosure-${disclosureSeq}`,
providerSlug: event.provider_slug,
service: event.service,
isExternal: event.is_external,
reason: event.reason,
dataKinds: event.data_kinds,
riskLevel: event.risk_level,
riskCategories: event.risk_categories,
receivedAt: Date.now(),
};
}
/**
* Hydrate the current Privacy Mode from the core on boot. Mirrors the RPC
* PrivacyModeSection uses (`config_get_privacy_mode`) whose result is the
* double-wrapped `{ result: { mode } }` shape. Failures resolve to `null` so
* the pill degrades gracefully rather than throwing.
*/
export const hydratePrivacyMode = createAsyncThunk<PrivacyMode | null>(
'privacy/hydratePrivacyMode',
async () => {
try {
const resp = await callCoreRpc<{ result: { mode: PrivacyMode } }>({
method: CORE_RPC_METHODS.configGetPrivacyMode,
params: {},
});
privacyLog('[privacy] hydrated mode=%s', resp.result.mode);
return resp.result.mode;
} catch (err) {
privacyLog('[privacy] failed to hydrate privacy mode: %o', err);
return null;
}
}
);
const privacySlice = createSlice({
name: 'privacy',
initialState,
reducers: {
/** Set the current Privacy Mode (from boot hydration or a settings change). */
setPrivacyMode: (state, action: PayloadAction<PrivacyMode>) => {
privacyLog('[privacy] setPrivacyMode %s', action.payload);
state.privacyMode = action.payload;
},
/** Append a disclosure for a thread, capping the ledger length. */
pushDisclosureForThread: (
state,
action: PayloadAction<{ threadId: string; disclosure: PrivacyDisclosure }>
) => {
const { threadId, disclosure } = action.payload;
const list = (state.disclosuresByThread[threadId] ??= []);
list.push(disclosure);
if (list.length > MAX_DISCLOSURES_PER_THREAD) {
list.splice(0, list.length - MAX_DISCLOSURES_PER_THREAD);
}
// Mark the thread as having a live external transfer so the status pill
// flips off-device. This is independent of the dismissible ledger above:
// dismissing the card does NOT clear it (the transfer is still active) —
// only the turn-boundary `clearActiveExternalForThread` does.
if (disclosure.isExternal) {
state.activeExternalByThread[threadId] = true;
}
privacyLog(
'[privacy] pushDisclosureForThread thread=%s service=%s external=%s depth=%d',
threadId,
disclosure.service,
String(disclosure.isExternal),
list.length
);
},
/** Dismiss a single disclosure by id (the only user action in S3). */
dismissDisclosureForThread: (
state,
action: PayloadAction<{ threadId: string; id: string }>
) => {
const { threadId, id } = action.payload;
const list = state.disclosuresByThread[threadId];
if (!list) return;
const next = list.filter(d => d.id !== id);
if (next.length === 0) {
delete state.disclosuresByThread[threadId];
} else {
state.disclosuresByThread[threadId] = next;
}
privacyLog('[privacy] dismissDisclosureForThread thread=%s id=%s', threadId, id);
},
/** Clear all disclosures for a thread (e.g. on turn start / thread reset). */
clearDisclosuresForThread: (state, action: PayloadAction<{ threadId: string }>) => {
delete state.disclosuresByThread[action.payload.threadId];
privacyLog('[privacy] clearDisclosuresForThread thread=%s', action.payload.threadId);
},
/**
* Clear the live external-transfer flag for a thread. Dispatched by
* ChatRuntimeProvider on the turn boundary (chat_done / chat_error /
* disconnect reconcile) so the pill returns to on-device once the turn's
* external activity is over — even though the (un-dismissed) ledger entries
* remain for the in-chat card's history.
*/
clearActiveExternalForThread: (state, action: PayloadAction<{ threadId: string }>) => {
if (state.activeExternalByThread[action.payload.threadId]) {
delete state.activeExternalByThread[action.payload.threadId];
privacyLog('[privacy] clearActiveExternalForThread thread=%s', action.payload.threadId);
}
},
},
extraReducers: builder => {
// On identity flip / sign-out, drop per-user disclosure history. The
// privacy mode is a core-side setting re-hydrated on the next boot, so it
// is safe to reset here too.
builder.addCase(resetUserScopedState, () => initialState);
builder.addCase(hydratePrivacyMode.fulfilled, (state, action) => {
if (action.payload) state.privacyMode = action.payload;
});
},
});
export const {
setPrivacyMode,
pushDisclosureForThread,
dismissDisclosureForThread,
clearDisclosuresForThread,
clearActiveExternalForThread,
} = privacySlice.actions;
export default privacySlice.reducer;
+2
View File
@@ -24,6 +24,7 @@ import localeReducer from '../store/localeSlice';
import mascotReducer from '../store/mascotSlice';
import notificationReducer from '../store/notificationSlice';
import personaReducer from '../store/personaSlice';
import privacyReducer from '../store/privacySlice';
import { pttReducer } from '../store/pttSlice';
import socketReducer from '../store/socketSlice';
import themeReducer from '../store/themeSlice';
@@ -53,6 +54,7 @@ const testRootReducer = combineReducers({
mascot: mascotReducer,
notifications: notificationReducer,
persona: personaReducer,
privacy: privacyReducer,
ptt: pttReducer,
socket: socketReducer,
theme: themeReducer,