🛂 feat: Filter Model-Bound Content by Source (#14425)

* feat: introduce optional content protection seam

* feat: enforce source-aware content filters

* feat: complete source-aware content enforcement

* test: activate skill file-text fail-close fixtures

* fix: harden source-aware content filters

* fix: harden model-bound content filtering

* fix: preserve legacy filters and generated files

* fix: inspect shared scalar metadata

* test: align mocks with current dev dependencies

* feat: add persisted content filter safeguards

* feat: complete source-aware content filter enforcement

* fix: move resume content preflight into TypeScript

* fix: close content inspection edge cases

* fix: harden content protection boundaries

* fix: complete content protection safeguards

* test: align persisted memory filter coverage

* fix: reconcile content protection with current dev

* fix: reconcile content protection with latest dev

* fix: close content protection review gaps

* fix: enforce source-aware provider boundaries

* fix: preserve legacy PII preflight semantics

* test: stabilize stored branch preflight fixture

* fix: defer agent writes until protected model admission

* perf: harden source-aware model-bound filtering

* fix: canonicalize provider lineage before validation

* fix: satisfy model-bound callback type checks

* perf: Bound content protection filtering work

* fix: Bound submission array traversal

* fix: Stabilize bounded content snapshots

* fix: Scope model-bound traversal overflows

* fix: Preserve scoped content inspection

* fix: Accumulate aggregate traversal scopes

* fix: centralize content policy boundaries

* test: align deferred tool policy context

* test: align controller policy mocks

* style: normalize content protection imports

* fix: close content policy review gaps

* fix: narrow active skill policy config

* fix: address content protection review boundaries

* fix: retain exact provenance overflow sentinel

* fix: preserve literal and scoped provenance updates

* fix: narrow persisted edit provenance

* fix: isolate exact overflow attribution

* fix: centralize stored prompt protection

* fix: fail closed on incomplete transcript evidence

* fix: align canonical transcript routing

* refactor: centralize content policy preflights

* fix: isolate upload policy error typing

* style: sort policy preflight imports

* refactor: centralize content policy boundaries
This commit is contained in:
Danny Avila 2026-08-21 22:43:32 -04:00 committed by GitHub
parent 10f95c0ce9
commit 67b7b441b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
303 changed files with 75546 additions and 2327 deletions

View file

@ -18,6 +18,7 @@ const formatDate = (dateString: string): string => {
export default function MemoryCard({ memory, hasUpdateAccess }: MemoryCardProps) {
const localize = useLocalize();
const displayKey = memory.key || localize('com_ui_memory');
return (
<div
@ -29,7 +30,7 @@ export default function MemoryCard({ memory, hasUpdateAccess }: MemoryCardProps)
>
{/* Row 1: Key + Agent badge + Token count + Actions */}
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-text-primary">{memory.key}</span>
<span className="truncate text-sm font-semibold text-text-primary">{displayKey}</span>
{memory.agentId != null && (
<span
className="shrink-0 truncate rounded-full border border-border-light px-2 py-0.5 text-xs text-text-secondary"

View file

@ -15,6 +15,7 @@ import {
import type { TUserMemory } from 'librechat-data-provider';
import { useDeleteMemoryMutation } from '~/data-provider';
import MemoryEditDialog from './MemoryEditDialog';
import { getMemoryAddress } from './address';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -28,6 +29,7 @@ export default function MemoryCardActions({ memory }: MemoryCardActionsProps) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const memoryAddress = getMemoryAddress(memory);
const { mutate: deleteMemory, isLoading: isDeleting } = useDeleteMemoryMutation();
@ -40,8 +42,11 @@ export default function MemoryCardActions({ memory }: MemoryCardActionsProps) {
);
const confirmDelete = () => {
if (!memoryAddress) {
return;
}
deleteMemory(
{ key: memory.key, agentId: memory.agentId },
{ ...memoryAddress, agentId: memory.agentId },
{
onSuccess: () => {
showToast({ message: localize('com_ui_deleted'), status: 'success' });
@ -54,6 +59,10 @@ export default function MemoryCardActions({ memory }: MemoryCardActionsProps) {
);
};
if (!memoryAddress) {
return null;
}
return (
<div className="flex items-center gap-0.5">
{/* Edit Button */}
@ -114,7 +123,7 @@ export default function MemoryCardActions({ memory }: MemoryCardActionsProps) {
<Label className="text-left text-sm font-medium">
<Trans
i18nKey="com_ui_delete_confirm_strong"
values={{ title: memory.key }}
values={{ title: memory.key || localize('com_ui_memory') }}
components={{ strong: <strong /> }}
/>
</Label>

View file

@ -14,6 +14,7 @@ import {
import type { TUserMemory } from 'librechat-data-provider';
import { getMemoryKeyError, getMemoryValueError, getMemoryApiErrorMessage } from '~/utils/memory';
import { useUpdateMemoryMutation, useMemoriesQuery } from '~/data-provider';
import { getMemoryAddress, getMemoryUpdateAddress } from './address';
import { useLocalize, useHasAccess } from '~/hooks';
import MemoryUsageBadge from './MemoryUsageBadge';
@ -75,6 +76,9 @@ export default function MemoryEditDialog({
const [originalKey, setOriginalKey] = useState('');
const [touched, setTouched] = useState({ key: false, value: false });
const [prevMemory, setPrevMemory] = useState<TUserMemory | null>(null);
const memoryAddress = memory ? getMemoryAddress(memory) : null;
const requiresKey =
memoryAddress == null || !('id' in memoryAddress) || memory?.key.trim() !== '';
if (memory !== prevMemory) {
setPrevMemory(memory);
@ -86,12 +90,15 @@ export default function MemoryEditDialog({
}
}
const keyError = getMemoryKeyError({
key,
memories: memData?.memories,
agentId: memory?.agentId,
originalKey,
});
const keyError =
requiresKey || key.trim() !== ''
? getMemoryKeyError({
key,
memories: memData?.memories,
agentId: memory?.agentId,
originalKey,
})
: null;
const valueError = getMemoryValueError(value);
const hasErrors = keyError != null || valueError != null;
/** Stay quiet on a pristine empty field; validate live once there is something to judge. */
@ -99,20 +106,25 @@ export default function MemoryEditDialog({
const showValueError = hasUpdateAccess && (touched.value || value !== '');
const handleSave = () => {
if (!hasUpdateAccess || !memory) {
if (!hasUpdateAccess || !memory || !memoryAddress) {
return;
}
const trimmedKey = key.trim();
if (keyError || valueError) {
setTouched({ key: true, value: true });
return;
}
const updateAddress = getMemoryUpdateAddress(memory, trimmedKey);
if (!updateAddress) {
return;
}
updateMemory({
key: key.trim(),
...updateAddress,
value: value.trim(),
agentId: memory.agentId,
...(originalKey !== key.trim() && { originalKey }),
});
};
@ -230,7 +242,7 @@ export default function MemoryEditDialog({
variant="submit"
onClick={handleSave}
aria-label={localize('com_ui_save')}
disabled={isLoading || hasErrors}
disabled={isLoading || !memoryAddress || hasErrors}
>
{isLoading ? <Spinner className="size-4" /> : localize('com_ui_save')}
</Button>

View file

@ -1,5 +1,6 @@
import type { TUserMemory } from 'librechat-data-provider';
import MemoryEmptyState from './MemoryEmptyState';
import { getMemoryListKey } from './address';
import MemoryCard from './MemoryCard';
import { useLocalize } from '~/hooks';
@ -23,7 +24,7 @@ export default function MemoryList({
return (
<div className="space-y-2" role="list" aria-label={localize('com_ui_memories')}>
{memories.map((memory) => (
<div key={`${memory.agentId ?? ''}:${memory.key}`} role="listitem">
<div key={getMemoryListKey(memory)} role="listitem">
<MemoryCard memory={memory} hasUpdateAccess={hasUpdateAccess} />
</div>
))}

View file

@ -0,0 +1,62 @@
import type { TUserMemory } from 'librechat-data-provider';
import { getMemoryAddress, getMemoryListKey, getMemoryUpdateAddress } from './address';
const memory = (overrides: Partial<TUserMemory> = {}): TUserMemory => ({
_id: 'memory-id',
key: 'preference',
value: 'tea',
updated_at: '2026-08-04T00:00:00.000Z',
...overrides,
});
describe('memory addressing', () => {
it('uses the opaque id for a policy-projected memory even when its key is safe', () => {
const projected = memory({ contentFilterBlocked: true, value: '' });
expect(getMemoryAddress(projected)).toEqual({ id: 'memory-id' });
expect(getMemoryListKey(projected)).toBe('id:memory-id');
});
it('uses the opaque id when the key is redacted', () => {
const projected = memory({ key: '', contentFilterBlocked: true });
expect(getMemoryAddress(projected)).toEqual({ id: 'memory-id' });
expect(getMemoryListKey(projected)).toBe('id:memory-id');
});
it('preserves key addressing for legacy safe responses without an id', () => {
const legacy = memory({ _id: undefined });
expect(getMemoryAddress(legacy)).toEqual({ key: 'preference' });
expect(getMemoryListKey(legacy)).toBe('key::preference');
});
it('does not create a blank key address for a malformed legacy response', () => {
expect(getMemoryAddress(memory({ _id: undefined, key: '' }))).toBeNull();
});
it('preserves a hidden key by omitting it from an opaque update', () => {
const projected = memory({ key: '', contentFilterBlocked: true });
expect(getMemoryUpdateAddress(projected, '')).toEqual({ id: 'memory-id' });
});
it('uses the opaque id for value-blocked edits and sends only an explicit rename', () => {
const projected = memory({ contentFilterBlocked: true, value: '' });
expect(getMemoryUpdateAddress(projected, 'preference')).toEqual({ id: 'memory-id' });
expect(getMemoryUpdateAddress(projected, 'new_preference')).toEqual({
id: 'memory-id',
key: 'new_preference',
});
});
it('retains the legacy original-key contract for unprojected memories', () => {
const legacy = memory({ _id: undefined });
expect(getMemoryUpdateAddress(legacy, 'new_preference')).toEqual({
key: 'new_preference',
originalKey: 'preference',
});
});
});

View file

@ -0,0 +1,46 @@
import type { TUserMemory } from 'librechat-data-provider';
export type MemoryAddress = { id: string } | { key: string };
export type MemoryUpdateAddress =
| { id: string; key?: string }
| { key: string; originalKey?: string };
/** Uses the opaque record id whenever the key cannot safely address the record. */
export function getMemoryAddress(memory: TUserMemory): MemoryAddress | null {
if (memory._id && (memory.contentFilterBlocked === true || memory.key.trim() === '')) {
return { id: memory._id };
}
if (memory.key.trim() !== '') {
return { key: memory.key };
}
if (memory._id) {
return { id: memory._id };
}
return null;
}
export function getMemoryListKey(memory: TUserMemory): string {
const address = getMemoryAddress(memory);
if (address && 'id' in address) {
return `id:${address.id}`;
}
if (address) {
return `key:${memory.agentId ?? ''}:${address.key}`;
}
return `unaddressable:${memory.agentId ?? ''}:${memory.updated_at}`;
}
export function getMemoryUpdateAddress(
memory: TUserMemory,
submittedKey: string,
): MemoryUpdateAddress | null {
const address = getMemoryAddress(memory);
const key = submittedKey.trim();
if (address && 'id' in address) {
return { id: address.id, ...(key && key !== memory.key ? { key } : {}) };
}
if (!address || !key) {
return null;
}
return { key, ...(key !== memory.key ? { originalKey: memory.key } : {}) };
}

View file

@ -6,7 +6,7 @@ import type {
UseMutationOptions,
QueryObserverResult,
} from '@tanstack/react-query';
import type { TUserMemory, MemoriesResponse } from 'librechat-data-provider';
import type { TUserMemory, MemoriesResponse, UpdateMemoryResponse } from 'librechat-data-provider';
export const useMemoriesQuery = (
config?: UseQueryOptions<MemoriesResponse>,
@ -19,11 +19,19 @@ export const useMemoriesQuery = (
});
};
export type DeleteMemoryParams = { key: string; agentId?: string };
export type DeleteMemoryParams = { key?: string; id?: string; agentId?: string };
export const useDeleteMemoryMutation = () => {
const queryClient = useQueryClient();
return useMutation(
({ key, agentId }: DeleteMemoryParams) => dataService.deleteMemory(key, agentId),
({ key, id, agentId }: DeleteMemoryParams) => {
if (id) {
return dataService.deleteMemoryById(id, agentId);
}
if (key) {
return dataService.deleteMemory(key, agentId);
}
throw new Error('Memory address is required.');
},
{
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.memories]);
@ -33,18 +41,26 @@ export const useDeleteMemoryMutation = () => {
};
export type UpdateMemoryParams = {
key: string;
key?: string;
id?: string;
value: string;
originalKey?: string;
agentId?: string;
};
export const useUpdateMemoryMutation = (
options?: UseMutationOptions<TUserMemory, Error, UpdateMemoryParams>,
options?: UseMutationOptions<UpdateMemoryResponse, Error, UpdateMemoryParams>,
) => {
const queryClient = useQueryClient();
return useMutation(
({ key, value, originalKey, agentId }: UpdateMemoryParams) =>
dataService.updateMemory(key, value, originalKey, agentId),
({ key, id, value, originalKey, agentId }: UpdateMemoryParams) => {
if (id) {
return dataService.updateMemoryById(id, value, key, agentId);
}
if (key) {
return dataService.updateMemory(key, value, originalKey, agentId);
}
throw new Error('Memory address is required.');
},
{
...options,
onSuccess: (...params) => {

View file

@ -1,10 +1,14 @@
import { dataService as _dataService } from 'librechat-data-provider';
import axios from 'axios';
import { dataService as _dataService } from 'librechat-data-provider';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
beforeEach(() => {
jest.clearAllMocks();
});
describe('getMemories', () => {
it('should fetch memories from /api/memories', async () => {
const mockData = [{ key: 'foo', value: 'bar', updated_at: '2024-05-01T00:00:00Z' }];
@ -17,3 +21,39 @@ describe('getMemories', () => {
expect(result).toEqual(mockData);
});
});
describe('opaque memory management', () => {
it('deletes a projected memory through its encoded id and agent partition', async () => {
mockedAxios.delete.mockResolvedValueOnce({ data: { deleted: true } });
await _dataService.deleteMemoryById('memory/id', 'agent id');
expect(mockedAxios.delete).toHaveBeenCalledWith(
'/api/memories/id/memory%2Fid?agentId=agent%20id',
);
});
it('updates a projected memory without sending its hidden key', async () => {
mockedAxios.patch.mockResolvedValueOnce({ data: { updated: true } });
await _dataService.updateMemoryById('memory-id', 'replacement value', undefined, 'agent-id');
expect(mockedAxios.patch).toHaveBeenCalledWith(
'/api/memories/id/memory-id?agentId=agent-id',
JSON.stringify({ value: 'replacement value' }),
{ headers: { 'Content-Type': 'application/json' } },
);
});
it('sends an explicit replacement key only when supplied', async () => {
mockedAxios.patch.mockResolvedValueOnce({ data: { updated: true } });
await _dataService.updateMemoryById('memory-id', 'replacement value', 'replacement_key');
expect(mockedAxios.patch).toHaveBeenCalledWith(
'/api/memories/id/memory-id',
JSON.stringify({ value: 'replacement value', key: 'replacement_key' }),
{ headers: { 'Content-Type': 'application/json' } },
);
});
});