mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-30 14:53:13 +00:00
🛂 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:
parent
10f95c0ce9
commit
67b7b441b2
303 changed files with 75546 additions and 2327 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
62
client/src/components/SidePanel/Memories/address.spec.ts
Normal file
62
client/src/components/SidePanel/Memories/address.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
46
client/src/components/SidePanel/Memories/address.ts
Normal file
46
client/src/components/SidePanel/Memories/address.ts
Normal 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 } : {}) };
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue