FEAT add CodeAttackConverter (closes #1945) - #1960
FEAT add CodeAttackConverter (closes #1945)#1960Utkarsh Bahuguna (u7k4rs6) wants to merge 12 commits into
Conversation
2f306cc to
88631e8
Compare
Utkarsh Bahuguna (u7k4rs6)
left a comment
There was a problem hiding this comment.
PR Risk Summary
Quality Score: 9/10
Risk Level: low
Merge Recommendation: Safe to merge
Rationale: The changes appear to be focused on adding and refining code attack functionalities and their associated prompt converters. The review found no issues, and the changes are well-contained within the relevant modules. The addition of unit tests further strengthens the quality of this pull request.
|
We need a references.bib update to include the paper. |
88631e8 to
76b74d6
Compare
…te enum, add bib entry, docs - Rename CodeAttackAttack -> CodeAttack (Task 1) - Collapse language + verbose into a single CodeAttackConverter.Template enum modelled on BinaryConverter.BitsPerChar; custom pathlib.Path still accepted for caller-supplied YAML templates (Task 2) - CodeAttack.__init__ now accepts template: CodeAttackConverter.Template | Path and forwards it to the converter; language/verbose params removed (Task 3) - Add @ren2024codeattack entry to doc/references.bib after liu2024flipattack (Task 4) - Add Code row to the attack table in 1_single_turn.py, add ## Code section after ## Flip mirroring the FlipAttack shape, regenerate notebook (Task 5) - Rebase onto upstream/main (doc/code/executor/attack/ directory was removed upstream; old standalone code_attack.ipynb/.py deleted, content moved into 1_single_turn.py) - Update all unit tests for the new Template-based API; add custom-Path cases
|
Roman Lutz (@romanlutz) Thanks for the thorough pass. Addressed everything: Rebased on main; folded the docs into 1_single_turn next to Flip, removed the separate file and myst.yml entry Open to dropping the class for converter-only if you'd rather. Re-requesting review. |
|
Tracked the framing variant in #2088. Resolving. |
9fc7c3c to
b500382
Compare
…te enum, add bib entry, docs - Rename CodeAttackAttack -> CodeAttack (Task 1) - Collapse language + verbose into a single CodeAttackConverter.Template enum modelled on BinaryConverter.BitsPerChar; custom pathlib.Path still accepted for caller-supplied YAML templates (Task 2) - CodeAttack.__init__ now accepts template: CodeAttackConverter.Template | Path and forwards it to the converter; language/verbose params removed (Task 3) - Add @ren2024codeattack entry to doc/references.bib after liu2024flipattack (Task 4) - Add Code row to the attack table in 1_single_turn.py, add ## Code section after ## Flip mirroring the FlipAttack shape, regenerate notebook (Task 5) - Rebase onto upstream/main (doc/code/executor/attack/ directory was removed upstream; old standalone code_attack.ipynb/.py deleted, content moved into 1_single_turn.py) - Update all unit tests for the new Template-based API; add custom-Path cases
b500382 to
5b5876e
Compare
…te enum, add bib entry, docs - Rename CodeAttackAttack -> CodeAttack (Task 1) - Collapse language + verbose into a single CodeAttackConverter.Template enum modelled on BinaryConverter.BitsPerChar; custom pathlib.Path still accepted for caller-supplied YAML templates (Task 2) - CodeAttack.__init__ now accepts template: CodeAttackConverter.Template | Path and forwards it to the converter; language/verbose params removed (Task 3) - Add @ren2024codeattack entry to doc/references.bib after liu2024flipattack (Task 4) - Add Code row to the attack table in 1_single_turn.py, add ## Code section after ## Flip mirroring the FlipAttack shape, regenerate notebook (Task 5) - Rebase onto upstream/main (doc/code/executor/attack/ directory was removed upstream; old standalone code_attack.ipynb/.py deleted, content moved into 1_single_turn.py) - Update all unit tests for the new Template-based API; add custom-Path cases
|
Roman Lutz (@romanlutz) Rebased onto current main. The branch was ~150 commits behind, so this Also fixed a gap that predates the rebase: Converter, docs and setup suites green, pre-commit clean. Description |
|
hey Roman Lutz (@romanlutz) , no rush on the review itself, but #1960's CI is stuck awaiting maintainer approval so nothing has actually run on it yet. could you kick that off when you get a sec? also cleaned up the description, the attack class is gone and it's a converter + technique factory now, plus updated the bib entry to the ACL Findings version to match your #2409 pass 🙏 |
| }, | ||
| { | ||
| "cell_type": "code", | ||
| "execution_count": null, |
There was a problem hiding this comment.
This new cell is committed with execution_count: null and no output. The documentation instructions require retaining executed notebook outputs. Please execute and regenerate the paired notebook using this checkout.
There was a problem hiding this comment.
This cell runs a live PromptSendingAttack against OpenAIChatTarget and I don't have target credentials, so I can't produce real output for it. I didn't want to fabricate one. Options as I see them: someone with a configured target executes the cell and pushes to the branch (edits by maintainers is on), or the cell becomes an offline convert_async demo, or it ships unexecuted. Any of those works for me, let me know which you prefer. For what it's worth tests/unit/docs passes green as-is and nothing in the hooks or tests inspects execution_count.
There was a problem hiding this comment.
We'll rerun this notebook before merging, so this is not a problem for the implementation. Leaving the thread open so we don't forget the pre-merge step.
Implement CodeAttack (Ren et al., ACL 2024) as a standalone converter
and a PromptSendingAttack subclass following the FlipAttack pattern.
CodeAttackConverter encodes a natural-language prompt word-by-word into
a data-structure initialisation sequence (deque appends, list appends,
or a string assignment) and embeds it in a partial code template that
asks the model to complete the code. Five language variants are
supported: python_stack, python_list, python_string, cpp, go. The
verbose flag selects the _plus template (detailed paragraphs) for the
three Python variants; cpp and go have no plus variant upstream.
CodeAttackAttack wraps the converter in a PromptSendingAttack, prepends
a system prompt that frames the session as code completion, and forwards
language and verbose to the converter. Callers supply a scorer via
AttackScoringConfig as usual.
Files added:
pyrit/prompt_converter/code_attack_converter.py
pyrit/executor/attack/single_turn/code_attack.py
pyrit/datasets/executors/code_attack.yaml
pyrit/datasets/prompt_converters/code_attack_python_stack{,_plus}.yaml
pyrit/datasets/prompt_converters/code_attack_python_list{,_plus}.yaml
pyrit/datasets/prompt_converters/code_attack_python_string{,_plus}.yaml
pyrit/datasets/prompt_converters/code_attack_cpp.yaml
pyrit/datasets/prompt_converters/code_attack_go.yaml
tests/unit/prompt_converter/test_code_attack_converter.py (23 tests)
tests/unit/executor/attack/single_turn/test_code_attack.py (16 tests)
doc/code/executor/attack/code_attack.py
doc/code/executor/attack/code_attack.ipynb
Files modified:
pyrit/prompt_converter/__init__.py
pyrit/executor/attack/single_turn/__init__.py
pyrit/executor/attack/__init__.py
doc/myst.yml
…te enum, add bib entry, docs - Rename CodeAttackAttack -> CodeAttack (Task 1) - Collapse language + verbose into a single CodeAttackConverter.Template enum modelled on BinaryConverter.BitsPerChar; custom pathlib.Path still accepted for caller-supplied YAML templates (Task 2) - CodeAttack.__init__ now accepts template: CodeAttackConverter.Template | Path and forwards it to the converter; language/verbose params removed (Task 3) - Add @ren2024codeattack entry to doc/references.bib after liu2024flipattack (Task 4) - Add Code row to the attack table in 1_single_turn.py, add ## Code section after ## Flip mirroring the FlipAttack shape, regenerate notebook (Task 5) - Rebase onto upstream/main (doc/code/executor/attack/ directory was removed upstream; old standalone code_attack.ipynb/.py deleted, content moved into 1_single_turn.py) - Update all unit tests for the new Template-based API; add custom-Path cases
Correct the authors to match the ren2024codeattack bib entry: Ren, Gao, Shao, Yan, Tan, Lam, Ma (SJTU / Shanghai AI Lab / CUHK). Remove incorrect names (Liu, Fan, Chen, Zhong, Lu, Wen) and replace Nanyang Technological University with Shanghai Jiao Tong University.
Remove CodeAttack attack class and its test file; wire code_attack as a PromptSendingAttack + CodeAttackConverter entry in scenario_techniques.py. Delete the now-orphaned executor system-prompt seed YAML. Update the single-turn executor doc and regenerate the notebook to show the converter-based usage pattern.
All five encoders (_encode_python_stack, _encode_python_list, _encode_python_string, _encode_cpp, _encode_go) now use json.dumps() to escape embedded double quotes and backslashes before interpolating into string literals. A prompt containing a double quote no longer produces malformed code. Also fix the class docstring: separator normalisation on [\s\-]+ applies only to python_stack; python_list uses str.split() and preserves hyphens. Add tests for embedded double quotes in all five encoder paths.
…ues/core.py Upstream (microsoft#2155) replaced initializers/components/scenario_techniques.py with initializers/techniques/core.py and renamed strategy_tags to technique_tags. The core group tag is now injected by build_technique_factories rather than stored in the factory. Port the code_attack entry and its test list update to the new layout.
Satisfies tests/unit/docs/test_converter_documentation.py, which requires every converter in pyrit.converter.__all__ to appear in a notebook under doc/code/converters/. The example lives in its own cell so the existing section 1.2 cell (which contains non-deterministic converters) keeps its stored output untouched. The converter is template-based and runs offline, so the new cell carries real executed output like every other cell on the page.
Every other factory in techniques/core.py carries a description; code_attack was the only one without. Matches the sibling register: single sentence, third-person present, states the mechanism.
…stable identifier Addresses review feedback on microsoft#1960. Escaping: every encoder used json.dumps() with the default ensure_ascii=True, so a non-BMP character became a surrogate pair ("😀"). Python evaluates that to two lone surrogates and Go and C++ reject surrogate escapes outright, so emoji input produced code that would not compile. All five encoders now route through one module-level _escape_string_literal() helper that emits literal UTF-8 and escapes only backslash, double quote and control characters. C++ uses three-digit octal for control characters because its hex escapes consume an unbounded run of hex digits and would swallow a following literal digit; Python and Go use two-digit hex, which their grammars bound. Verified by compiling the generated C++ and Go under -Wall -Wextra -Werror and go vet, then asserting byte-identical round-trips for emoji, newline, tab, backslash, double quote, control and DEL characters. Template loading: SeedPrompt.from_yaml_file() moved from convert_async into __init__ and cached on the instance, so a missing or malformed custom file now fails at construction instead of on first use. Construction also rejects a template whose body never references wrapped_input, which previously rendered a constant string and silently discarded the objective. Identifier: _build_identifier() no longer embeds an absolute template path, which was machine-dependent and did not change when a custom file's contents changed. It now reports the stable enum name, a sha256 prefix of the loaded template value and the encoding, matching TemplateSegmentConverter's shape. Encoding: a custom pathlib.Path template was forced to python_string, so a caller could not supply a custom stack, list, C++ or Go wrapper. Adds a separate Encoding enum, maps each Template member to its Encoding, and requires an explicit encoding= for Path templates rather than defaulting silently. The Template enum and the PYTHON_STACK_VERBOSE default are unchanged, so existing callers are unaffected. Docstring: the separator-normalisation section claimed normalisation was python_stack only. python_list also collapses whitespace runs via str.split(); the stack-specific part is that it additionally consumes hyphens and reverses order. Rewritten per encoding so a caller can tell what round-trips losslessly. Tests: 31 -> 64. Adds a real render parameterized over every Template member, exact decoded-literal assertions in place of escaped-substring matching, identifier stability across paths and sensitivity to content and encoding, non-BMP round-trips per encoding, control-character round-trips, custom template validation (missing file, malformed YAML, no wrapped_input, Path without encoding) and a focused code_attack factory-wiring test. Two existing tests changed because the behaviour they asserted is the bug being fixed: test_custom_path_template_constructs and test_custom_path_template_renders both relied on a Path template silently defaulting to python_string, and now pass encoding= explicitly.
Adds @ren2024codeattack to the citation-key list in doc/bibliography.md, keeping the list alphabetical (between @promptfoo2025ccp and @robustintelligence2024bypass). The bib entry already existed; it was not listed, so the citation did not render. Regenerates the modality table in doc/code/converters/0_converters.ipynb, whose stored output jumped from CharacterSpaceConverter straight to CodeChameleonConverter. CodeAttackConverter now appears at row 36. Only that cell was executed; the cells that need a live target were left untouched.
…literal Coverage audit against the six review asks turned up two gaps. The factory-wiring test was in the converter test file, but the repo keeps core technique factory tests in tests/unit/setup/techniques/test_core_techniques.py next to the flip technique. Moved it there as TestCodeAttackTechnique, matching TestFlipTechnique's shape: one test for the factory wiring (attack class, tags, description, the single wired CodeAttackConverter and its template and encoding) and one that the wired converter actually encodes the objective. Updated that module's docstring, which claimed it covered flip only. Seven assertions in the round-trip and edge-case tests still extracted literals with a ([^"]+) regex and compared the escaped form, which is exactly what the review asked to stop doing and which silently breaks on any escape. They now go through _decode_literals/_decode_assignment like the rest, so they compare the value the target language would actually see. The three superseded _extract_* helpers are gone, along with a duplicated _write_template. No behaviour change; converter test file 64 -> 63 tests because the factory test moved, core techniques 3 -> 5.
c41dc66 to
a45f7c8
Compare
|
Roman Lutz (@romanlutz) Addressed all of these. The substantive ones: language-aware escaping so non-BMP input survives Go and C++, eager template load with wrapped_input validation, content-hash identifier, and a new Encoding enum so custom Path templates aren't locked to python_string. Tests went 31 to 63 in the converter file plus 2 in core techniques. One I can't close: the 1_single_turn cell needs a live target and I don't have credentials. Left options on that thread. Also, CI still hasn't run on this PR at all. All five workflows are stuck awaiting maintainer approval, so none of this has been verified upstream yet. Could you kick that off? |
| if isinstance(template, CodeAttackConverter.Template): | ||
| self._template_path = pathlib.Path(CONVERTER_SEED_PROMPT_PATH) / f"{template.value}.yaml" | ||
| self._template_name: str = template.name | ||
| resolved_encoding = encoding if encoding is not None else _TEMPLATE_ENCODING[template] |
There was a problem hiding this comment.
Could we reject an encoding that does not match a built-in Template? For example, Template.PYTHON_STACK with Encoding.PYTHON_LIST produces:
my_stack = deque()
my_list.append("alpha")
task = decode(my_stack)The objective is written to my_list but decoded from the empty my_stack, so the attack silently loses the objective. Custom paths need an explicit encoding, but built-in templates should use their mapped encoding or raise on a mismatch. Am I missing a valid use case for overriding a built-in this way?
| """ | ||
| environment = SandboxedEnvironment() | ||
| referenced = meta.find_undeclared_variables(environment.parse(seed_prompt.value)) | ||
| if _WRAPPED_INPUT not in referenced: |
There was a problem hiding this comment.
Could we also reject unsupported template variables during construction? The converter only supplies wrapped_input, so a custom template such as {{ wrapped_input }} {{ suffix }} passes this check and then fails on its first conversion because suffix is undefined. Checking for any remaining undeclared variables here would keep custom-template failures at construction time.
Closes #1945.
Summary
Implements CodeAttack (Ren et al., ACL 2024, arXiv:2403.07865), which reformulates a harmful query as a code-completion task. The query is encoded into a data-structure initialization sequence inside a partial code template with a
decode()stub, and the target is asked to complete the code. Because the intent is expressed as a programming task rather than a natural-language request, safety training keyed to natural language triggers less reliably. Black-box, no compute requirements.Two notes for review (deltas from the issue)
Encoding is word-by-word, not character-by-character. The issue described it as char-by-char (from the paper abstract), but the reference implementation (renqibing/CodeAttack) splits on whitespace and hyphens via regex, with character-level only as a fallback for single-token inputs. I matched the reference code. One consequence: the two token-splitting encodings normalize separators.
PYTHON_LISTcallsstr.split(), so it collapses any run of whitespace and drops leading and trailing whitespace, though hyphens survive inside tokens.PYTHON_STACKdoes the same and additionally consumes hyphens as delimiters, and reverses token order. The three string encodings (PYTHON_STRING,CPP,GO) embed the prompt as a single literal and round-trip byte-identically. The converter docstring spells out which inputs are lossless under which encoding.Eight templates, not five. The issue scoped five (one per language), but the reference ships eight: the three Python types each have a base and a
_plusverbose variant, and cpp and go have no verbose variant upstream. I included all eight to match the reference. Happy to drop the three_plusfiles if you would rather keep it to five.Design
CodeAttackConverterinpyrit/converter/code_attack_converter.py. It encodes the prompt into a data-structure initialisation sequence and renders a partial code template with adecode()stub. It is a standaloneConverterand composes through the normal converter pipeline.Template selection is the
CodeAttackConverter.Templateenum, which has eight members:PYTHON_STACKcode_attack_python_stackPYTHON_STACK_VERBOSEcode_attack_python_stack_plusPYTHON_LISTcode_attack_python_listPYTHON_LIST_VERBOSEcode_attack_python_list_plusPYTHON_STRINGcode_attack_python_stringPYTHON_STRING_VERBOSEcode_attack_python_string_plusCPPcode_attack_cppGOcode_attack_goThe default is
PYTHON_STACK_VERBOSE. Apathlib.Pathcan be passed instead to supply a custom template file.Encoding is a separate axis from the template.
CodeAttackConverter.Encodingis a new public enum with five members:PYTHON_STACK,PYTHON_LIST,PYTHON_STRING,CPP,GO. For a built-inTemplatethe matching encoding is derived automatically and does not need to be passed. For apathlib.Pathit is required, because the data structure cannot be inferred from a custom file; passing aPathwithoutencoding=raisesValueErrorrather than silently defaulting.Eight seed prompts in
pyrit/datasets/converters/, matching the CodeChameleon convention:code_attack_python_stack.yaml,code_attack_python_stack_plus.yaml,code_attack_python_list.yaml,code_attack_python_list_plus.yaml,code_attack_python_string.yaml,code_attack_python_string_plus.yaml,code_attack_cpp.yaml,code_attack_go.yaml.No attack class. Per review feedback the earlier
CodeAttackAttacksubclass was dropped, and the technique is registered as thecode_attackfactory inpyrit/setup/initializers/techniques/core.py, wiring the converter ontoPromptSendingAttack.Tests
63 unit tests in
tests/unit/converter/test_code_attack_converter.py, covering per-template rendering, base vs_plusvariants, word-recovery round-trips, separator normalization, empty, special-character and long prompts, custompathlib.Pathtemplates, unsupported input types, and identifier construction.Factory coverage is split across two files.
TestCodeAttackTechniqueintests/unit/setup/techniques/test_core_techniques.pyholds the focused wiring tests (2): one asserting the factory shape (attack class, tags, description, and the single wiredCodeAttackConverterwith its template and encoding), and one asserting the wired converter actually encodes the objective. It sits next to the existingfliptechnique tests, which is where this repo keeps per-technique factory tests. Separately,tests/unit/setup/test_technique_initializer.pycontributes the registry-level check, wherecode_attackis added toCORE_TECHNIQUE_NAMESand asserted against the built factory set in six places.Two documentation surfaces:
doc/code/converters/1_text_to_text_converters.py, section 1.2 Obfuscation Converters, next to CodeChameleon. The converter is template-based and runs offline, so the cell carries real executed output.doc/code/executor/1_single_turn.py, showing the converter applied throughAttackConverterConfig.The
ren2024codeattackentry indoc/references.bibcites the published ACL 2024 Findings paper.Review round 2
Language-aware string-literal escaping. All five encoders used
json.dumps()with the defaultensure_ascii=True, so a non-BMP character became a surrogate pair (\ud83d\ude00). Python evaluates thatto two lone surrogates, and Go and C++ reject surrogate escapes outright, so emoji input produced code that
would not compile. The encoders now share one escaping helper that emits literal UTF-8 and escapes only
backslash, double quote and control characters. C++ uses three-digit octal for control characters, since its
hex escapes consume an unbounded run of hex digits and would swallow a following literal digit. Verified by
compiling the generated C++ and Go under
-Wall -Wextra -Werrorandgo vet, then asserting byte-identicalround-trips for emoji, newline, tab, backslash, double quote, control and DEL input.
Template loaded and validated in
__init__.SeedPrompt.from_yaml_file()moved out ofconvert_asyncand is cached on the instance, so it no longer parses YAML synchronously on every conversion and a missing or
malformed custom file fails at construction. Construction also rejects a template whose body never references
wrapped_input, which previously rendered a constant string and silently discarded the objective.Content-based identifier.
_build_identifier()no longer embeds an absolute template path, which differedacross worktrees and did not change when a custom file's contents changed. It now reports the stable enum name,
a
sha256[:16]of the loaded template value, and the encoding, matchingTemplateSegmentConverter's shape.Note on
0_converters.ipynb. Regenerating the modality table also picked up two converter rows thecommitted output predated:
AcrosticConverter(FEAT: add AcrosticConverter #2280) andVigenereConverter(FEAT: Add VigenereConverter #2333). Nothing was removed.Those rows are unrelated to this PR but the table is generated, so trimming them would mean hand-editing
generated output.
Files
New (10):
pyrit/converter/code_attack_converter.pypyrit/datasets/converters/code_attack_cpp.yamlpyrit/datasets/converters/code_attack_go.yamlpyrit/datasets/converters/code_attack_python_list.yamlpyrit/datasets/converters/code_attack_python_list_plus.yamlpyrit/datasets/converters/code_attack_python_stack.yamlpyrit/datasets/converters/code_attack_python_stack_plus.yamlpyrit/datasets/converters/code_attack_python_string.yamlpyrit/datasets/converters/code_attack_python_string_plus.yamltests/unit/converter/test_code_attack_converter.pyModified (11):
pyrit/converter/__init__.pypyrit/setup/initializers/techniques/core.pytests/unit/setup/techniques/test_core_techniques.pytests/unit/setup/test_technique_initializer.pydoc/references.bibdoc/bibliography.mddoc/code/converters/0_converters.ipynbdoc/code/converters/1_text_to_text_converters.pydoc/code/converters/1_text_to_text_converters.ipynbdoc/code/executor/1_single_turn.pydoc/code/executor/1_single_turn.ipynbNotes
Rebased onto main after the
PromptConvertertoConverterrename (upstream 3acaaa6), so the converter lives underpyrit/converter/, subclassesConverter, and the seed prompts moved topyrit/datasets/converters/.Total diff: 21 files changed, 1450 insertions(+), 68 deletions(-).
Checklist