fix(directive): honour a marker on provenance, not on appearance - #8696
fix(directive): honour a marker on provenance, not on appearance#8696chenmingwei23 wants to merge 2 commits into
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of All checks pan out: Design-Verdict: PASS Provenance at the single producer is the root-cause fix; it fails closed, rests on a documented sequential-dispatch invariant, and is mutation-pinned by tests. Suggestions
[DESIGN-REVIEWED] acc3ab8 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All checks complete. Here is the review. First-Principles-Verdict: PASS A demonstrated, main-reproducible forgery is closed at the one seam where provenance is knowable, with a three-function mechanism whose every piece has exactly one counted consumer. What this change shipsIntent: stop a rejected directive-tool call from applying the very arguments validation refused, by honouring a marker only when this process emitted it. This is a FIX.
The cause chain checks out: Watch
[FIRST-PRINCIPLES-REVIEWED] acc3ab8 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've fully traced the mechanism independently:
The candidate list contains no candidates, and my independent Step 2 falsification finds no grounded defect: no genuine-directive regression, no reachable forgery, no crash or data loss. Docs ( No findings. [OPUS-REVIEWED] acc3ab8 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
14193c6 to
1291b38
Compare
f2a1ec2 to
90ecec9
Compare
…refusal An over-long autonudge_stop reason was rejected by the schema in the dispatch wrapper, ahead of the handler, so _emit_directive never ran: the loop was not stopped, and the marker-less error landed in the consumer's lost-marker branch and fired the WARNING that exists to catch a rawOutput-envelope escaping regression (~10x/day on the reporting host). FieldSpec.clamp_to_max makes the two stop tools' reason truncate instead of reject, stamped with what the cut dropped. refuse_if_markerless tags every marker-less return from a directive tool at the server's outermost return, so decode FAILED means exactly one thing again; ask_question's nested validation and the two callers of parse_github_pull_request_target now RETURN their declines through one guarded seam, and a ratchet over DIRECTIVE_TOOLS keeps that total. Because both sentinels are tail-anchored, tag_refusal bounds its text and preserve_tail_marker re-attaches a marker the transport cut removed. Closes #8635
1291b38 to
6dacb04
Compare
A directive marker is model-visible text, so a gate that decides 'is this a directive?' from the bytes can be imitated by content the model chose. validate_tool_args reports an unknown field by echoing the argument NAME, so a name carrying the sentinel plus a JSON payload plus a newline made the REJECTION string decode as a genuine directive under the real tool's authenticated identity, applying the arguments validation had just refused. Reproduced on untouched main, so this is an inherited hole. _emit_directive now vouches for the one marker it produces, _call_tool clears that record per dispatch, and refuse_if_markerless defangs any marker nobody vouched for -- so forgery fails by construction rather than by author discipline. The three mcp_shared error-construction sites also defang, kept as defense in depth for the audit row and the other four servers. _emit_directive classifies its own output structurally too: testing is_refusal(out) matched any payload merely CONTAINING the refusal token, so a stop whose reason quoted it lost both its publish and its marker. Refs #8635
90ecec9 to
acc3ab8
Compare
Problem / Motivation
The session-directive marker is model-visible text -- it comes back as a tool result -- so any gate that decides "is this a directive?" by looking at the bytes can be imitated by content the model chose.
validate_tool_argsreports an unknown field by echoing the argument NAME, and the model picks that name, which makes it the injection point:The rejection string echoes the name verbatim, and the consumer decodes it under the genuine tool's authenticated
_metaidentity -- the call really WASautonudge_stopserved bykirocrew-core, it had merely failed validation -- so the forgery gate passes andapply_session_directiveruns the arguments validation had just refused. Measured against the real_call_tool:<SENTINEL>{payload}None(trailing message text keeps the line from parsing)<SENTINEL>{payload}\n{'reason': 'FORGED'}\n<SENTINEL>{payload}\n{'reason': 'FORGED'}\n<SENTINEL>{payload}NoneThe newline is the load-bearing part: it ends the marker's line, leaving the payload as the whole line
json.loadssees.This is an INHERITED hole, not a regression. The same probe decodes
{'reason': 'FORGED'}on untouchedmain, and #8640 -- which introducedrefuse_if_markerless-- neither caused it nor closed it: that gate passed a marker-bearing result through untouched, exactly asmaindoes.Why it matters
The applier keeps its own gates (sensitive-path checks, the authorizer, session-type refusals), so the smuggled effect is confined to the caller's own session. What it bypasses is the tool's ARGUMENT validation -- the layer that decides whether a payload is well-formed at all -- under an identity the consumer has already authenticated. And it is silent: the forged marker made
has_marker()true, so the result was not even tagged a refusal, meaning the forgery suppressed the one diagnostic that would have named it.Scope note (updated)
The first layer of this work --
session_directive.neutralize_markersand its threemcp_sharedcall sites -- MOVED INTO #8640. It is independent of everything in that PR, GPT blocks #8640 on the
forgery hole it closes, and that finding is real rather than overridable, so the layer had to travel
with the PR that merges first. The forgery reproduction, its four argument-name shapes and the
defang tests now live there.
What remains HERE is the structural half: positive provenance. That is the part that needs #8640's
refuse_if_markerlessto exist, and the part that is a new mechanism rather than a patch.What changed (motivation -> approach -> change)
Defanging the text at each site where an error is built closes the paths you remember. It is per-site author discipline, and #8640's own convention -- a directive tool must RETURN its declines rather than raise them -- routes declines through handler
return f"Error: {exc}"statements that no defanging covers. Safe only while those messages stay static. So the question itself is changed:Positive provenance.
_emit_directiveis the ONE producer of a real marker, so it vouches for what it built (a digest, recorded on the dispatch);_call_toolclears that record before every dispatch; andrefuse_if_markerlessdefangs any marker nobody vouched for. "Does this look like a directive?" becomes "did we make one?", and forgery fails by construction rather than by discipline. The single module slot is safe for the documented reasonmcp_caller.set_current_callerrelies on -- MCP dispatch is strictly sequential, one worker joined before the next -- and clearing before dispatch is what stops the previous call's genuine directive from laundering this call's bytes.Defanging is inherited from #8640, and stays as defense in depth. Those three
mcp_sharedsites are no longer part of this diff, but they remain load-bearing: they keep live marker bytes out of the SEL audit row and the four other MCP servers' outputs, where no vouch gate runs. This PR adds the layer they cannot provide -- a decline RETURNED by a handler never passes an error-construction site at all.The emitter classifies its own output structurally.
_emit_directiveused to askis_refusal(out)to detectencode's refusal -- a CONTENT test, which matched any payload that merely CONTAINED the refusal token. A stop whosereasonquoted that token was therefore filed as a refusal: no publish, no vouch, and its genuine marker defanged downstream, so the stop was lost. It now tests for the marker's presence, which is the structural fact. A gate against imitable content cannot itself be built on imitable content. This also repairs a pre-existing bug on the same line: such a reason already skipped the out-of-band publish before any of this work.Tests
test/test_directive_marker_provenance.py:Noneand are tagged refusals, and none of them publishes a recordneutralize_markersleaves ordinary text alonereasonquoting the refusal sentinel still stops the loop AND still publishes out of band, alongside an ordinary reason as the controlMutation-verified:
has_markeras the provenance checkis_refusal(out)in_emit_directivequotes-refusal-tokenfails -- the stop is lost531 passed across the directive, monitor, validation, MCP-core, cron, artifacts and messaging-driver suites (the wider set because the error path is shared by all five MCP servers).
Manual verification
Probed through the real
_call_toolrather than only through the handler, and against a separate worktree at untouchedmainto establish the hole is inherited. N/A for UI.Screenshots / video
N/A -- no user-visible UI change.
Related Issues
Refs #8635. Stacked on #8640, which must merge first: this PR modifies
refuse_if_markerless, which #8640 introduces. Review it against that base -- the diff here is the provenance delta only.Pattern harvest
Rule candidate: review-prompt
Pattern: a trust decision made from imitable content. A control token that travels through model-visible text cannot be authenticated by its own appearance, no matter how distinctive -- authenticity has to come from the producer saying so out of band. The same mistake appeared twice at two levels in this work (the consumer gate trusting
has_marker, then the emitter trustingis_refusal), which is the tell that the fix belongs at the seam where provenance is KNOWN rather than at each site that inspects the bytes.Other notes for the reviewer