Skip to content

fix(hitl): prevent tool execution when the TypeSafe audit response is incomplete - #394

Open
EddieMurphy89 wants to merge 1 commit into
AIPentest:mainfrom
EddieMurphy89:fix/hitl-incomplete-typesafe-response
Open

EddieMurphy89 wants to merge 1 commit into
AIPentest:mainfrom
EddieMurphy89:fix/hitl-incomplete-typesafe-response

Conversation

@EddieMurphy89

Copy link
Copy Markdown

Summary

When HITL is enabled with default_reviewer: audit_agent and audit_backend: typesafe, an HTTP 200 response with an empty or partial answers object is currently treated as a successful low-risk evaluation. SystemOne converts nil answers into an empty map, Noul returns 0 for every missing or non-numeric answer, and DecideJev applies its "uncertain → approve" default, so the HITL middleware executes the tool. This PR validates every requested answer before accepting the result and makes DecideJev reject an empty or partial result, so an incomplete evaluation fails closed.

Impact

What the bypass gives the attacker

  • Tools that require HITL approval run without it: exec (arbitrary shell commands as the server account), file writes, webshell/C2 tools, and any other tool outside the configured whitelist.
  • The failure is invisible: the API call succeeds, no error or retry is raised, and the hitl_interrupts record attributes the decision to the audit agent with "no destructive rule matched; default allow". It looks like a normal low-risk approval.
  • Every tool call made while the upstream response is empty or partial is approved. A provider incident, quota truncation, a gateway that returns a valid but incomplete JSON body with status 200, or an incomplete deployment is enough; no human reviewer is involved.
  • Boundary: this is a fail-open in the audit gate. It requires the TypeSafe response to be empty or partial (provider fault, interception/proxy, or malformed upstream body); it is not an unauthenticated remote entry point by itself. Deployments with HITL off, or with default_reviewer: human, are not affected.

Affected versions

Tested on the default branch at b43bdb991c3bee4f433cd1a0a370a90c4e24241e (2026-11-08, v1.7.24-44-gb43bdb99) and on release tag v1.7.24 at 4ca8b7d33663cd4ba8213f4afa8194ae1928ef16 (2026-10-16). The vulnerable code is present in both. Module cyberstrike-ai, go 1.25.0.

Reproduction

Control (valid destructive answer):

  1. Run the server with HITL approval, default_reviewer: audit_agent, audit_backend: typesafe, and hitl.audit_model.base_url pointing at a local test server.
  2. Ask the agent to run the exec tool through /api/eino-agent with a session HITL approval.
  3. TypeSafe returns a complete destructive answer set (destroys_availability.noul = 0.97, irreversible_data_loss.noul = 0.97, etc.). Expected and observed: the audit agent rejects, the tool does not run, and no marker file is created.

Fault (empty or partial answer):

  1. Same setup, but TypeSafe returns HTTP 200 with:
{"answers":{}}

(or only one answer key, for example {"answers":{"destroys_availability":{"noul":0.02}}}).
2. Run the same exec call.
3. Baseline (unpatched): the audit agent approves, the tool runs, and the marker file is created. Patched: the audit agent rejects with audit agent: TypeSafe 调用失败,保守拒绝, no marker file is created, and mcpExecutionIds is empty.

Expected difference: only the answers payload differs; all other inputs are identical.

Fix

internal/typesafe/client.go adds Result.Validate(questions). It requires a usable answer for every question that was sent: each key must be present, noul values must be numeric in [0,1], and choice answers must have a non-empty choice and a numeric confidence. SystemOne now returns an error instead of a Result for an empty, partial, or otherwise unusable 200 response, and the nil-answers normalization is removed.

internal/hitl/jev.go calls the same validation for the built-in decision questions before applying the policy, so an empty or partial result constructed outside SystemOne also rejects instead of falling into "uncertain → approve".

internal/handler/hitl_audit_agent.go already converts a TypeSafe error into a conservative reject and logs a warning, so no handler change is needed. Complete low-risk and destructive answer sets keep their previous behavior.

Testing

  • go test ./internal/typesafe ./internal/hitl ./internal/handler — pass.
  • go test -race ./internal/typesafe ./internal/hitl ./internal/handler — pass.
  • go test ./internal/config ./internal/app — pass.
  • go vet ./... — pass; go build ./... — pass; gofmt clean (the repository has no lint target).
  • Regression tests fail on unpatched v1.7.24 and pass with the patch: empty answers, partial answers, non-numeric noul, choice without confidence, DecideJev empty/partial/missing-value, and the audit handler's empty/partial cases.
  • Process-level E2E with the real server (HITL + audit agent + real exec), a local fake TypeSafe endpoint and a local OpenAI-compatible stub:
    • Baseline: {"answers":{}} and partial answers → approve, exec runs, marker created.
    • Patched: the same responses → reject, no marker, mcpExecutionIds empty.
    • Normal: complete low-risk answers → approve and execute; complete destructive answers and HTTP 500 → reject; HITL off → execute with no TypeSafe call.

I may be missing context here — happy to adjust the approach if the project prefers a different fix. Thanks for taking a look.

Fixes #393

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.

[Security] HITL audit: empty TypeSafe response is treated as approval and executes tools

1 participant