Skip to content

Commit c3ad49d

Browse files
authored
FEAT: Refactor ContextComplianceOrchestrator as ContextComplianceAttack (#1022)
1 parent 496091a commit c3ad49d

4 files changed

Lines changed: 1225 additions & 2 deletions

File tree

pyrit/attacks/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
TAPAttackResult,
2020
TreeOfAttacksWithPruningAttack,
2121
)
22+
from pyrit.attacks.single_turn.context_compliance import ContextComplianceAttack
2223
from pyrit.attacks.single_turn.flip_attack import FlipAttack
2324
from pyrit.attacks.single_turn.many_shot_jailbreak import ManyShotJailbreakAttack
2425
from pyrit.attacks.single_turn.prompt_sending import PromptSendingAttack
@@ -34,6 +35,7 @@
3435
"AttackStrategy",
3536
"AttackStrategyLogAdapter",
3637
"ContextT",
38+
"ContextComplianceAttack",
3739
"ConversationSession",
3840
"CrescendoAttack",
3941
"FlipAttack",
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
import logging
5+
from pathlib import Path
6+
from typing import Optional
7+
8+
from pyrit.attacks.base.attack_config import (
9+
AttackAdversarialConfig,
10+
AttackConverterConfig,
11+
AttackScoringConfig,
12+
)
13+
from pyrit.attacks.base.attack_context import SingleTurnAttackContext
14+
from pyrit.attacks.single_turn.prompt_sending import PromptSendingAttack
15+
from pyrit.common.path import DATASETS_PATH
16+
from pyrit.models import (
17+
PromptRequestPiece,
18+
PromptRequestResponse,
19+
SeedPrompt,
20+
SeedPromptDataset,
21+
SeedPromptGroup,
22+
)
23+
from pyrit.prompt_normalizer import PromptNormalizer
24+
from pyrit.prompt_target import PromptChatTarget
25+
26+
logger = logging.getLogger(__name__)
27+
28+
29+
class ContextComplianceAttack(PromptSendingAttack):
30+
"""
31+
Implementation of the context compliance attack strategy.
32+
33+
This attack attempts to bypass safety measures by rephrasing the objective into a more benign context.
34+
It uses an adversarial chat target to:
35+
1. Rephrase the objective as a more benign question
36+
2. Generate a response to the benign question
37+
3. Rephrase the original objective as a follow-up question
38+
39+
This creates a context that makes it harder for the target to detect the true intent.
40+
"""
41+
42+
# Default path for context description instructions
43+
DEFAULT_CONTEXT_DESCRIPTION_PATH: Path = (
44+
Path(DATASETS_PATH) / "orchestrators" / "context_compliance" / "context_description.yaml"
45+
)
46+
47+
# Default affirmative response used in conversation
48+
DEFAULT_AFFIRMATIVE_RESPONSE: str = "yes."
49+
50+
def __init__(
51+
self,
52+
*,
53+
objective_target: PromptChatTarget,
54+
attack_adversarial_config: AttackAdversarialConfig,
55+
attack_converter_config: Optional[AttackConverterConfig] = None,
56+
attack_scoring_config: Optional[AttackScoringConfig] = None,
57+
prompt_normalizer: Optional[PromptNormalizer] = None,
58+
context_description_instructions_path: Optional[Path] = None,
59+
affirmative_response: Optional[str] = None,
60+
) -> None:
61+
"""
62+
Initialize the context compliance attack strategy.
63+
64+
Args:
65+
objective_target (PromptChatTarget): The target system to attack. Must be a PromptChatTarget.
66+
attack_adversarial_config (AttackAdversarialConfig): Configuration for the adversarial component,
67+
including the adversarial chat target used for rephrasing.
68+
attack_converter_config (Optional[AttackConverterConfig]): Configuration for attack converters,
69+
including request and response converters.
70+
attack_scoring_config (Optional[AttackScoringConfig]): Configuration for attack scoring.
71+
prompt_normalizer (Optional[PromptNormalizer]): The prompt normalizer to use for sending prompts.
72+
context_description_instructions_path (Optional[Path]): Path to the context description
73+
instructions YAML file. If not provided, uses the default path.
74+
affirmative_response (Optional[str]): The affirmative response to be used in the conversation history.
75+
If not provided, uses the default "yes.".
76+
77+
Raises:
78+
ValueError: If the context description instructions file is invalid.
79+
"""
80+
# Initialize base class
81+
super().__init__(
82+
objective_target=objective_target,
83+
attack_converter_config=attack_converter_config,
84+
attack_scoring_config=attack_scoring_config,
85+
prompt_normalizer=prompt_normalizer,
86+
)
87+
88+
# Store adversarial chat target
89+
self._adversarial_chat = attack_adversarial_config.target
90+
91+
# Load context description instructions
92+
instructions_path = context_description_instructions_path or self.DEFAULT_CONTEXT_DESCRIPTION_PATH
93+
self._load_context_description_instructions(instructions_path=instructions_path)
94+
95+
# Set affirmative response
96+
self._affirmative_response = affirmative_response or self.DEFAULT_AFFIRMATIVE_RESPONSE
97+
98+
def _load_context_description_instructions(self, *, instructions_path: Path) -> None:
99+
"""
100+
Load context description instructions from YAML file.
101+
102+
Args:
103+
instructions_path (Path): Path to the instructions YAML file.
104+
105+
Raises:
106+
ValueError: If the instructions file is invalid or missing required prompts.
107+
"""
108+
try:
109+
context_description_instructions = SeedPromptDataset.from_yaml_file(instructions_path)
110+
except Exception as e:
111+
raise ValueError(f"Failed to load context description instructions from {instructions_path}: {e}")
112+
113+
if len(context_description_instructions.prompts) < 3:
114+
raise ValueError(
115+
f"Context description instructions must contain at least 3 prompts, "
116+
f"but found {len(context_description_instructions.prompts)}"
117+
)
118+
119+
self._rephrase_objective_to_user_turn = context_description_instructions.prompts[0]
120+
self._answer_user_turn = context_description_instructions.prompts[1]
121+
self._rephrase_objective_to_question = context_description_instructions.prompts[2]
122+
123+
def _validate_context(self, *, context: SingleTurnAttackContext) -> None:
124+
"""
125+
Validate the context for the attack.
126+
This attack does not support prepended conversations, so it raises an error if one exists.
127+
Args:
128+
context (SingleTurnAttackContext): The attack context to validate.
129+
Raises:
130+
ValueError: If the context has a prepended conversation.
131+
"""
132+
# Call parent validation first
133+
super()._validate_context(context=context)
134+
135+
if context.prepended_conversation:
136+
raise ValueError(
137+
"This attack does not support prepended conversations. "
138+
"Please clear the prepended conversation before starting the attack."
139+
)
140+
141+
async def _setup_async(self, *, context: SingleTurnAttackContext) -> None:
142+
"""
143+
Set up the context compliance attack.
144+
145+
This method:
146+
1. Generates a benign rephrasing of the objective
147+
2. Gets an answer to the benign question
148+
3. Rephrases the objective as a follow-up question
149+
4. Constructs a conversation with this context
150+
5. Sends an affirmative response to complete the attack
151+
152+
Args:
153+
context (SingleTurnAttackContext): The attack context containing configuration and state.
154+
"""
155+
self._logger.info(f"Setting up context compliance attack for objective: {context.objective}")
156+
157+
# Build the prepended conversation that creates the benign context
158+
prepended_conversation = await self._build_benign_context_conversation_async(
159+
objective=context.objective, context=context
160+
)
161+
162+
# Update context with the prepended conversation
163+
context.prepended_conversation.extend(prepended_conversation)
164+
165+
# Create the affirmative seed prompt group
166+
affirmative_seed_prompt = SeedPromptGroup(
167+
prompts=[
168+
SeedPrompt(
169+
value=self._affirmative_response,
170+
data_type="text",
171+
)
172+
]
173+
)
174+
175+
# Set the seed prompt group in context
176+
context.seed_prompt_group = affirmative_seed_prompt
177+
178+
await super()._setup_async(context=context)
179+
180+
async def _build_benign_context_conversation_async(
181+
self, *, objective: str, context: SingleTurnAttackContext
182+
) -> list[PromptRequestResponse]:
183+
"""
184+
Build the conversation that creates a benign context for the objective.
185+
186+
Args:
187+
objective (str): The attack objective to disguise.
188+
context (SingleTurnAttackContext): The attack context.
189+
190+
Returns:
191+
list[PromptRequestResponse]: The constructed conversation with benign context.
192+
"""
193+
# Step 1: Rephrase objective as a benign question
194+
benign_user_query = await self._get_objective_as_benign_question_async(objective=objective, context=context)
195+
196+
# Step 2: Generate an answer to the benign question
197+
benign_answer = await self._get_benign_question_answer_async(
198+
benign_user_query=benign_user_query, context=context
199+
)
200+
201+
# Step 3: Rephrase objective as a follow-up question
202+
objective_as_question = await self._get_objective_as_question_async(objective=objective, context=context)
203+
204+
# Construct the assistant response that includes the context
205+
assistant_response = self._construct_assistant_response(
206+
benign_answer=benign_answer, objective_question=objective_as_question
207+
)
208+
209+
# Create the conversation pieces
210+
return [
211+
PromptRequestResponse(
212+
request_pieces=[
213+
PromptRequestPiece(
214+
role="user",
215+
original_value=objective,
216+
converted_value=benign_user_query,
217+
),
218+
],
219+
),
220+
PromptRequestResponse(
221+
request_pieces=[
222+
PromptRequestPiece(
223+
role="assistant",
224+
original_value=assistant_response,
225+
),
226+
],
227+
),
228+
]
229+
230+
async def _get_objective_as_benign_question_async(self, *, objective: str, context: SingleTurnAttackContext) -> str:
231+
"""
232+
Rephrase the objective as a more benign question.
233+
234+
Args:
235+
objective (str): The original objective to rephrase.
236+
context (SingleTurnAttackContext): The attack context.
237+
238+
Returns:
239+
str: The objective rephrased as a benign question.
240+
"""
241+
seed_prompt_group = SeedPromptGroup(
242+
prompts=[
243+
SeedPrompt(
244+
value=self._rephrase_objective_to_user_turn.render_template_value(objective=objective),
245+
data_type="text",
246+
)
247+
]
248+
)
249+
250+
response = await self._prompt_normalizer.send_prompt_async(
251+
seed_prompt_group=seed_prompt_group,
252+
target=self._adversarial_chat,
253+
orchestrator_identifier=self.get_identifier(),
254+
labels=context.memory_labels,
255+
)
256+
257+
return response.get_value()
258+
259+
async def _get_benign_question_answer_async(
260+
self, *, benign_user_query: str, context: SingleTurnAttackContext
261+
) -> str:
262+
"""
263+
Generate an answer to the benign question.
264+
265+
Args:
266+
benign_user_query (str): The benign question to answer.
267+
context (SingleTurnAttackContext): The attack context.
268+
269+
Returns:
270+
str: The answer to the benign question.
271+
"""
272+
seed_prompt_group = SeedPromptGroup(
273+
prompts=[
274+
SeedPrompt(
275+
value=self._answer_user_turn.render_template_value(benign_request=benign_user_query),
276+
data_type="text",
277+
)
278+
]
279+
)
280+
281+
response = await self._prompt_normalizer.send_prompt_async(
282+
seed_prompt_group=seed_prompt_group,
283+
target=self._adversarial_chat,
284+
orchestrator_identifier=self.get_identifier(),
285+
labels=context.memory_labels,
286+
)
287+
288+
return response.get_value()
289+
290+
async def _get_objective_as_question_async(self, *, objective: str, context: SingleTurnAttackContext) -> str:
291+
"""
292+
Rephrase the objective as a question.
293+
294+
Args:
295+
objective (str): The original objective to rephrase.
296+
context (SingleTurnAttackContext): The attack context.
297+
298+
Returns:
299+
str: The objective rephrased as a question.
300+
"""
301+
seed_prompt_group = SeedPromptGroup(
302+
prompts=[
303+
SeedPrompt(
304+
value=self._rephrase_objective_to_question.render_template_value(objective=objective),
305+
data_type="text",
306+
)
307+
]
308+
)
309+
310+
response = await self._prompt_normalizer.send_prompt_async(
311+
seed_prompt_group=seed_prompt_group,
312+
target=self._adversarial_chat,
313+
orchestrator_identifier=self.get_identifier(),
314+
labels=context.memory_labels,
315+
)
316+
317+
return response.get_value()
318+
319+
def _construct_assistant_response(self, *, benign_answer: str, objective_question: str) -> str:
320+
"""
321+
Construct the assistant response that creates the benign context.
322+
323+
Args:
324+
benign_answer (str): The answer to the benign question.
325+
objective_question (str): The objective rephrased as a question.
326+
327+
Returns:
328+
str: The constructed assistant response.
329+
"""
330+
assistant_response = f"{benign_answer}\n\n"
331+
assistant_response += "I can provide more information depending on what you're seeking. "
332+
assistant_response += f"To help with understanding, {objective_question}"
333+
334+
return assistant_response

pyrit/attacks/single_turn/prompt_sending.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ def _validate_context(self, *, context: SingleTurnAttackContext) -> None:
114114
Raises:
115115
ValueError: If the context is invalid.
116116
"""
117-
if not context.objective:
118-
raise ValueError("Attack objective must be provided in the context")
117+
if not context.objective or context.objective.isspace():
118+
raise ValueError("Attack objective must be provided and non-empty in the context")
119119

120120
async def _setup_async(self, *, context: SingleTurnAttackContext) -> None:
121121
"""

0 commit comments

Comments
 (0)