fix(hitl): refuse a tool call that targets its own conversation - #689
Merged
ginccc merged 2 commits intoAug 15, 2026
Merged
Conversation
An agent granted the runtime conversation endpoints can list conversations — a
GET, exempt from approval — find its own, and POST /agents/{conversationId}
into it. That writes a USER turn, indistinguishable afterwards from something
the human typed, into the one channel the safety preamble designates as trusted.
It is the bridge from "text the agent READ from this platform" to "text the
agent was TOLD", which is the laundering route rule 1 exists to shut. An
approver cannot reasonably catch it either: the request shows an opaque
conversation id and nothing says it is the agent's own.
ToolLoopResumer refuses it at approval-execution time, which is the point: the
REST /resume endpoint, the Slack buttons and the MCP resume_conversation tool
all execute approved calls through this loop, so a check in any single approval
UI has three bypasses. The Manager keeps its own refusal as defence in depth.
Two deliberate differences from requestChangedSinceApproval next door. Unpinned
calls are checked — that method has no fingerprint to compare and nothing sound
to say, while this rule needs no baseline and falls back to the raw arguments.
Amended calls are checked too: an approver rewriting the arguments to aim at the
agent's own conversation is exactly the move being refused.
Contributor
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…/EDDI into fix/self-conversation-tool-call # Conflicts: # docs/changelog.md
pull Bot
pushed a commit
to Stars1233/EDDI
that referenced
this pull request
Aug 17, 2026
… live guard A four-reviewer audit of labsai#679-labsai#689 plus an end-to-end operator-path trace, then a second adversarial round over this fix itself. Everything confirmed is addressed here; docs/changelog.md carries the full account. - Cross-version placeholder stranding: dropPendingApprovalPlaceholder now recognises the previous builds' default wordings (legacy constant, suffix-less tool-named), so the first post-upgrade resume of an in-flight pause no longer renders [stale placeholder, answer]. Two upgrade-boundary tests simulate a pre-upgrade pause. - Self-conversation guard is now enforced on the LIVE path, including the mixed-batch pause branch the second review round caught (ungated calls execute before the pause is thrown and are never rechecked). Shared core extracted; same NOT_EXECUTED envelope and trace everywhere. - labsai#684 contract narrowed: failed results stay out of ApiCallsTask's cross-call template merge and out of the RAG system prompt; error bodies (and the status-message fallback) are redacted before reaching the model. Memory-side *Error keys unchanged. - Test-drive read-back: a blank returningFields entry means NO filter - [""] no longer nulls steps/outputs/properties out of the snapshot. - Generated tools: body $refs resolve one level (schemas namespace only), so descriptions name real fields - a guessed say-body bound to InputData defaults and silently sent an empty message; enum values and defaults now reach parameter descriptions (the environment typo->production trap). - padDataLines normalises bare CR; RFC 7615 headers join the credential response deny-list; labsai#688's shared-path stripping disclosed in changelog. 264 tests across the affected suites, including mutation-informed pins: same-tool ordinal drop, refused-mid-batch pairing, blank-filter recovery, redaction survival of the failure reason.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The route
An agent granted the runtime conversation endpoints can list conversations — a
GET, exempt from approval — find its own, andPOST /agents/{conversationId}into it. That writes a USER turn, indistinguishable afterwards from something the human typed, into the one channel the safety preamble designates as trusted:It is the bridge from text the agent READ from this platform to text the agent was TOLD — precisely the laundering route that rule exists to shut.
An approver cannot reasonably be expected to catch it: the request shows an opaque conversation id, and nothing in the call says that id is the agent's own.
Why the engine, not the approval UI
EDDI-Manager already refuses this on its three approval surfaces. That is one surface of several — the REST
/resumeendpoint, the Slack approval buttons and the MCPresume_conversationtool all execute an approved call throughToolLoopResumer, and none of them consult the UI. A control living only in the Manager is a control with three documented bypasses, which is exactly what two independent reviewers flagged on labsai/EDDI-Manager#158.The check now sits on the path they share, immediately after the existing request-pinning re-check and before the journal claim, so a refusal consumes nothing and stays replayable.
Two deliberate differences from
requestChangedSinceApprovalnext doorMatching
Substring, case-insensitive, percent-decoding-tolerant — the same asymmetry
self-guard.tsdocuments on the Manager side: a false positive costs one refused approval, a false negative costs the boundary. A blank conversation id refuses nothing, since""is a substring of every URI.Tests
Eight. The self-targeted refusal; a call to a different conversation still allowed (the operator test-drive this must not break); amended arguments; both unresolvable fallbacks; a blank id refusing nothing; and the casing/percent-encoding cases.
Related
🤖 Generated with Claude Code