Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions frontend/e2e/history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface MockAttackSummary {
last_message_preview?: string | null;
message_count: number;
related_conversation_ids: string[];
operator?: string | null;
operation?: string | null;
labels: Record<string, string>;
created_at: string;
updated_at: string;
Expand All @@ -30,6 +32,8 @@ function makeAttack(overrides: Partial<MockAttackSummary> & { attack_result_id:
last_message_preview: null,
message_count: 0,
related_conversation_ids: [],
operator: null,
operation: null,
labels: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
Expand All @@ -44,7 +48,8 @@ const ATTACKS: MockAttackSummary[] = [
attack_type: "SingleTurnAttack",
target: { target_type: "OpenAIChatTarget", model_name: "gpt-4o" },
outcome: "success",
labels: { operator: "alice", operation: "test_a" },
operator: "alice",
operation: "test_a",
message_count: 3,
last_message_preview: "Hello from alice",
}),
Expand All @@ -53,7 +58,8 @@ const ATTACKS: MockAttackSummary[] = [
attack_type: "MultiTurnAttack",
target: { target_type: "OpenAIImageTarget", model_name: "dall-e-3" },
outcome: "failure",
labels: { operator: "bob", operation: "test_b" },
operator: "bob",
operation: "test_b",
message_count: 5,
last_message_preview: "Hello from bob",
}),
Expand All @@ -62,15 +68,17 @@ const ATTACKS: MockAttackSummary[] = [
attack_type: "SingleTurnAttack",
target: { target_type: "OpenAIChatTarget", model_name: "gpt-4o" },
outcome: "undetermined",
labels: { operator: "alice", operation: "test_b" },
operator: "alice",
operation: "test_b",
message_count: 1,
}),
makeAttack({
attack_result_id: "atk-bob-a",
attack_type: "MultiTurnAttack",
target: { target_type: "OpenAIChatTarget", model_name: "gpt-4o" },
outcome: "success",
labels: { operator: "bob", operation: "test_a" },
operator: "bob",
operation: "test_a",
message_count: 2,
last_message_preview: "Hello again from bob",
}),
Expand All @@ -83,7 +91,7 @@ function generatePaginatedAttacks(count: number): MockAttackSummary[] {
attack_result_id: `atk-page-${String(i).padStart(3, "0")}`,
attack_type: i % 2 === 0 ? "SingleTurnAttack" : "MultiTurnAttack",
outcome: "undetermined",
labels: { operator: "paginator" },
operator: "paginator",
message_count: 1,
}),
);
Expand Down Expand Up @@ -143,10 +151,9 @@ async function mockHistoryAPIs(
contentType: "application/json",
body: JSON.stringify({
source: "attacks",
labels: {
operator: operatorLabels,
operation: operationLabels,
},
labels: {},
operators: operatorLabels,
operations: operationLabels,
}),
});
});
Expand All @@ -163,6 +170,8 @@ async function mockHistoryAPIs(
const url = new URL(route.request().url());
const attackTypeParams = url.searchParams.getAll("attack_types");
const outcome = url.searchParams.get("outcome");
const operatorParams = url.searchParams.getAll("operator");
const operationParams = url.searchParams.getAll("operation");
const labelParams = url.searchParams.getAll("label");

let filtered = [...attacks];
Expand All @@ -172,6 +181,12 @@ async function mockHistoryAPIs(
if (outcome) {
filtered = filtered.filter((a) => a.outcome === outcome);
}
if (operatorParams.length > 0) {
filtered = filtered.filter((a) => a.operator != null && operatorParams.includes(a.operator));
}
if (operationParams.length > 0) {
filtered = filtered.filter((a) => a.operation != null && operationParams.includes(a.operation));
}
if (labelParams.length > 0) {
// Group repeated label keys into OR-sets; combine across keys with AND.
const grouped = new Map<string, string[]>();
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ interface LoadedAttack {
targetSource: 'persisted' | 'active-selection'
mainConversationId: string | null
labels: Record<string, string> | null
operator: string | null
target: TargetInfo | null
relatedConversationIds: string[]
objective: string
Expand Down Expand Up @@ -319,6 +320,7 @@ function App() {
status: 'loading',
mainConversationId: null,
labels: null,
operator: null,
target: null,
relatedConversationIds: [],
objective: '',
Expand All @@ -333,6 +335,7 @@ function App() {
targetSource: 'persisted',
mainConversationId: attack.conversation_id,
labels: attack.labels ?? {},
operator: attack.operator ?? null,
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
objective: attack.objective ?? '',
Expand All @@ -352,6 +355,7 @@ function App() {
status: isMissing ? 'not-found' : 'error',
mainConversationId: null,
labels: null,
operator: null,
target: null,
relatedConversationIds: [],
objective: '',
Expand Down Expand Up @@ -441,6 +445,7 @@ function App() {
mainConversationId: convId,
// New attack uses the current user's labels, so it is never operator-locked.
labels: null,
operator: null,
target,
relatedConversationIds: [],
objective: '',
Expand Down Expand Up @@ -488,7 +493,7 @@ function App() {
labels={globalLabels}
onLabelsChange={handleGlobalLabelsChange}
onNavigate={handleNavigate}
attackLabels={readyAttack ? readyAttack.labels : null}
attackOperator={readyAttack ? readyAttack.operator : null}
attackTarget={readyAttack ? readyAttack.target : null}
targetResolutionStatus={targetResolutionStatus}
onRetryTargetResolution={retryTargetResolution}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,7 @@ describe("ChatWindow Integration", () => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith({
target_registry_name: "openai_chat_1",
labels: { operator: 'testuser', operation: 'test_op' },
system_prompt: undefined,
});
expect(onConversationCreated).toHaveBeenCalledWith("ar-conv-1", "conv-1");
expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith("ar-conv-1", {
Expand All @@ -714,7 +715,6 @@ describe("ChatWindow Integration", () => {
send: true,
target_registry_name: "openai_chat_1",
target_conversation_id: "conv-1",
labels: { operator: "testuser", operation: "test_op" },
});
});

Expand Down Expand Up @@ -2655,7 +2655,7 @@ describe("ChatWindow Integration", () => {
conversationId="conv-locked"
activeConversationId="conv-locked"
labels={{ operator: "alice", operation: "test_op" }}
attackLabels={{ operator: "bob", operation: "test_op" }}
attackOperator="bob"
/>
</TestWrapper>
);
Expand Down Expand Up @@ -3786,7 +3786,7 @@ describe("ChatWindow Integration", () => {
it("allows exporting a read-only historical conversation", async () => {
const user = userEvent.setup();
// Operator lock: the loaded attack belongs to a different operator.
await renderWithLoadedConversation({ attackLabels: { operator: "someone-else" } });
await renderWithLoadedConversation({ attackOperator: "someone-else" });
const { clickSpy } = spyOnDownloadAnchor();

const exportButton = screen.getByRole("button", { name: /export conversation/i });
Expand Down
30 changes: 17 additions & 13 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ import { buildMessagePieces, backendMessagesToFrontend } from '../../utils/messa
import { exportConversation } from '../../utils/conversationExport'
import type { ExportFormat } from '../../utils/conversationExport'
import type {
AddMessageRequest,
AttackTargetResolutionStatus,
CreateAttackRequest,
Message,
MessageAttachment,
TargetInstance,
Expand Down Expand Up @@ -89,8 +91,8 @@ interface ChatWindowProps {
labels?: Record<string, string>
onLabelsChange?: (labels: Record<string, string>) => void
onNavigate?: (view: ViewName) => void
/** Labels from the loaded attack (for operator locking). Null for new attacks. */
attackLabels?: Record<string, string> | null
/** Operator from the loaded attack (for operator locking). Null for new attacks. */
attackOperator?: string | null
/** Target info that the current attack was started with (for cross-target guard). */
attackTarget?: TargetInfo | null
/** Result of resolving the persisted attack target against the current registry. */
Expand Down Expand Up @@ -118,7 +120,7 @@ export default function ChatWindow({
labels,
onLabelsChange,
onNavigate,
attackLabels,
attackOperator,
attackTarget,
targetResolutionStatus = 'idle',
onRetryTargetResolution,
Expand Down Expand Up @@ -238,10 +240,9 @@ export default function ChatWindow({
&& isTargetResolutionBlocking(targetResolutionStatus),
)
const currentOperator = labels?.operator
const attackOperator = attackLabels?.operator
// Existing attacks are operator-locked when their operator differs from the current one.
const isOperatorLocked = Boolean(
attackResultId && attackLabels && attackOperator && currentOperator && attackOperator !== currentOperator,
attackResultId && attackOperator && currentOperator && attackOperator !== currentOperator,
)
// They are cross-target locked when the selected target's canonical hash differs from the persisted target.
const isCrossTargetLocked = Boolean(
Expand Down Expand Up @@ -428,11 +429,14 @@ export default function ChatWindow({
let currentConversationId = conversationId
let currentActiveConversationId = activeConversationId
if (!currentAttackResultId) {
const createResponse = await attacksApi.createAttack({
const createRequest: CreateAttackRequest = {
target_registry_name: activeTarget.target_registry_name,
labels: labels,
// TODO(PyRIT 1.4): Pass only dedicated attribution after legacy label aliases are removed.
// The create-attack API normalizes these aliases through _AttackAttributionInput.
labels,
system_prompt: supportsSystemPrompt ? systemPrompt.trim() || undefined : undefined,
})
}
const createResponse = await attacksApi.createAttack(createRequest)
currentAttackResultId = createResponse.attack_result_id
currentConversationId = createResponse.conversation_id
currentActiveConversationId = currentConversationId
Expand Down Expand Up @@ -465,15 +469,15 @@ export default function ChatWindow({

// Send message to target
const converterIds = allConverterIds.length > 0 ? allConverterIds : undefined
const response = await attacksApi.addMessage(currentAttackResultId!, {
const addMessageRequest: AddMessageRequest = {
role: 'user',
pieces,
send: true,
target_registry_name: activeTarget.target_registry_name,
target_conversation_id: effectiveConvId!,
labels: labels ?? undefined,
converter_ids: converterIds,
})
}
const response = await attacksApi.addMessage(currentAttackResultId!, addMessageRequest)

// Clear converter state after successful send
setPieceConversions({})
Expand Down Expand Up @@ -656,7 +660,7 @@ export default function ChatWindow({
try {
const createResponse = await attacksApi.createAttack({
target_registry_name: activeTarget.target_registry_name,
labels: labels,
labels,
source_conversation_id: activeConversationId,
cutoff_index: messageIndex,
})
Expand Down Expand Up @@ -707,7 +711,7 @@ export default function ChatWindow({
// Let the backend clone the conversation with new labels
const createResponse = await attacksApi.createAttack({
target_registry_name: activeTarget.target_registry_name,
labels: labels,
labels,
source_conversation_id: activeConversationId,
cutoff_index: lastIndex,
})
Expand Down
37 changes: 35 additions & 2 deletions frontend/src/components/History/AttackHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -687,9 +687,9 @@ describe('AttackHistory', () => {
})
mockedLabelsApi.getLabels.mockResolvedValue({
source: 'attacks',
operators: ['alice', 'bob'],
operations: ['op_one'],
labels: {
operator: ['alice', 'bob'],
operation: ['op_one'],
custom_tag: ['val1', 'val2'],
},
})
Expand All @@ -707,6 +707,39 @@ describe('AttackHistory', () => {
expect(mockedLabelsApi.getLabels).toHaveBeenCalled()
})

it('should narrow arbitrary label options by selected attribution and labels', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
})
mockedLabelsApi.getLabels.mockResolvedValue({
source: 'attacks',
operators: ['alice', 'bob'],
operations: ['nightly'],
labels: { env: ['prod'] },
})
const activeFilters = {
...DEFAULT_HISTORY_FILTERS,
operator: ['alice'],
operation: ['nightly'],
otherLabels: ['team:red'],
}

render(
<TestWrapper>
<AttackHistory {...defaultProps} filters={activeFilters} />
</TestWrapper>
)

await waitFor(() => {
expect(mockedLabelsApi.getLabels).toHaveBeenCalledWith('attacks', {
operator: ['alice'],
operation: ['nightly'],
label: ['team:red'],
})
})
})

it('should show empty text with filter hint when filters active and no results', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
Expand Down
Loading
Loading