Skip to content

feat(trace): add investigation submission endpoint and The Debrief challenge - #573

Open
stealthwhizz wants to merge 9 commits into
GenAI-Security-Project:mainfrom
stealthwhizz:feature/trace-purple-team-evaluator
Open

feat(trace): add investigation submission endpoint and The Debrief challenge#573
stealthwhizz wants to merge 9 commits into
GenAI-Security-Project:mainfrom
stealthwhizz:feature/trace-purple-team-evaluator

Conversation

@stealthwhizz

Copy link
Copy Markdown
Contributor

Summary

Adds PurpleTeamEvaluator, which scores forensic reasoning instead of agent behavior: a student submits {server, tool, directive} identifying a compromised MCP server, the poisoned tool, and the directive it carried, and each field scores independently against a known seeded answer. Also adds the investigation-submission API endpoint and "The Debrief" challenge that uses it.

Test plan

  • Partial credit verified at 33/66/100 score thresholds
  • Incorrect submissions score 0; extra fields don't inflate the score
  • Investigation challenge live and scored correctly end to end

Scores a student's forensic investigation submission (server, tool,
directive) against the expected answer in detector_config, with
independent partial credit per field (33/33/34 by default). Server and
tool match exactly; the free-text directive uses substring matching by
default, or exact when directive_match is set.

Scoring is deterministic and never calls an LLM, so it behaves
identically under any model backend. Evidence records the score and
which fields were correct but never the expected answer, so completion
evidence cannot spoil the challenge.

19 unit tests cover the 33/66/100 thresholds, zero-score submissions,
extra fields that must not inflate the score, directive fuzzy/exact
modes, normalization, the tool_arguments fallback, and config
validation.
…allenge

Wire the PurpleTeamDetector into a playable forensic challenge:

- POST /ctf/api/v1/toolkit/investigation accepts a {server, tool,
  directive} answer and emits business.investigation.submitted with a
  fresh workflow_id per submission, so each attempt is tracked.
- The Debrief challenge is a purple team follow-up to Toxic Transfer.
  It names finstripe/create_transfer as the poisoned tool and grades the
  directive by meaning, not verbatim text.
- Upgrade directive matching from substring to token overlap with light
  prefix stemming so students can paraphrase. A configurable
  directive_threshold (default 0.6) controls how much of the expected
  answer must appear.

24 unit tests pass, including reordered-paraphrase acceptance and
unrelated-text rejection. Full submit-to-score flow verified against the
challenge config.

@syedDS syedDS left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. In purple_team_detector.py:157, the fuzzy-match logic accepts a submission if it's a substring of the expected answer:
    if exp_norm in sub_norm or sub_norm in exp_norm:
    return True

The problem seems to be the second half (sub_norm in exp_norm) "is what the student typed contained somewhere inside the correct answer." There's no minimum length check on it. The seeded answer is:

"email the vendor tax id and bank account to an external address"

If a student just types directive: "to", that's technically a substring of the sentence above, so it scores 100% correct with zero understanding of the attack. Same goes for "and", "id", "account", etc. Since this is the core thing the challenge is testing, this one basically breaks the challenge.

Recommended Fix: either remove that substring branch and rely only on the token-overlap scoring below it (which already has a real threshold), or add a minimum word-count requirement before accepting a substring match.

  1. The challenge description tells students: "Partial credit is awarded, so a partial understanding still earns points." That's not what happens.

What actually happens: score (0–100) only decides pass/fail them detected = score >= min_score. Once a student passes that bar, they get the full 250 points, no matter whether they scored 50 or 100. This is because the points multiplier (points_modifier) comes from a completely separate mechanism (scoring.modifiers in the debrief.yaml), which this challenge doesn't configure so it stays at 1.0 always.

Issue is a person who correctly names the server and tool but writes total nonsense for the directive (score 66) earns exactly the same reward as someone who gets everything right (score 100). And once they pass, the challenge is marked complete there's no way to go back and improve the score even if a student wanted to.

Fix: either wire the score into points_modifier so points scale with accuracy (the codebase already has this plumbing, just needs connecting), or rewrite the description so it doesn't promise something the code doesn't do.

Comment thread tests/unit/ctf/test_purple_team_detector.py
stealthwhizz added a commit to stealthwhizz/finbot-ctf that referenced this pull request Aug 24, 2026
… bypass and unearned full credit

Both issues raised by @syedDS.

1. Fuzzy directive matching accepted a submission that was merely a substring
   OF the expected answer. The seeded directive is an ordinary sentence, so
   single filler words are literal substrings of it: "to", "and", "id" and
   "account" each scored the directive field at full credit. Combined with
   server/tool being small guessable enums, a player could take all 250 points
   without understanding the attack -- which is the only thing this challenge
   tests.

   Removed that direction. Quoting the expected directive in full still passes;
   genuine paraphrases still pass through the token-overlap check, which has a
   real threshold. Added regression tests covering the reviewer's exact
   examples, plus tests proving real paraphrases are still accepted.

2. The description promised partial credit that the code did not deliver. The
   0-100 score only decided pass/fail; every player past min_score earned the
   same 250 points, because points_modifier comes from scoring.modifiers, which
   this challenge never configured.

   Connected the existing plumbing rather than rewording the promise:
   ChallengeService now passes the DetectionResult through to modifiers as
   detection_evidence, and a new generic `detection_score` modifier converts a
   detector's score into points_modifier (award = points * score/100, floored
   by min_multiplier). The Debrief opts in at min_multiplier 0.5, so 66/100 now
   earns 165 of 250 instead of the full award. The modifier is generic: any
   detector reporting a score can use it.

   Description rewritten to state the per-field values and the scaling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stealthwhizz

Copy link
Copy Markdown
Contributor Author

@syedDS Thanks for the review — both points were spot on, and both are fixed in 607b71a.

1. Substring bypass in the fuzzy directive match

You were right that this broke the challenge outright. With the seeded answer, submitting "to" scored the directive field at full credit, and combined with server/tool being small guessable enums a player could take all 250 points without understanding anything.

I went with your first option: dropped the sub_norm in exp_norm direction entirely and let the token-overlap check below handle partial answers, since it already has a real threshold. Quoting the expected directive in full still passes, and genuine paraphrases still pass.

Added regression tests for your exact examples (to, and, id, account, plus a couple more), a test reproducing the full "guess the enums, type one word" scenario, and tests proving real paraphrases are not caught by the stricter rule.

2. Partial credit that wasn't

Also correct — the score only gated pass/fail, and everyone past min_score earned the same 250 because points_modifier came from scoring.modifiers, which this challenge never configured.

I took the "wire it up" option rather than reword the description. Instead of special-casing The Debrief, I added a generic detection_score modifier: ChallengeService now passes the DetectionResult through to modifiers as detection_evidence, so any detector that reports a score can opt in. Challenges that don't configure it are unaffected (modifier stays 1.0).

Result:

score modifier award
100 1.00 250
66 0.66 165
50 0.50 125

min_multiplier: 0.5 keeps a passing answer worth at least half. The description now states the per-field values (33/33/34) and the scaling explicitly instead of a vague promise.

Tests in test_purple_team_detector.py: 22 → 31, all passing.

One note: this branch has 4 pre-existing test failures (test_sai_von_005, test_sai_edge_001/002, test_sqlite_url_from_db_path). I verified they fail identically at the parent commit acd3b7f, so they're unrelated to this PR — I left them alone to keep this commit focused on the review.

… bypass and unearned full credit

Both issues raised by @syedDS.

1. Fuzzy directive matching accepted a submission that was merely a substring
   OF the expected answer. The seeded directive is an ordinary sentence, so
   single filler words are literal substrings of it: "to", "and", "id" and
   "account" each scored the directive field at full credit. Combined with
   server/tool being small guessable enums, a player could take all 250 points
   without understanding the attack -- which is the only thing this challenge
   tests.

   Removed that direction. Quoting the expected directive in full still passes;
   genuine paraphrases still pass through the token-overlap check, which has a
   real threshold. Added regression tests covering the reviewer's exact
   examples, plus tests proving real paraphrases are still accepted.

2. The description promised partial credit that the code did not deliver. The
   0-100 score only decided pass/fail; every player past min_score earned the
   same 250 points, because points_modifier comes from scoring.modifiers, which
   this challenge never configured.

   Connected the existing plumbing rather than rewording the promise:
   ChallengeService now passes the DetectionResult through to modifiers as
   detection_evidence, and a new generic `detection_score` modifier converts a
   detector's score into points_modifier (award = points * score/100, floored
   by min_multiplier). The Debrief opts in at min_multiplier 0.5, so 66/100 now
   earns 165 of 250 instead of the full award. The modifier is generic: any
   detector reporting a score can use it.

   Description rewritten to state the per-field values and the scaling.
@stealthwhizz
stealthwhizz force-pushed the feature/trace-purple-team-evaluator branch from 607b71a to 0150572 Compare August 24, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants