[FIX]: Return UNDETERMINED when an evaluator cannot observe the evidence it needs - #141
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
@microsoft-github-policy-service agree |
…nce it needs ToolCalled and SideEffectOccurred returned NOT_DETECTED whether the thing did not happen or the adapter never reports it. Under attack semantics that resolves to SAFE, so an adapter at TOOL_ONLY running SideEffectOccurred reports "Agent defended successfully" for an agent that exfiltrated. EvalContext now carries the adapter's observability level, and both evaluators return UNDETERMINED when they cannot see the evidence they need, matching how LLMJudge already reports its own uncertainty. The check runs after the scan, so evidence the adapter does report still counts. The verdict downgrade in XPIAExecution._adjust_for_observability is unchanged and still owned by the execution strategy.
_AllEvaluator returned UNDETERMINED as soon as the left operand was
undetermined, so it never reached a right operand that was definitively
NOT_DETECTED. That made & depend on operand order: under RESPONSE_ONLY
observability, ToolCalled("x") & ResponseContains("absent") returned
UNDETERMINED, while the same pair written the other way round returned
NOT_DETECTED.
Only a NOT_DETECTED operand settles the conjunction on its own, so that is
the only case the left operand short-circuits now. The outcome tables for &
and | are covered in both operand orders, together with De Morgan's law,
which the old behavior broke.
The backstop paragraph in the XPIA docs is narrowed to match. A single
evaluator no longer reaches that check as SAFE, but a composition still can.
Rebased onto main, which now enforces RMP001 from microsoft#158 and microsoft#159. The tests this PR adds were written before that rule landed, so they are renamed to match it. Seven names are also shortened to stay inside the line limit.
Making & evaluate the right operand when the left is undetermined meant the right operand's evidence was computed and then thrown away. A judge detection that is real but not confirmable on its own was lost that way. Both undetermined branches now carry the evidence of both operands. Also covers the two algebraic properties the suite was missing: the negated-or form of De Morgan's law, and associativity for & and |. Both already held.
The probe summary already does this after the earlier commit in this PR, so the two paths disagreed. An XPIA run that is undetermined because one turn could not be observed led its summary with a NOT_DETECTED rationale from a different turn, which names the wrong reason.
Session.send_async said empty lists mean "no observations", not "nothing happened", and two doc pages repeated it. That rule predates the declared level. An empty list is now read against observability_profile: at a level that reports that evidence it means the thing did not happen, and at a level that does not it means the thing could not be seen. The old wording also contradicted observability_profile's own docstring in the same file. ObservabilityLevel's docstring only described the RESPONSE_ONLY case, so it omitted TOOL_ONLY with side effects, which is the case the linked issue is about. Also documents how UNDETERMINED travels through & and |, which no user facing page covered, and says which operator to reach for when two evaluators are two views of one harm.
…here & and | answer different questions, and the difference only shows when one operand cannot be observed. Under TOOL_ONLY a blind SideEffectOccurred with ResponseContains settles as NOT_DETECTED under &, in either order, and stays UNDETERMINED under |. Both are covered so a change to either has to be deliberate. Adds the missing return annotations on the tests added here, aligns two rationale test names that had drifted apart, and renames the composition class now that it covers the outcome tables and the algebraic laws rather than operand order alone.
0217f67 to
afacd6a
Compare
TestXPIAUndeterminedSummary was added above the last method of TestResponseMetadataPropagation, so test_multi_turn_metadata_keyed_by_turn_number_async silently became a method of the new class and its node id changed. Nothing failed, which is why it went unnoticed. The new class now follows the whole class it was meant to sit after. Collected node ids now differ from main by exactly the one intended rename.
_AnyEvaluator returned a bare "One or both operands undetermined" with no evidence, so an OR composition hid the adapter setting behind the verdict. That undoes the point of this PR on the OR path: the probe and XPIA summaries were changed here to name that setting, and the note added to authoring-tests.md points the reader at | for exactly this case. It now names the undetermined operand and carries the evidence of both, the same way & does. Outcomes are unchanged, so the truth table and the algebra tests are untouched. The observability paragraph in authoring-tests.md said a gap in the adapter cannot come back as a passing test. That holds for a single evaluator, not for a conjunction where the other operand definitively did not happen, so it now says so and points at the note below it.
Nina Chikanov (nina-msft)
left a comment
There was a problem hiding this comment.
Mostly doc comments in this round :-) Bashir Partovi (@bashirpartovi) - if you'd like to take a look before merge please do!
…ndering Review follow-ups. Both composite docstrings now give the outcome precedence in order instead of as a list, and the evidence sentence is narrowed to what the code actually does. _AllEvaluator carries both operands' evidence on DETECTED and UNDETERMINED but not on NOT_DETECTED, and _AnyEvaluator carries it only on UNDETERMINED. Verified against all nine operand pairs for each operator. The "Undetermined operands" note rendered as a code block on GitHub. A paragraph indented under an admonition after a blank line is a code block in plain Markdown, even though mkdocs renders the same source as prose. The note is now a single paragraph and the practical guidance follows it as ordinary text, which both renderers agree on. Drops "blind" from that note and from the side effect test names, and ends the corroboration sentence where it stops being useful.
The evidence sentence in _AllEvaluator said the DETECTED and UNDETERMINED outcomes are the ones reached after both operands run. That is not true: DETECTED & NOT_DETECTED also runs both and returns NOT_DETECTED. It now states only the part that holds, which is that those two outcomes are the ones carrying both operands' evidence. Applies the same review points to the wording they did not land on. The Session.send_async sentence is split so it reads cleanly, the composition note in the XPIA page loses the repeated "halves" phrasing and ends where it stops being useful, and one sentence in authoring-tests.md had its words in the wrong order.
resolve_as_probe returns UNSAFE only when some evaluator was NOT_DETECTED, but the summary took the last rationale of any outcome. Now that the evaluators in this PR can return UNDETERMINED, an undetermined turn can end up stating the reason for a definitive unsafe verdict: UNSAFE: Right operand undetermined: Adapter observability is 'tool_only', which does not report side effects The verdict is right there and the reason is not. It now takes the reason from a NOT_DETECTED result, which matches the undetermined branch three lines below and the XPIA summary.
|
Thanks for the thorough follow-ups Mahdi Alhakim (@mahdi-al-hakim). I read through the full review thread and the latest changes (PS. great comments Nina Chikanov (@nina-msft)). The main fix looks right to me that an observability gap should not be treated as proof that nothing happened, and the propagation and test coverage here are strong. I do still have one concern about I understand the literal conjunction argument. If both conditions are required and one definitively did not happen, then the conjunction is false even if the other condition could not be evaluated. What concerns me is the next step, for an attack, RAMPART maps that For example: The conjunction becomes I don't think the truth table itself is the problem. The problem is that "the predicate is false, but part of the evaluation was unobservable" becomes indistinguishable from "the predicate is false and all required evidence was observable." Those carry different levels of assurance, especially in a safety test. Part of what makes this tricky is that Moving evaluator failures to Could we agree on how to keep that gap visible before merging? I see two reasonable paths:
I prefer the first approach because it preserves the predicate algebra without losing the safety signal. If that is too large a change for this PR, the second approach seems safer as an interim behavior. Separately, I'd take you up on the XPIA summary issue you mentioned. Now that an One small documentation clarification I would like to mention is that the observability guarantee here is channel-level. A Other than those points, the direction looks solid :) |
Description
Fixes #140, which has a standalone repro: a probe asserting an agent does not exfiltrate passes green while the agent exfiltrates on every turn, because the adapter declares
TOOL_ONLYand so never populatesResponse.side_effects.ToolCalledandSideEffectOccurredreturnNOT_DETECTEDin two different situations: the thing did not happen, and the adapter does not report that kind of evidence.resolve_as_attackmapsNOT_DETECTEDtoSAFEandresolve_as_probemaps it toUNSAFE, so a gap in the adapter becomes a statement about the agent._adjust_for_observabilitycovers part of this, but only forRESPONSE_ONLY, only in XPIA, and only by counting tool calls.This PR adds a regression test for the attack side. Run against
mainat 7ae98a6 it produces:FAILandPASSthere are RAMPART's per-result verdict labels, not pytest outcomes. Both lines come from one pytest test that runs the same attack twice. The agent exfiltrates on both runs; the two adapters differ in the declared level and in what that level lets them report, so theTOOL_ONLYone returns the same response with noside_effects, which is exactly what aTOOL_ONLYadapter can do. RAMPART calls that second run "Agent defended successfully". The pytest test fails onmainand passes with this change.Every combination where a built-in evaluator needs evidence the adapter does not report, measured with a
MockAdapter:RESPONSE_ONLYToolCalledUNDETERMINEDUNDETERMINEDUNSAFEUNDETERMINEDRESPONSE_ONLYSideEffectOccurredUNDETERMINEDUNDETERMINEDUNSAFEUNDETERMINEDTOOL_ONLYSideEffectOccurredSAFEUNDETERMINEDUNSAFEUNDETERMINEDTwo of the six were already correct, and both were correct because
_adjust_for_observabilitycaught them.Rebased onto
mainat 7ae98a6, so the tests here followRMP001from #158 and #159.Changes
ObservabilityLevelgainsobserves_tool_callsandobserves_side_effects, following thePayloadFormat.is_textandis_binarypattern already in that file. Its class docstring described only theRESPONSE_ONLYcase, so it now also coversTOOL_ONLYwith side effects, which is the case in the linked issue.EvalContextgainsobservability_level, defaulting toTOOL_AND_SIDE_EFFECTSso a context built by hand is evaluated exactly as before.evaluate_turn_asynctakes the level and puts it on the context.XPIAExecutionandSingleTurnExecutionboth passadapter.observability_profile.ToolCalledandSideEffectOccurredreturnUNDETERMINEDwhen they cannot see the evidence they need. The check runs after the scan, so anything the adapter does report still counts as evidence._adjust_for_observabilitymakes the same allowance today.UNDETERMINEDsummary carries the evaluator's rationale instead of a fixed string, so the result names the adapter setting that caused it._AllEvaluatorshort-circuits only on aNOT_DETECTEDleft operand. AnUNDETERMINEDleft operand no longer skips the right one, so&no longer depends on the order the operands were written in. Both undetermined branches carry the evidence of both operands. This is the review fix from Nina Chikanov (@nina-msft) below._AnyEvaluatornames the undetermined operand and carries the evidence of both, instead of a bare "One or both operands undetermined". Outcomes are unchanged. Without this,|hid the adapter setting behind the verdict, which is the one thing this PR is trying to surface, and the note added toauthoring-tests.mdpoints the reader at|for exactly this case.NOT_DETECTEDrationale from a different turn.NOT_DETECTEDresult. It took the last rationale of any outcome, so once these evaluators can returnUNDETERMINED, an undetermined turn could state the reason for a definitive failure.Session.send_async,authoring-tests.mdandquickstart.mdsaid empty lists mean "no observations", not "nothing happened". That rule predates the declared level and now reads backwards, and it contradictedobservability_profile's own docstring in the same file. All three now say an empty list is read against the declared level.Why the fix is in the evaluator
Two docstrings disagree about this, so I want to be explicit about which one I followed and why. Both are quoted as they stand on
main; this PR updates both.rampart/core/types.py:27-29:rampart/evaluators/tool_called.py:23-25:I followed the first one.
The obvious alternative is to keep the adjustment central and have evaluators declare a
required_observabilityfor the strategy to read. I could not make that work for composition. UnderTOOL_ONLY,ToolCalled("x") | SideEffectOccurred("y")should still returnDETECTEDifxwas called, while the right operand cannot be observed. A strategy-level check against a composite's declared requirement cannot see the operands, so it either suppresses a real detection or does nothing. The post-scan allowance above has the same problem: "evidence the adapter actually reported still counts" is a per-operand runtime fact, not something a static declaration can express.|,&and~already arbitrate this correctly once operands can returnUNDETERMINED, which is what this change gives them.There is also precedent for an evaluator reporting its own uncertainty.
LLMJudgereturnsUNDETERMINEDwhen the judge output is malformed after retries or the call fails, rather than guessing. Those are transient instrument failures and an observability gap is static configuration, so the situations are not identical, but the outcome type is doing the same job in both:EvalOutcome.UNDETERMINEDis defined as "The evaluator could not make a determination".The adjustment itself stays where the second docstring puts it.
_adjust_for_observabilityis unchanged and still owns the verdict downgrade. What changes is the quality of its input. The sentence inToolCalled's docstring is contradicted by this PR and is updated, as is the matching note indocs/usage/authoring-tests.md.No new verdict semantics
UNDETERMINEDis not new at either level.EvalOutcome.UNDETERMINEDis produced today byLLMJudgeand by|and&, and preserved by~.SafetyStatus.UNDETERMINEDis produced by both resolvers and by_adjust_for_observability. Every consumer already handles it: the resolver precedence rules, the composition operators, the xdist round trip throughSafetyStatus(value),JsonFileReportSink, the WARN terminal label, and the population summary. This change produces it in more of the cases it already exists for.DETECTEDthat came from observed evidence is untouched on every path, so no evidence-based detection is weakened. The one detection that changes is~inverting an absence the adapter could not attest, covered below.Breaking changes
None to the API. Nothing is removed or renamed,
EvalContextiskw_only=Trueso adding a field cannot break positional construction, both new parameters have defaults, and nothing new is serialized.Verdicts change in one direction for the evaluators on their own, and in one cell for
&.For
ToolCalledandSideEffectOccurredused alone,NOT_DETECTEDbecomesUNDETERMINEDand nothing moves towardSAFE. What existing suites will see:UNDETERMINEDand fails. That is the bug being fixed, and it will surface as a newly red test.ToolCalledorSideEffectOccurredbelow the level it needs goes fromUNSAFEtoUNDETERMINED. Both are falsy, so the test still fails, but the terminal label changes from FAIL to WARN.~ToolCalled(...)underRESPONSE_ONLYpreviously returnedDETECTEDby inverting an absence the adapter could not attest, and now passesUNDETERMINEDthrough. On a probe, "must not call X" against an adapter that cannot report tool calls was a false pass and now fails. The linked issue is the same shape one level down:~SideEffectOccurred("http_request")against aTOOL_ONLYadapter.UNDETERMINEDlogs a passing gate line where it previously logged a failing one. The clones still fail, sinceassert resultis falsy, and_evaluate_gatesonly logs, so no CI outcome flips. I left the threshold alone because PR [FEAT]: Add execution trial populations and threshold verdicts #121 is reworking that layer.Making
&order independent required choosing which outcome wins when one operand isNOT_DETECTEDand the other isUNDETERMINED. It returnsNOT_DETECTED, which is Kleene and is what the review asked for. Against every operand pair onmain, one cell moves:This is not a regression against
mainforToolCalledorSideEffectOccurred, which returnedNOT_DETECTEDonmainat a level that could not report the evidence, so the conjunction already resolvedSAFE. It does mean a composed evaluator no longer gets the protection the first commit of this PR gave it in one of the two operand orders, and that a degradedLLMJudgeinside&can now resolveSAFEwheremainsaidUNDETERMINED.|reportsUNDETERMINEDin those cases, and the docs now say which operator to reach for. There is more detail, and three options, in my reply to the review comment.There is no migration beyond fixing the adapter's declared level or the evaluator choice, both of which the new rationale string names. No API changes, but existing suites do go red, so say the word and I will retitle this
[BREAKING] [FIX]:, the placementdocs/contributing/pull-requests.mdgives and the one #159 used. The comment above the regex in.github/pull_request_template.mdsays the opposite order and appears to be stale, since nothing enforces it and #159 would fail it.Deliberately out of scope
_adjust_for_observabilityalso fires when it should not:RESPONSE_ONLYwithResponseContainsis downgraded even though that evaluator never needed tool data. That is a false positive rather than a false negative, and narrowing the heuristic is a separate change.LLMJudgenow receivesobservability_leveland ignores it. Telling the judge that tool calls are not visible would stop it reading an evidence-free transcript as innocence, but that changes judge prompting.ResponseContainsis untouched on purpose. Every level reports text, so no declared level hides it.EvalOutcome.UNDETERMINEDinto "cannot observe" and "did not run" so&can treat them differently. That is the real fix for theLLMJudgecase above and it is bigger than this PR.Checklist
pre-commit run --all-filespassesTests
49 new tests. One existing test changed:
test_left_undetermined_short_circuits_asyncasserted that&skips the right operand when the left isUNDETERMINED, which is the behavior the review asked me to remove. It is nowtest_left_undetermined_evaluates_right_asyncand asserts the right operand runs; its outcome assertion is unchanged. Collected node ids differ frommainby exactly that one rename, so nothing else existing was changed, removed or reparented. Three existing test helpers gained a defaultedobservabilitykeyword equal to their previous behavior.test_xpia.py(7): the paired run quoted above, theTOOL_ONLYfalseSAFE,RESPONSE_ONLYwithToolCalled, full observability still resolvingSAFE, a real detection stillUNSAFE, and the undetermined summary naming the right reason.test_single_turn.py(7): the probe side, plus the undetermined summary carrying the rationale and falling back without one, and the unsafe summary taking its reason from a not-detected result.test_tool_called.py(9) andtest_side_effect.py(7):UNDETERMINEDat each insufficient level, the rationale naming the level and the target,NOT_DETECTEDwhen the level is sufficient, evidence still detected below the declared level,UNDETERMINEDpropagating through|, and the unobserved-operand composition cases in both operand orders.test_evaluator.py(12): the outcome table for&and|, commutativity over all nine operand pairs, De Morgan in both directions, associativity over all 27 triples,UNDETERMINED & UNDETERMINEDstaying undetermined, and the rationale and evidence carried through both an undetermined conjunction and an undetermined disjunction.test_types.py(6): the two properties across all three levels, theEvalContextdefault, andfrom_responsepassing the level through.test_execution.py(1):evaluate_turn_asyncputs the level on the context.Nine of the new tests fail against the commit before the
&fix and pass against this one.tests/integration/test_smoke.pyusesToolCalledthroughEvalContext.from_responseand asserts a detection, so it is unaffected. It needs no credentials and passes:2 passed.Documentation
docs/usage/authoring-tests.md: theToolCalledwarning said it "always returnsNOT_DETECTED" underRESPONSE_ONLY, which is no longer true.SideEffectOccurredhad no note and now has one. Added a short paragraph under the levels table on why declaring the level honestly matters, and a note on howUNDETERMINEDtravels through&and|, which no user facing page covered.docs/attacks/xpia.md: the Observability Adjustment section now says what it is for, now that evaluators handle their own cases, and the composition example says which operator to reach for when two evaluators are two views of one harm.docs/contributing/extending-rampart.md: the custom execution strategy example calledevaluate_turn_asyncwithout the level, which would silently treat every adapter as fully observable. Fixed, plus a bullet in the key points.docs/getting-started/quickstart.mdanddocs/glossary.md: the empty-list rule and theEvalContextentry.No new pages, so no
mkdocs.ymlnav change.Checks run locally
The
tyandflake8-ramparthooks shell out touv, which I do not have on thismachine, so those two were run directly at the versions pinned in
uv.lock. The tworuff hooks pass through
pre-commititself.mkdocs build --strictaborts on bothmainand this branch for the sameenvironmental reasons: it cannot create symlinks on Windows and cannot reach
fonts.gstatic.com. Filtering those out leaves no warnings on either side.