Repository navigation
FEAT: Run one technique against an objective with technique.execute - #3118
Richard Lundeen (richlundeen) wants to merge 3 commits into
Conversation
Keep source-history fingerprints in resume metadata, preserve packaged identities on retry results, and reuse configured attacks with fresh execution contexts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db42a1de-ae96-464a-8766-d0b84818c1f2
Expose max_concrete_techniques from scenario metadata. Validate resolved selections in previews and runs, and use a single-selection GUI picker when the limit is one. Keep baseline comparison separate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db42a1de-ae96-464a-8766-d0b84818c1f2
Return the concrete ScenarioTechnique class without a redundant cast, preserving type checks on this branch and the merged CI branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db42a1de-ae96-464a-8766-d0b84818c1f2
| children = [ | ||
| SequentialChildAttack( | ||
| strategy=technique.attack, | ||
| seed_group=execution_group, | ||
| adversarial_chat=adversarial_chat, | ||
| objective_scorer=self._objective_scorer, | ||
| memory_labels={"_objective_attempt": str(attempt + 1)}, | ||
| technique_identifier=technique.get_identifier(), | ||
| identity_seed_group=group, | ||
| ) | ||
| for attempt in range(retries + 1) | ||
| ] |
There was a problem hiding this comment.
🔴 Must Fix: A finished child can hide an incomplete retry sequence.
I reproduced this with real SQLite storage and PromptSendingAttack: set retries_on_objective_failure=2 and max_retries=1, let the first child finish with FAILURE, then raise a transport error in the second child. The error retry sends nothing and marks the scenario COMPLETED. Resuming the saved run also sends nothing, so the remaining objective attempts never happen.
SequentialAttack gives each child the scenario collection and the compound's parent_eval_hash. Scenario._get_completed_objective_hashes_by_attack_async() therefore treats the first failed child as completion of the whole unit, even though the compound ended in ERROR.
Please track completion of the compound separately from completion of its children, and cover an exception after an earlier child has completed in both the error-retry and saved-run resume tests.
| factory = factory.with_extra_turns(turns=get_prepended_turn_count(groups[0].prepended_conversation)) | ||
| converters = self._technique_converters.get(name) | ||
| technique = factory.create( | ||
| objective_target=context.objective_target, | ||
| attack_scoring_config=AttackScoringConfig(objective_scorer=self._objective_scorer), | ||
| extra_request_converters=( | ||
| ConverterConfiguration.from_converters(converters=converters) if converters else None | ||
| ), | ||
| ) |
There was a problem hiding this comment.
🔴 Must Fix: Request converters change the saved history a second time.
SeedPrompt.from_messages() copies the values the target already received, but the attack's default PrependedConversationConfig applies request converters to the copied user messages again. The source database row stays unchanged, yet the new target receives different history.
For example, I stored a user turn whose converted value is c29ydCBhIGxpc3Q= and continued with Base64Converter in technique_converters. At the real target boundary, that historical turn became YzI5eWRDQmhJR3hwYzNRPQ==. With a model-based converter this can also rewrite old turns and make additional model calls.
Please keep already-sent history out of the request-conversion pipeline while still applying the selected converters to new outgoing turns. This needs to cover both converters baked into the technique and additional technique_converters.
| return AttackTechnique( | ||
| attack=SequentialAttack( | ||
| objective_target=context.objective_target, | ||
| child_attacks=children, | ||
| completion_policy=SequenceCompletionPolicy.FIRST_SUCCESS, | ||
| ) | ||
| ) |
There was a problem hiding this comment.
🟡 Should Fix: The wrapper is counted as another objective attempt.
After a real SQLite run with retries_on_objective_failure=2, three target calls, and no exceptions, compute_scenario_statistics(result) reports attempts=4 and overall.retries=3. The three child results and the orchestration envelope all resolve to the same execution unit. In the late transport-error case, the child ERROR and wrapper ERROR also count the same failure twice.
The planned/completed unit count is correctly one, but retry and error pressure shown by the SDK, backend, and reports are inflated. Please distinguish orchestration rows from actual attempt rows when calculating these statistics, and add a persisted-result check that three objective attempts produce two retries, not three.
Draft until we do some work to glue some paramaters together
Description
Adds
technique.execute: run exactly one registered attack technique toward one free-text objective, without selecting a dataset. It can also continue from a stored manual conversation. This connects manual testing to an automated attack without changing the source conversation.red_teaming; a custom catalog without it uses the first technique in name order. Adds plain multi-turncrescendoto the opt-inextracatalog.harm_categorieson the objective and its results. Use standard target selection, technique converters, and the existing adversarial target picker/context override.retries_on_objective_failureto allow additional attempts. Each attempt gets a fresh context and conversation; execution stops at the first success.max_retriesremains the separate error/exception retry setting.prepended_conversation_idto copy stored history through the standard seed path. Roles, order, data types, converted values, and message metadata are retained; old request traces are removed. Prior assistant turns extend attacks' configuredmax_turnsso manual history does not consume the new automated-turn budget. Blocked/error history is rejected explicitly.Technique selection contract
The scenario declares
MAX_CONCRETE_TECHNIQUES = 1. The backend publishes this asmax_concrete_techniquesin the scenario catalog and enforces it after aggregate expansion and deduplication, for both estimates and runs. Other scenarios remain unrestricted by default.CoPyRIT uses radio buttons when the limit is one. Selecting another technique replaces the previous selection; tags remain descriptive badges rather than bulk-selection buttons. Baseline is an independent checkbox and does not count toward the limit. Over-limit drafts cannot request an estimate or launch until corrected. The shared picker also respects larger declared limits.
The existing form renders the scenario parameters. Dataset inputs are removed through
Execute.supported_parameters(). The independent generic GUI fix in #3119 makes forms honor existing declarations across all scenarios, including opaque common inputs. Until that fix lands, some generic dataset controls remain visible and must be left unset. The two changes stay in separate PRs: #3119 handles input support; this PR handles technique-selection cardinality. This change adds no common CLI flags or new technique selector parameter.CLI
Start a backend using your normal configuration. It must register the objective and scorer targets; multi-turn techniques also need an adversarial target.
--config-fileselects the backend configuration when starting it.Run one technique with one objective:
Here,
2permits up to three objective attempts, stopping on success. Do not pass--dataset-namesor--max-dataset-size.To continue from a stored conversation in the same memory backend, add:
To use plain Crescendo instead, register the extra catalog at backend startup or select it through the existing initializer syntax:
The scenario-specific arguments are discovered from the backend catalog. Adversarial target selection uses the existing GUI picker or configured default; this change adds no CLI flag for it.
GUI preview
Real CoPyRIT screenshots captured with isolated demo targets and no live model calls. The selected technique now uses a radio control; the tag badges do not change the selection:
The scenario-specific parameters and existing adversarial target picker:
Tests and Documentation
Added paired
doc/scanner/technique.py/.ipynbdocumentation and scanner navigation. Coverage includes catalog selection, parameters, converters, baseline, source-history copying, multipart tool metadata, new-turn budgets, adversarial overrides, retries, persisted child identities, GUI/API resume, changed-source refusal, completed-run resume, and selection-count enforcement. Updated E2E argument setup to omit dataset limits for scenarios that do not accept dataset configuration.Initial scenario implementation tests — passed, 805 tests:
Technique-selection backend tests — passed, 251 tests:
Catalog/request regressions — passed, 121 tests:
Frontend scenario-form tests — passed, 72 tests; type check and build — passed:
All pre-commit hooks — passed after the selection changes and again after the merged-branch type fix:
The Linux merged-branch check found a cast made redundant by main's newer typed factory builder. Removed the cast and aligned the local builder's return annotation. Follow-up tests — passed, 160 tests; type check — passed:
JupyText — passed; outputs retained and paired sources checked:
The notebook's catalog cells run without model calls. Its configured-target SDK example is documentation, not a live-service test.
CLI smoke run — passed through the real CLI and backend with isolated demo targets and mocked scorer I/O:
Also checked the live catalog reports
max_concrete_techniques=1, an estimate with two techniques returns HTTP 400, GUI radio selection is exclusive, and baseline does not change it. No live model calls or full test suite were run.uv.lockis unchanged.