Skip to content

feat(hitl): approval binds to the resolved request, not the tool name - #627

Merged
ginccc merged 40 commits into
mainfrom
feat/operator-request-fingerprint
Aug 7, 2026
Merged

feat(hitl): approval binds to the resolved request, not the tool name#627
ginccc merged 40 commits into
mainfrom
feat/operator-request-fingerprint

Conversation

@ginccc

@ginccc ginccc commented Aug 3, 2026

Copy link
Copy Markdown
Member

Makes a human approval bind to the actual HTTP request a tool call will send, instead of to its name. Until now an approver of a gated http call saw the tool's name and the model's raw arguments — never the method, path or body — because those are only produced inside ApiCallExecutor#execute, after approval. So what someone signed off on and what ran could differ. This is the precondition EDDI-Manager#129 needs before granting the operator any write at all.

Size 40 commits · 44 files · +3,598 / −121
Of which ~20 main-code files, 18 test files, 3 docs
Risk Touches the HITL resume path. Behaviour for existing agents is unchanged unless they have hitlConfig.toolApprovals set.
Merge order This merges first. #129 calls requestPreview/requestPinned, the hitlConfig on setup, and /administration/operator/*.

Where to look

Ranked — the first three are where a bug would actually matter:

  1. ResolvedRequest.java — the fingerprint canonicalisation. If two different requests can hash alike, the whole guard is decorative. Note the deliberate asymmetry: headers are hashed redacted, body is hashed raw.
  2. ApiCallExecutor.javaresolve(), and canExecuteDivergeFromResolve(), which decides what may be pinned at all. Getting that predicate wrong pins something we cannot honour, and then refuses a legitimate call.
  3. RequestRedactor.java — the single definition of "redacted request", shared by the conversation debug record and the approver preview so they cannot drift. Four separate leaks were found here during review; all four are in the tests.
  4. AgentOrchestrator.java — gate-time pinning and the pre-execution re-check.
  5. AgentSetupService / SetupAgentRequest — the new hitlConfig on the standard agent-setup path.
  6. Everything else is metrics relays, docs and tests.

What it does

Resolve without sending. IApiCallExecutor.resolve builds the request an ApiCall would send and returns method, URI, query, redacted headers, body and a SHA-256 fingerprint — without sending it.

Pinned at gate time, re-checked before execution. The fingerprint and a redacted preview are stored on the pause. On resume the call is re-resolved and compared immediately before it runs; a mismatch refuses it with a synthetic NOT_EXECUTED and an audit line (hitl.tool.request_changed — tool and callId only, never the request), and the rest of the batch proceeds.

Not everything can be pinned, and that is deliberate. The rule is never pin what cannot be honoured — four cases where execute() may legitimately build a request resolve() did not:

Unpinnable when Why
The tool is not http No HTTP request on this side of the boundary.
preRequest.propertyInstructions They write to conversation memory; resolving early would apply them twice.
fireAndForget + batchRequests The batch expands into N requests at execution, none of them the previewed one.
retryApiCallInstruction with maxRetries >= 1 buildRequest is inside the retry loop, and each attempt re-renders against a memory the previous attempt wrote to.

The retry row is the easy one to miss: maxRetries defaults to 3, so "retryApiCallInstruction": {} alone unpins a write. Read requestPinned per call; do not infer it from the endpoint.

Unpinned and amended calls pass through unchanged — nothing is refused on a comparison that was never sound. Three cases fail closed, because "cannot verify" is not "unchanged": the tool vanished mid-pause, re-resolution throws, or a pinned call became unpinnable.

Gate provisioning. SetupAgentRequest gained hitlConfig — the standard agent-setup path previously had no way to install a gate, so every agent it created shipped ungated. Validated up front and set on v1, never via a later PUT, so an ungated v1 cannot stay reachable by redeploy. Deliberately absent from the MCP setup_agent tool, which would otherwise let a caller pick its own gate.

Redaction. Four leaks closed, each mutation-verified: the body was never redacted at all; header values were judged only by name (so X-Client-Auth: Bearer … reached the approver in full); percent-encoding defeated the shape rules; and password was missing from the name list despite this class's own javadoc listing it.

Plus: a resume verdict that resolved to null was one comparison away from executing as approved, with the metric tagging it "rejected" while it ran. Fixed at both ends.

What to be sceptical about

  • Headers leave the fingerprint on a broad rule. The stated reason is ${caller:token}, which legitimately differs between requester and approver — but the exemption is any header matching the sensitive-name list or a secret shape. A header carrying ordinary business data under a credential-ish name therefore does not participate in change detection. URI, query and body are all covered as-resolved.
  • Field names in the canonicalisation are not length-prefixed (values are). Not reachable today — parameter names come from static config, never templates — but the scheme's stated purpose is that field boundaries cannot be forged.
  • The metric counts intent, not outcome. eddi.operator.write.approval{decision=approved} fires before the refusal checks, so an approved-then-refused call increments approved. Don't alert on it as "writes executed".

Verification

Every security-relevant fix is mutation-tested — the fix reverted, the specific test confirmed red, restored. The one worth naming: two different API keys must produce different fingerprints, which fails if body redaction is hoisted above the hash.

CI green including Integration Tests, which cannot run in a sandbox (no loopback sockets) and is therefore the first true end-to-end confirmation — and it ran on the post-merge commit, so it covers the merge with main.

@Nested-only classes report Tests run: 0 in the plain-text surefire report even when green; real counts are in the XML. ResolvedRequestTest 33, ApiCallExecutorTest 59, RequestRedactorTest 16, AgentOrchestratorResumeToolLoopTest 14.

Docs: docs/hitl.md, docs/changelog.md.

ginccc added 9 commits August 2, 2026 20:30
Groundwork for binding a human approval to the request that actually
executes, rather than to a tool name. Nothing calls resolve() yet.

Today an approver of a gated tool call sees the tool's name and the model's
raw arguments. For a client generated from an OpenAPI spec that is close to
meaningless: the name comes from an operationId and says nothing about
which resource is written or with what body. Method, path, query and body
are only produced inside ApiCallExecutor#execute, after approval.

IApiCallExecutor#resolve now builds exactly that request and returns it
redacted, alongside a fingerprint.

Two design points worth stating, because both look like compromises:

The fingerprint covers the REDACTED request. That is the point, not a
concession. ApiCallExecutor resolves ${caller:token} into Authorization,
and on a resumed turn the caller is whoever approved the pause — routinely
not the person whose turn raised it. Fingerprinting the live header would
mismatch on every cross-user approval, i.e. on correct behaviour, until
someone switched the guard off. Redacting first makes the fingerprint
answer what approval is actually about: what the request does. Whose
credentials carry it is authentication's business.

resolve() does NOT run pre-request property instructions, because those
write to conversation memory and previewing a call must not change the
conversation. A call that has them therefore cannot be resolved to the
request execute() will build, so it comes back with a null fingerprint and
will simply not be enforced — rather than being failed on a comparison that
was never sound. Tools generated from a spec never carry them, so the
operator's writes are always pinned.

Canonicalisation is length-prefixed rather than delimiter-separated: a JSON
body can contain any delimiter, and without prefixes a body carrying a
newline could impersonate an extra header field and collide. Header names
are lowercased and both maps sorted, so casing and ordering — neither of
which changes what the request does — cannot change the hash.

Also extracts the header redaction ApiCallExecutor already did privately
into RequestRedactor, now shared by the memory scrub and the approval
preview. Two copies of "what counts as a credential" would eventually
disagree, and the one that drifted would leak. The toMap() key names move
onto IRequest for the same reason — readers of that map should not
re-spell the strings.
Each gated httpcall tool now resolves to the request it would send, and the
pause carries both a redacted preview of it and its fingerprint. Nothing
enforces the fingerprint yet — that is the next commit; this one only makes
the pause record the truth.

The preview replaces guessing. An approver previously saw a tool name and
the model's raw arguments, and the Manager reconstructed a method and path
client-side by looking the operationId up in a spec it fetched separately.
That reconstruction is a guess from a document that can drift, and it was
labelled as such because it could not be anything better. The backend knows
the answer exactly, so it now says it.

Headers ride along in the preview even though they are mostly dull, because
the fingerprint covers them: a header the approver never saw could
otherwise be the thing that later fails the check, and "approve what you
are shown" has to mean the whole of what is checked.

Three deliberate non-failures, all of which leave a call simply unpinned
rather than breaking anything:

- Non-http tools have no resolver. There is no HTTP request on this side of
  the boundary to pin, so builtin/mcp/a2a calls are approved on name and
  arguments exactly as before.
- A call whose pre-request property instructions would have to run first
  cannot be resolved without writing to conversation memory, so it is left
  unpinned rather than pinned to a request execution will not build.
- A resolver that throws is logged and skipped. Letting a template error
  abort the batch would turn a display feature into a way to kill a turn.

Null therefore means "unenforced", never "rejected" — no call is ever
refused on a comparison that was never sound.

The executor and the resolver now share templateDataFor(): the fingerprint
is only meaningful if it was computed from the inputs execution will use,
and two copies of that merge would eventually disagree — rejecting correct
calls, or worse, passing altered ones.

Body truncation in the preview is display-only and cannot weaken the check:
the fingerprint is computed over the whole body before capping, which the
oversize-body test pins by asserting a change past the cut-off still moves
the hash.
Closes the loop opened by the previous two commits. A pinned call is
re-resolved immediately before execution and refused if its fingerprint no
longer matches the one the human approved. Approval now binds to a request,
not to a tool name.

Checked before the journal claim, so a refusal consumes nothing and the
call stays replayable. The refusal returns a synthetic NOT_EXECUTED result
rather than throwing: the model sees that the call did not run and can say
so, and the rest of the batch proceeds normally.

Fails closed on anything unverifiable, which is a different question from
unchanged:

- the tool is gone from the workflow (the agent was reconfigured while a
  human was deciding),
- re-resolution throws,
- the call can no longer be pinned at all (config gained a pre-request
  property instruction across the pause).

All three refuse. A pin we can no longer check is exactly the situation
this guard exists for; treating "cannot verify" as "unchanged" would make
reconfiguring an agent mid-pause the way around it.

Two deliberate exemptions, both returning "proceed":

- A call that was never pinned. Every non-http tool, and anything
  unresolvable at gate time. There is no comparison to make, and inventing
  a failure here would break every builtin/mcp/a2a approval.
- An amended call. The approver rewrote the arguments themselves, so the
  pin describes the request they replaced — comparing against it would
  refuse every amendment. An amendment is already a deliberate, audited act
  by the same human whose approval the pin exists to honour.

The audit line carries the tool, the call id and a fixed reason string, and
deliberately no argument, body or header.

Mutation-verified: disabling the check kills four tests, covering the
tamper case and all three fail-closed paths.
The rubber-stamping signal the plan calls for: approvals far outnumbering
rejections over time means the approval step has stopped being read.

Emitted once per gated call the instant its verdict is resolved, before any
of the downstream branches (truncated args, the changed-request refusal
from the previous two commits, execution itself) — all of those are still
an instance of a decision having been made, human or automatic.

"write" names the mechanism, not the transport: every call reaching this
loop was gated by toolApprovals.requireApproval, whether it dispatches over
http, mcp, or a2a. Scoping the tag to http-sourced calls only would
silently drop a gated MCP write from the signal.

decidedBy distinguishes an actual human decision from one the timeout
policy made (HitlTimeoutHandler, decidedBy = "system:timeout") and tags it
"timeout" rather than folding it into approved/rejected — an unattended
timeout auto-approval inflating "approved" would defeat the metric's whole
purpose. Tagged only with the decision outcome: no tool name, argument, or
conversation id.

Follows the existing AgentOrchestrator idiom (recordRuleMatches,
recordPauseCapGuard): Metrics.globalRegistry, not an injected
MeterRegistry, since this class is not CDI-managed; best-effort, swallowing
any emission failure rather than letting it break the LLM loop.

Testing this against Metrics.globalRegistry needed one extra thing: outside
a running Quarkus app the global registry is a bare CompositeMeterRegistry
with no backing store attached, so meters register and increment without
throwing but every read-back is silently 0. Attaching a SimpleMeterRegistry
in @BeforeAll (guarded, so repeat attachment across test classes in the
same fork is a no-op) is what makes the counter observable at all — without
it all four tests below would report a false pass.

Mutation-verified: disabling the timeout/verdict distinction fails exactly
the two timeout-tagging tests, leaving approved/rejected untouched.
…rics

Completes the plan's metrics table. eddi.operator.write.approval (prior
commit) is genuinely backend-native — the orchestrator observes every
decision directly. The other three are not: the write canary is a synthetic
conversation the Manager drives in the browser, and gate verification is
the Manager re-reading every version of the operator agent document. This
codebase has no first-class notion of "the operator" at all — it's an
agent like any other with a particular hitlConfig — so neither fact has a
server-side event to hang a meter on.

POST /administration/operator/{canary-result,gate-status} exists purely to
relay those already-established facts onto this deployment's /q/metrics,
so an on-call engineer watching Grafana does not need a Manager tab open
to see whether the write gate is currently sound. It is NOT a verification
endpoint — a report is trusted at face value — which is exactly why it
sits behind eddi-admin, the same tier that can provision the operator in
the first place. Whoever could misreport through it could reconfigure the
operator directly instead.

eddi.operator.gate.verified defaults to 0 before any report ever arrives,
matching "fail closed on an inconclusive signal" — a deployment that has
never activated an operator therefore also reads 0, indistinguishable from
one whose gate broke. That ambiguity is real and not solved here; it needs
a separate activation signal if an alerting rule has to tell the two apart.

Duration and outcome are recorded independently: a negative or absent
durationMs still counts the outcome, since a malformed timing value says
nothing about whether the gate held.

Testing needed one thing the CDI-managed path gets for free: the gate
gauge is registered once in @PostConstruct, which never fires when a test
constructs the service directly. Made public (not package-private) and
called explicitly in @beforeeach — same shape as RestDocsTest, which
already establishes the "construct the collaborator directly, no mocking
framework" pattern this class follows.

Full mvnw test run checked against the documented environmental baseline
(no-network loopback failures in Web*ToolTest) — none of the touched
classes appear in the failure list.
Belated per AGENTS.md §2 rule 8 — should have landed alongside each of the
four preceding commits on this branch rather than after all of them.

docs/hitl.md: new "Request pinning" subsection under Tool-Level Approval
Gating, covering what gets resolved and fingerprinted, why the fingerprint
covers the redacted request rather than the live one, and the deliberate
unpinned/amended exemptions versus the three fail-closed cases. Operations
metrics list extended with all four new meters.

docs/changelog.md: one entry covering the whole branch to date (the four
commits already pushed), including the honest "what's left" list — the
Manager-side canary, populating WRITE_ENDPOINTS, real scope selection, and
rendering the server preview in place of the client-side reconstruction —
so the entry doesn't read as though writes are already reachable.
The gate-time pinning from three commits ago persisted requestPreview and
requestFingerprint on PendingToolCallBatch.PendingToolCall, but nothing
external ever read them back — buildToolCallPauseDetails builds its
response as an explicit LinkedHashMap, field by field, so a new model field
does not appear in the API just because it exists on the entity. This is
the other half: an approver's GET .../approval-status now receives the
actual resolved request (method, URI, query, redacted headers, body) for
any pinned call, replacing what the Manager has so far had to guess by
reconstructing an operationId against a separately-fetched spec.

requestPinned rides alongside requestPreview so a client can tell "nothing
to preview" (every non-http tool, or an http call that could not be
resolved without side effects) apart from a resolution that failed
silently. The raw fingerprint itself is deliberately NOT exposed — it is
an internal comparison value with no meaning to a human approver, and
there is no reason to hand it out.

Explicit field-by-field again, matching every other field this method
already builds (arguments, gateReason, ...) rather than handing the POJO
to Jackson: keeps the exposed shape under the same review as the redacted
arguments field right above it.

The OTHER read path — namesOnlyPendingToolCalls, the security-motivated
projection used by the generic conversation-read surfaces (MCP
read_conversation, REST simple conversation log) — needed no code change:
it is an explicit allow-list copy, so a field it was never told to copy is
absent by construction, the same way argumentsRaw and argumentsRedacted
already are. Only its doc comment needed updating to name the two new
fields explicitly, and a test now pins that guarantee for them the same
way the existing test already pinned it for the older fields.

Verification note: this repo's `@Nested`-only JUnit test classes report
`Tests run: 0` in the plain-text surefire report even when they pass —
documented in memory before this session, re-confirmed the hard way during
it (see updated `surefire-nested-test-filter`). The real result for both
touched test classes, read from the XML `<testsuite>` attribute rather
than the ambiguous .txt: RestAgentEngineToolPauseDetailsTest tests="11"
errors="0" failures="0"; ConversationMemoryUtilitiesHitlTest tests="8"
errors="0" failures="0". Both include the three new pinned/unpinned/
fingerprint-exclusion cases plus the one securing the redaction boundary.
Addendum to the request-pinning changelog entry: the approval-status
REST surface now returns requestPinned/requestPreview per pending
call, and the write-approval decision metric plus the two Manager
metrics-relay endpoints are live. Updates the stale "what's left"
note now that WRITE_ENDPOINTS is populated on the Manager side.
RequestRedactor only ever touched "headers", so both consumers of a
resolved request — the debug record persisted to the conversation
document and the approval preview shown to a human — carried the body
verbatim. A config write carries its credential in the body, and the
approver is routinely a different admin than whoever's turn raised the
pause.

Adds RequestRedactor.redactBody, delegating to SecretRedactionFilter
(the same value-shape scan already behind argumentsRedacted, so the two
cannot drift), wired into redactRequestMap and ResolvedRequest#of.

Headers stay fingerprinted redacted, for the cross-user-approval reason
already documented. The body is fingerprinted RAW and only the stored
copy is redacted: a body has no equivalent legitimate variance, and
redacting first would hash two different credentials to one marker and
so to one fingerprint, letting a swapped secret pass the pre-execution
re-check as unchanged. ResolvedRequest#of does the redaction itself so
no call site can invert that order.
Copilot AI review requested due to automatic review settings August 3, 2026 12:28
@ginccc
ginccc requested a review from rolandpickl as a code owner August 3, 2026 12:28
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds HTTP request pinning for HITL approvals, redacted request previews, SHA-256 fingerprints, fail-closed resume verification, approval metrics, and admin-protected operator metric relay endpoints.

Changes

HITL security and metrics

Layer / File(s) Summary
Request resolution and fingerprinting
src/main/java/ai/labs/eddi/modules/apicalls/impl/*, src/main/java/ai/labs/eddi/engine/httpclient/*, src/test/java/ai/labs/eddi/modules/apicalls/impl/*
HTTP requests can be resolved without execution. Sensitive values are redacted. Canonical request data produces optional SHA-256 fingerprints.
HITL pinning and resume verification
src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java, src/main/java/ai/labs/eddi/engine/memory/model/PendingToolCallBatch.java, src/main/java/ai/labs/eddi/engine/internal/RestAgentEngine.java, src/test/java/ai/labs/eddi/modules/llm/impl/*, src/test/java/ai/labs/eddi/engine/internal/*
Gated HTTP calls persist redacted previews and fingerprints. Resume processing re-resolves pinned calls and rejects changed or unavailable requests before execution.
Operator metric reporting
src/main/java/ai/labs/eddi/engine/api/*, src/main/java/ai/labs/eddi/engine/rest/RestOperatorMetrics.java, src/test/java/ai/labs/eddi/engine/api/OperatorMetricsServiceTest.java, src/test/java/ai/labs/eddi/engine/rest/RestOperatorMetricsTest.java
Admin-protected endpoints accept canary and gate-status reports. The service records outcomes, durations, and gate verification state.
Documentation and contract validation
docs/*, src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java, src/test/java/ai/labs/eddi/engine/memory/*, src/test/java/ai/labs/eddi/modules/llm/impl/*
Documentation covers pinning, redaction, metrics, and integration status. Tests verify serialization boundaries, approval responses, resolver behavior, and metric updates.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentOrchestrator
  participant ApiCallExecutor
  participant PendingToolCallBatch
  participant RestAgentEngine
  AgentOrchestrator->>ApiCallExecutor: resolve and redact gated request
  ApiCallExecutor-->>AgentOrchestrator: preview and fingerprint
  AgentOrchestrator->>PendingToolCallBatch: persist request binding
  RestAgentEngine->>PendingToolCallBatch: return approval details
  AgentOrchestrator->>ApiCallExecutor: re-resolve request on resume
  ApiCallExecutor-->>AgentOrchestrator: current fingerprint
  AgentOrchestrator->>AgentOrchestrator: continue or fail closed
Loading

Possibly related PRs

  • labsai/EDDI#576: Both changes modify IRequest, HttpClientWrapper, and ApiCallExecutor; this change adds request pinning and redaction.
  • labsai/EDDI#606: Both changes modify AgentOrchestrator HITL resume and tool-execution flow.
  • labsai/EDDI#625: Both changes modify HITL approval handling in AgentOrchestrator and PendingToolCallBatch.

Suggested reviewers: rolandpickl, aisabella-ai

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: HITL approvals now bind to resolved HTTP requests instead of only tool names. This change is reflected throughout the changeset in request pinning, fingerprinting, and re-validation logic.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/operator-request-fingerprint

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR strengthens HITL (human-in-the-loop) tool approvals by binding approvals to the resolved HTTP request (method/URI/query/headers/body) rather than just the tool name + raw model arguments, and adds operator-facing metrics plus request redaction to prevent credential leakage in persisted/debug/approval surfaces.

Changes:

  • Add IApiCallExecutor.resolve() and a ResolvedRequest (+ fingerprint) mechanism to preview/pin/redact HTTP requests at gate time and re-check them before execution.
  • Extend HITL pause persistence and REST approval-status payloads with requestPinned + requestPreview, and enforce “request changed since approval” fail-closed behavior for pinned calls.
  • Add operator metrics (eddi.operator.write.approval{decision} + client-reported canary/gate relays) and new tests/docs to cover the new HITL behavior and redaction rules.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java Pins resolved request previews/fingerprints at gate time; re-resolves and refuses execution if a pinned request changed; emits write-approval decision metrics.
src/main/java/ai/labs/eddi/modules/apicalls/impl/IApiCallExecutor.java Adds resolve() contract for side-effect-free request resolution to support approver previews and pinning.
src/main/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutor.java Implements resolve(), wires centralized request redaction, and reuses redaction for persisted request debug maps.
src/main/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequest.java New value type for redacted request preview + SHA-256 fingerprinting with canonicalization.
src/main/java/ai/labs/eddi/modules/apicalls/impl/RequestRedactor.java New shared redaction utility for headers and body (value-shape based) used by both persistence and approval preview paths.
src/main/java/ai/labs/eddi/engine/memory/model/PendingToolCallBatch.java Extends pending tool-call persistence with requestFingerprint and requestPreview plus preview body size cap.
src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java Ensures “names-only” snapshot projections exclude request previews/fingerprints (security boundary).
src/main/java/ai/labs/eddi/engine/internal/RestAgentEngine.java Surfaces requestPinned and redacted requestPreview in approval-status pauseDetails payloads.
src/main/java/ai/labs/eddi/engine/httpclient/IRequest.java Adds KEY_* constants and clarifies toMap() contract (used by resolve/redaction).
src/main/java/ai/labs/eddi/engine/httpclient/impl/HttpClientWrapper.java Uses IRequest.KEY_* constants for request map shaping (ensures consistent keys).
src/main/java/ai/labs/eddi/engine/api/OperatorMetricsService.java New service to back operator canary + gate-verification metrics in Micrometer.
src/main/java/ai/labs/eddi/engine/api/IRestOperatorMetrics.java New admin-only REST interface for reporting client-observed operator canary/gate outcomes into /q/metrics.
src/main/java/ai/labs/eddi/engine/rest/RestOperatorMetrics.java REST implementation validating and delegating canary/gate metric reports.
src/main/java/ai/labs/eddi/engine/api/model/OperatorCanaryReport.java New DTO for canary outcome + duration reporting.
src/main/java/ai/labs/eddi/engine/api/model/OperatorGateStatusReport.java New DTO for gate verification reporting.
src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorTest.java Updates tests for new HttpCallToolsResult record shape.
src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedTest.java Updates tests for new HttpCallToolsResult record shape.
src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorCoverageTest.java Adds coverage for request pinning behavior, “changed since approval” enforcement, and write-approval metric tagging.
src/test/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequestTest.java New test suite validating fingerprint stability/discrimination and body redaction vs hashing order.
src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorTest.java Wires RequestRedactor and adds regression test ensuring secrets in request bodies are redacted before persistence.
src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorExtendedTest.java Updates executor construction to include RequestRedactor.
src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorBranchCoverageTest.java Updates executor construction to include RequestRedactor.
src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorValidationErrorTest.java Updates executor construction to include RequestRedactor.
src/test/java/ai/labs/eddi/engine/internal/RestAgentEngineToolPauseDetailsTest.java Adds tests for requestPinned/requestPreview exposure rules and ensures fingerprints do not leak via REST responses.
src/test/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilitiesHitlTest.java Extends snapshot security tests to ensure request preview/fingerprint do not leak through generic snapshots.
src/test/java/ai/labs/eddi/engine/api/OperatorMetricsServiceTest.java New unit tests for canary counters/timer, outcome vocabulary validation, and gate gauge behavior.
src/test/java/ai/labs/eddi/engine/rest/RestOperatorMetricsTest.java New REST boundary tests for validation + metric emission delegation.
docs/hitl.md Documents request pinning semantics, preview exposure, enforcement rules, and new metrics.
docs/changelog.md Adds detailed changelog entry explaining motivation, design, enforcement, and verification notes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +273 to +278
var requestMap = buildRequest(targetServerUrl, call, templateDataObjects).toMap();
var headers = requestMap.get(IRequest.KEY_HEADERS) instanceof Map<?, ?> h ? (Map<String, ?>) h : Map.<String, Object>of();
var queryParams = requestMap.get(IRequest.KEY_QUERY_PARAMS) instanceof Map<?, ?> q
? (Map<String, String>) q
: Map.<String, String>of();
Object body = requestMap.get(IRequest.KEY_BODY);
Comment on lines +42 to +47
/** Key of the {@code Map<String, String>} of headers in {@link #toMap()}. */
String KEY_HEADERS = "headers";
/**
* Key of the {@code Map<String, String>} of query params in {@link #toMap()}.
*/
String KEY_QUERY_PARAMS = "queryParams";
Comment thread docs/hitl.md Outdated

For an `http`-sourced call, the tool name alone tells an approver little: it comes from the endpoint's `operationId` (or a generated slug) and says nothing about which resource is targeted or with what body. So at gate time each gated httpcall tool is **resolved** — `IApiCallExecutor.resolve` builds the method, URL, query, headers and body it would send, without sending it — and both a **redacted preview** and a **SHA-256 fingerprint** of that resolved request are persisted on the pause (`PendingToolCall.requestPreview`, `.requestFingerprint`). The preview is what an approver should actually look at, not the raw tool arguments.

`GET .../approval-status` surfaces this: each entry in `pauseDetails.calls[]` carries `requestPinned` (boolean) and, when pinned, `requestPreview` — `{method, uri, queryParams, headers, body, bodyTruncated}`, all already redacted. The raw fingerprint itself is never exposed; it is an internal comparison value with no meaning to a human. `requestPinned: false` with `requestPreview: null` means exactly what it says — nothing to preview, not a resolution failure the caller should treat as an error — see the unpinned/fail-closed cases above.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/main/java/ai/labs/eddi/engine/api/IRestOperatorMetrics.java (1)

39-62: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Operator metrics endpoints skip the required AsyncResponse pattern. Both the contract and its implementation declare reportCanaryResult/reportGateStatus as synchronous methods returning Response directly. As per coding guidelines: "Backend code must be thread-safe and non-blocking; use AsyncResponse for REST endpoints and avoid extended blocking in tasks."

  • src/main/java/ai/labs/eddi/engine/api/IRestOperatorMetrics.java#L39-L62: change both method signatures to accept an AsyncResponse parameter and return void, per the codebase's REST endpoint convention.
  • src/main/java/ai/labs/eddi/engine/rest/RestOperatorMetrics.java#L30-L46: update both implementations to call asyncResponse.resume(Response.noContent().build()) (or the equivalent error path) instead of returning Response synchronously.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/engine/api/IRestOperatorMetrics.java` around lines
39 - 62, Update src/main/java/ai/labs/eddi/engine/api/IRestOperatorMetrics.java
lines 39-62 so reportCanaryResult and reportGateStatus accept AsyncResponse and
return void. Update
src/main/java/ai/labs/eddi/engine/rest/RestOperatorMetrics.java lines 30-46 to
match these signatures and complete success or error handling via
asyncResponse.resume(...) rather than synchronous Response returns.

Source: Coding guidelines

src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java (1)

838-853: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the new javadoc so auditOutcomeUnknown keeps its documentation.

Lines 838-846 document auditOutcomeUnknown. The new javadoc block at lines 847-853 was inserted between that comment and the method it described. The result is two consecutive javadoc comments before requestChangedSinceApproval, and auditOutcomeUnknown at line 905 now has no javadoc. Move the auditOutcomeUnknown javadoc back down to line 905.

♻️ Proposed reordering
-    /**
-     * Records an at-most-once outcome-unknown event. No lightweight
-     * {`@code` hitl.tool.*} audit collector is reachable from this task (the
-     * {`@link` ai.labs.eddi.engine.audit.model.AuditEntry} record is built by the
-     * LifecycleManager per-task with HMAC context we do not have here), so —
-     * exactly as the config-drift path does — this WARN-logs with a distinctive
-     * marker that operators can alert on. Package-private + overridable so tests
-     * can assert it fired.
-     */
     /**
      * Whether the request this approved call would now send differs from the one
      * that was approved — the check that makes an approval bind to a request.

Then add the moved javadoc immediately above auditOutcomeUnknown at line 905.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java` around
lines 838 - 853, Move the at-most-once outcome-unknown Javadoc currently
preceding requestChangedSinceApproval so it appears immediately above
auditOutcomeUnknown. Keep the requestChangedSinceApproval Javadoc directly
attached to that method, and preserve the existing auditOutcomeUnknown
documentation unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutor.java`:
- Around line 284-290: Update the resolved preview construction in
ApiCallExecutor around ResolvedRequest.of to redact every query-parameter value
with the existing requestRedactor before passing queryParams. Preserve the
parameter names and structure, and apply the same scalar redaction behavior used
for other request components so secrets are not persisted or displayed.
- Around line 273-278: The query-parameter flow in ApiCallExecutor.resolve must
unwrap RequestWrapper.toMap() values from Map<String, List<String>> into the
scalar representation expected by ResolvedRequest before constructing it. Update
the queryParams normalization at the ApiCallExecutor site, and revise the
IRequest.KEY_QUERY_PARAMS contract in IRequest to accurately document the
emitted or normalized shape; preserve existing behavior for absent parameters.

In `@src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java`:
- Around line 1005-1017: Update the http resolver registration around
mergeExternalTools so toolRequestResolvers retains entries only when toolSources
confirms the http tool won that name. Filter httpCallTools.resolvers() against
the post-merge winner mapping before adding them, ensuring duplicate names keep
the incumbent tool’s behavior and cannot resolve or pin the dropped HTTP
request.
- Around line 2116-2122: Update the WARN in the request-resolution catch block
around resolver.resolve and setRequestFingerprint to pass req.name() through the
existing sanitize(...) helper before logging it, matching the handling used by
other log statements in AgentOrchestrator.

---

Nitpick comments:
In `@src/main/java/ai/labs/eddi/engine/api/IRestOperatorMetrics.java`:
- Around line 39-62: Update
src/main/java/ai/labs/eddi/engine/api/IRestOperatorMetrics.java lines 39-62 so
reportCanaryResult and reportGateStatus accept AsyncResponse and return void.
Update src/main/java/ai/labs/eddi/engine/rest/RestOperatorMetrics.java lines
30-46 to match these signatures and complete success or error handling via
asyncResponse.resume(...) rather than synchronous Response returns.

In `@src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java`:
- Around line 838-853: Move the at-most-once outcome-unknown Javadoc currently
preceding requestChangedSinceApproval so it appears immediately above
auditOutcomeUnknown. Keep the requestChangedSinceApproval Javadoc directly
attached to that method, and preserve the existing auditOutcomeUnknown
documentation unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 948491e3-5941-4e5b-aabd-67cf2b31fcc8

📥 Commits

Reviewing files that changed from the base of the PR and between 2d675bc and 96df3c8.

📒 Files selected for processing (29)
  • docs/changelog.md
  • docs/hitl.md
  • src/main/java/ai/labs/eddi/engine/api/IRestOperatorMetrics.java
  • src/main/java/ai/labs/eddi/engine/api/OperatorMetricsService.java
  • src/main/java/ai/labs/eddi/engine/api/model/OperatorCanaryReport.java
  • src/main/java/ai/labs/eddi/engine/api/model/OperatorGateStatusReport.java
  • src/main/java/ai/labs/eddi/engine/httpclient/IRequest.java
  • src/main/java/ai/labs/eddi/engine/httpclient/impl/HttpClientWrapper.java
  • src/main/java/ai/labs/eddi/engine/internal/RestAgentEngine.java
  • src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java
  • src/main/java/ai/labs/eddi/engine/memory/model/PendingToolCallBatch.java
  • src/main/java/ai/labs/eddi/engine/rest/RestOperatorMetrics.java
  • src/main/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutor.java
  • src/main/java/ai/labs/eddi/modules/apicalls/impl/IApiCallExecutor.java
  • src/main/java/ai/labs/eddi/modules/apicalls/impl/RequestRedactor.java
  • src/main/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequest.java
  • src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java
  • src/test/java/ai/labs/eddi/engine/api/OperatorMetricsServiceTest.java
  • src/test/java/ai/labs/eddi/engine/internal/RestAgentEngineToolPauseDetailsTest.java
  • src/test/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilitiesHitlTest.java
  • src/test/java/ai/labs/eddi/engine/rest/RestOperatorMetricsTest.java
  • src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorBranchCoverageTest.java
  • src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorExtendedTest.java
  • src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorTest.java
  • src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorValidationErrorTest.java
  • src/test/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequestTest.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorCoverageTest.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorExtendedTest.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorTest.java

Comment thread src/main/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutor.java Outdated
Comment thread src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java
Comment thread src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java
Three review findings, all in the pinning path.

IRequest#toMap returns query params as Map<String, List<String>> —
HttpClientWrapper accumulates repeats — but resolve() cast that to
Map<String, String>. The cast erases cleanly and then throws
ClassCastException inside the fingerprint canonicaliser, which
pinResolvedRequest catches and downgrades to "approved unpinned". So
every gated endpoint carrying a query parameter was silently unpinned,
including POST .../deploy/{agentId}?version=N, a granted write. Both
shapes are now accepted, and the canonical form emits one length-
prefixed field per value so ?tag=a&tag=b cannot be forged by a single
value containing the display separator. The KEY_QUERY_PARAMS javadoc
asserted the wrong type and is corrected.

Query parameters were also not redacted — same class as the body leak,
missed the same way. ?api_key=… is a conventional credential channel
and the query string is shown to the approver. Redacted for display,
hashed raw, exactly as the body is.

mergeExternalTools resolves a name collision by dropping the incoming
tool, but the resolver was registered before that verdict was known. A
builtin winning a collision against a same-named http tool would be
pinned against the DROPPED tool's request: the approver shown a preview
of a call that never runs, and the pre-execution check comparing against
that same fabricated request and passing. Resolvers are now pruned to
names a surviving http tool owns.

Also sanitizes the model-chosen tool name in the resolve-failure WARN,
and drops the docs claim that requestPinned:false implies
requestPreview:null — a call with preRequest.propertyInstructions is
previewed best-effort AND left unpinnable, so both hold at once.
Copilot AI review requested due to automatic review settings August 3, 2026 12:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.

Comment on lines +136 to +159
/**
* Redact the {@code headers} and {@code body} entries of a request map in
* place, as produced by
* {@link ai.labs.eddi.engine.httpclient.IRequest#toMap()}.
* <p>
* Query parameters are deliberately left alone here: this map is the debug
* record, whose {@code queryParams} entry is the live map the request itself
* holds ({@code HttpClientWrapper.RequestWrapper#toMap} does not copy it), so
* rewriting its values in place would corrupt the outgoing request. The
* approval preview redacts them on its own copy instead — see
* {@code ApiCallExecutor#resolve}.
*/
@SuppressWarnings("unchecked")
public void redactRequestMap(Map<String, Object> requestMap) {
if (requestMap == null) {
return;
}
if (requestMap.get("headers") instanceof Map<?, ?> headers) {
requestMap.put("headers", redactHeaders((Map<String, ?>) headers));
}
if (requestMap.get("body") instanceof String body) {
requestMap.put("body", redactBody(body));
}
}

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/ai/labs/eddi/modules/apicalls/impl/RequestRedactor.java (1)

136-159: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact the queryParams map without mutating the live request.

RequestWrapper#toMap() stores queryParamsMap directly, and ApiCallExecutor.execute() calls redactRequestMap() before creating the httpCalls memory entry. Query credentials in queryParams are therefore stored unredacted on the execute path, while the same redaction logic is applied in ResolvedRequest. Build a new redacted Map<String, List<String>>, replacing requestMap.get("queryParams") without changing the live queryParamsMap contents.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/modules/apicalls/impl/RequestRedactor.java` around
lines 136 - 159, Update redactRequestMap to also process the queryParams entry
by creating a new redacted Map<String, List<String>> and replacing the entry in
requestMap, rather than mutating the original map referenced by
RequestWrapper#toMap(). Reuse the existing query-parameter redaction logic used
by ApiCallExecutor#resolve or ResolvedRequest so execute-path memory entries
store redacted credentials while the live queryParamsMap remains unchanged.
🧹 Nitpick comments (1)
src/main/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequest.java (1)

99-118: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Length-prefix the query/header key, not only the value.

In fingerprintOf, the query field name is built as "query." + entry.getKey() + "[" + i + "]" (Line 112) and the header field name as "header." + entry.getKey() (Line 116). appendField only length-prefixes the VALUE it receives; the NAME string, which embeds the raw key, is written to the canonical buffer unprefixed and unescaped.

The class's own threat model (documented at Lines 90-98) exists to stop exactly this class of forgery for values: a \n or a ":" sequence inside an unprotected field lets one request's canonical bytes imitate two fields instead of one. The same forgery mechanism applies to the key if a query parameter name or header name ever contains those characters, since the key is concatenated directly into the field name rather than passed through appendField as a length-prefixed value.

The Injection nested test class in ResolvedRequestTest.java covers value-based forgeries (body-as-header, query-separator collision) but does not cover a forged key. Whether this is exploitable depends on whether query/header names can ever carry attacker- or model-influenced content — worth confirming, since httpcall query parameter names are normally developer-configured, but this is not guaranteed by the code itself.

🛡️ Proposed fix: move the key into a length-prefixed field
         for (var entry : queryParams.entrySet()) {
             // One field per value, indexed: a query parameter may legitimately
             // repeat (?tag=a&tag=b), and joining the values into one string would
             // let a single value containing the separator impersonate two — the
             // same field-boundary forgery the length prefixes exist to stop.
             var values = entry.getValue();
             for (int i = 0; i < values.size(); i++) {
-                appendField(canonical, "query." + entry.getKey() + "[" + i + "]", values.get(i));
+                appendField(canonical, "query[" + i + "].name", entry.getKey());
+                appendField(canonical, "query[" + i + "].value", values.get(i));
             }
         }
         for (var entry : headers.entrySet()) {
-            appendField(canonical, "header." + entry.getKey(), entry.getValue());
+            appendField(canonical, "header.name", entry.getKey());
+            appendField(canonical, "header.value", entry.getValue());
         }

The "query[" + i + "]" and literal "header.name"/"header.value" segments contain only loop-counter digits or fixed literals, never attacker-controlled text, so they cannot themselves be forged.

As per coding guidelines, no specific rule covers this file; this is a general security-hardening finding based on the class's own stated forgery-prevention design.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequest.java` around
lines 99 - 118, Update fingerprintOf so query parameter and header names are
passed to appendField as length-prefixed data instead of being concatenated into
the unescaped field name. Keep only fixed labels and value indexes in canonical
field names, and preserve separate indexed query values while ensuring
attacker-controlled keys cannot forge canonical field boundaries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/changelog.md`:
- Line 57: Update the `WRITE_ENDPOINTS` changelog entry so it states first that
the Manager-side population is complete, then clearly lists only the remaining
Manager-side tasks: rendering this backend’s `requestPreview` in the approval
banner and completing the agent/group authoring UI.

In `@docs/hitl.md`:
- Line 381: The documentation’s claim that hashing the raw body “reveals
nothing” is too absolute. Update the fingerprint explanation to state that it is
not exposed through any client API, while treating the persisted SHA-256 digest
as sensitive internal data because predictable values may permit offline
guessing or equality correlation.

---

Outside diff comments:
In `@src/main/java/ai/labs/eddi/modules/apicalls/impl/RequestRedactor.java`:
- Around line 136-159: Update redactRequestMap to also process the queryParams
entry by creating a new redacted Map<String, List<String>> and replacing the
entry in requestMap, rather than mutating the original map referenced by
RequestWrapper#toMap(). Reuse the existing query-parameter redaction logic used
by ApiCallExecutor#resolve or ResolvedRequest so execute-path memory entries
store redacted credentials while the live queryParamsMap remains unchanged.

---

Nitpick comments:
In `@src/main/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequest.java`:
- Around line 99-118: Update fingerprintOf so query parameter and header names
are passed to appendField as length-prefixed data instead of being concatenated
into the unescaped field name. Keep only fixed labels and value indexes in
canonical field names, and preserve separate indexed query values while ensuring
attacker-controlled keys cannot forge canonical field boundaries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c608776-a2e0-4c9d-8bfc-7bca4db8d725

📥 Commits

Reviewing files that changed from the base of the PR and between 96df3c8 and b0fe5cf.

📒 Files selected for processing (11)
  • docs/changelog.md
  • docs/hitl.md
  • src/main/java/ai/labs/eddi/engine/httpclient/IRequest.java
  • src/main/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutor.java
  • src/main/java/ai/labs/eddi/modules/apicalls/impl/RequestRedactor.java
  • src/main/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequest.java
  • src/main/java/ai/labs/eddi/modules/llm/impl/AgentOrchestrator.java
  • src/test/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutorTest.java
  • src/test/java/ai/labs/eddi/modules/apicalls/impl/ResolvedRequestTest.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorCoverageTest.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorToolGovernanceTest.java
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/main/java/ai/labs/eddi/engine/httpclient/IRequest.java
  • src/main/java/ai/labs/eddi/modules/apicalls/impl/ApiCallExecutor.java
  • src/test/java/ai/labs/eddi/modules/llm/impl/AgentOrchestratorCoverageTest.java

Comment thread docs/changelog.md Outdated
Comment thread docs/hitl.md Outdated
The preview redacts them, the conversation document did not — so a
credential passed as ?api_key=… was persisted to MongoDB in the clear.

The previous doc comment claimed this was unavoidable because
RequestWrapper#toMap hands back its LIVE queryParamsMap, so rewriting it
would corrupt the outgoing request. That reasoning was wrong: the entry
can be REPLACED with a redacted copy in the freshly-built outer map,
exactly as redactHeaders already does, leaving the nested live map
untouched. A test asserts both halves — the persisted record is redacted
AND the original map still holds its real value.

Also corrects two doc claims: SHA-256 is not encryption, so the
persisted fingerprint is described as sensitive internal data rather
than as revealing nothing, and the changelog no longer asks what is left
before WRITE_ENDPOINTS can be populated immediately before saying it
already has been.
Copilot AI review requested due to automatic review settings August 3, 2026 13:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/main/java/ai/labs/eddi/modules/apicalls/impl/RequestRedactor.java:165

  • redactRequestMap() is still keying into the request map with string literals ("headers"/"queryParams"/"body"), even though IRequest now defines these keys as part of the toMap() contract. Using the shared constants avoids future drift/typos if the map contract evolves, and matches the convention established in HttpClientWrapper/ApiCallExecutor#resolve.
        if (requestMap.get("headers") instanceof Map<?, ?> headers) {
            requestMap.put("headers", redactHeaders((Map<String, ?>) headers));
        }
        if (requestMap.get("queryParams") instanceof Map<?, ?> queryParams) {
            requestMap.put("queryParams", redactQueryParams((Map<String, ?>) queryParams));

…ing literals

redactRequestMap spelled "headers"/"queryParams"/"body" itself, so a rename on the IRequest side would leave it silently redacting nothing. Those constants were promoted onto the interface earlier in this branch precisely so callers stop duplicating them — and a disagreement between the interface's stated contract and what a caller assumed is exactly what produced the query-param defect this PR already had to fix.
Copilot AI review requested due to automatic review settings August 3, 2026 13:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/main/java/ai/labs/eddi/engine/rest/RestOperatorMetrics.java:37

  • reportCanaryResult treats a missing/empty request body the same as an invalid outcome and always throws "outcome must be one of: pass, fail, unknown". For null bodies this is misleading (and inconsistent with reportGateStatus, which explicitly reports that the request body is required). Split the validation so a null body yields a body-required error, and only invalid outcomes yield the outcome-vocabulary error.
    public Response reportCanaryResult(OperatorCanaryReport report) {
        if (report == null || !OperatorMetricsService.isValidOutcome(report.outcome())) {
            throw new BadRequestException("outcome must be one of: pass, fail, unknown");
        }

Copilot AI review requested due to automatic review settings August 4, 2026 07:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/main/java/ai/labs/eddi/engine/memory/ConversationMemoryUtilities.java:355

  • The Javadoc starting here is not attached to any declaration because it’s immediately followed by another Javadoc block. This can trigger Javadoc/checkstyle warnings and makes the intended documentation easy to miss. Convert this first block to a regular block comment (or merge it into the method/field Javadoc) so only one Javadoc applies to the next member.
    /**

src/main/java/ai/labs/eddi/modules/apicalls/impl/RequestRedactor.java:158

  • This Javadoc block is immediately followed by another Javadoc block, so it doesn’t attach to any member (and may violate Javadoc/checkstyle rules). Convert it to a regular block comment (or merge it with the method’s Javadoc) so documentation is applied predictably.
    /**

…rest

Third review comment about the same mistake, so this fixes the class
rather than the instance. Adding a method (or constant) directly above
an existing documented one leaves the existing javadoc stranded: two
comment blocks back to back, the first describing something further
down the file.

Repaired here — ConversationMemoryUtilities (REDACTED_FINGERPRINT split
stripRequestFingerprintsForRead from its doc) — plus three more the
first two review comments had not reached, found by scanning every
.java file this branch touches for a `*/` immediately followed by a
`/**`: AgentSetupService, RequestRedactor, AgentOrchestrator.

Comment lines only. The single non-comment line in the diff is
REDACTED_FINGERPRINT changing position; no logic, no reordering of
code. Full suite at the documented baseline (8 pre-existing
EmbeddingModelFactoryBranchTest failures, 313 environmental).
Copilot AI review requested due to automatic review settings August 4, 2026 07:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated no new comments.

Repo: EDDI-Manager (feat/operator-write-scope) — see that repo's
HANDOFF.md and commit 578c4587 for the full write-up. Landing the
entry here because this changelog tracks all repos in this arc, per
its own stated purpose.
Copilot AI review requested due to automatic review settings August 4, 2026 10:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated 1 comment.

Comment on lines +48 to +55
public static boolean isSensitiveHeaderName(String headerName) {
if (headerName == null) {
return false;
}
String name = headerName.toLowerCase(Locale.ROOT);
return name.contains("authorization") || name.contains("api-key") || name.contains("api_key") || name.contains("apikey")
|| name.contains("x-api-key") || name.contains("token") || name.contains("secret") || name.contains("credential");
}
…redaction

isSensitiveHeaderName's name check covered authorization/api-key/token/
secret/credential but not "password" — despite this class's own javadoc,
two lines above the query-param version of this same method, already
listing password among the generic rule's recognized credential names.

The gap was real: SecretRedactionFilter's shape rule needs the credential
name INSIDE the value ("password=hunter2"), so a value that's just the
bare password with the name sitting in a separate header/param name
(X-Password: hunter2, ?password=hunter2) matched neither the name check
nor the shape rule, and reached the approval preview and the debug
record in plaintext.

isSensitiveHeaderName backs both redactHeaderValue and
redactQueryParamValue, so one fix closes both channels. Mutation-verified
one test per channel: reverting the check fails both new tests, restored
and re-verified green (16/16, full suite otherwise at the documented
baseline).

Found by an automated review comment on PR #627.
Copilot AI review requested due to automatic review settings August 4, 2026 10:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated no new comments.

The "a call can be unpinned" paragraph listed non-http tools and
preRequest.propertyInstructions. canExecuteDivergeFromResolve has two
more branches: fireAndForget+batchRequests, and — the one that matters —
any call carrying a retryApiCallInstruction with maxRetries >= 1.

That last one is easy to trip over. RetryApiCallInstruction defaults
maxRetries to 3, so `"retryApiCallInstruction": {}` alone unpins an
otherwise-pinnable write, and a retry can fire on a 2xx when
responseValuePathMatchers matches rather than only on retryOnHttpCodes.
So a realistic gated POST could ship with requestPinned:false while this
document told the operator the request is re-checked before it runs.

Nothing is wrong at runtime — requestPinned is reported honestly per
call on approval-status, and the code has been correct since the retry
guard landed. But this document is the contract, and config that
silently does something other than what it says is precisely the class
of bug this whole feature exists to remove.

Restated as a table keyed on the actual invariant ("never pin what
cannot be honoured") rather than a list of features, so a future branch
that adds a fifth divergence path has somewhere obvious to add it. The
fail-closed paragraph gets the same correction.

Found by an adversarial review pass over the branch.
Copilot AI review requested due to automatic review settings August 4, 2026 11:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated no new comments.

…-fingerprint

# Conflicts:
#	docs/changelog.md
Copilot AI review requested due to automatic review settings August 4, 2026 12:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated no new comments.

@ginccc ginccc closed this Aug 4, 2026
@ginccc
ginccc deleted the feat/operator-request-fingerprint branch August 4, 2026 14:09
@ginccc
ginccc restored the feat/operator-request-fingerprint branch August 4, 2026 14:10
@ginccc ginccc reopened this Aug 4, 2026
@ginccc
ginccc merged commit 338fb78 into main Aug 7, 2026
42 checks passed
@ginccc
ginccc deleted the feat/operator-request-fingerprint branch August 7, 2026 13:45
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.

3 participants