Skip to content

Add the ToolApprovalGuard middleware package - #17

Merged
veeqtoh merged 5 commits into
mainfrom
feat/tool-approval-guard
Aug 6, 2026
Merged

Add the ToolApprovalGuard middleware package#17
veeqtoh merged 5 commits into
mainfrom
feat/tool-approval-guard

Conversation

@veeqtoh

@veeqtoh veeqtoh commented Aug 1, 2026

Copy link
Copy Markdown
Member

Motivation and Context

Intercept inspects what goes out, but never saw what comes back in. Tool results, retrieved documents and conversation history do not pass through the middleware pipeline, which is where indirect prompt injection lives. The effect of a successful injection almost always surfaces as a tool call.

ToolApprovalGuard inspects the tool calls an agent proposes while pausing for approval, before they are surfaced for review. It checks tool allow and deny lists, scans arguments for PII and secrets as an exfiltration signal, and scans them for injection patterns. It reuses the PII Redactor detectors and the Injection Guard patterns rather than keeping a second copy.

This is the first middleware to act on the response rather than the prompt, since the tool calls it guards are proposed by the model. On a streamed run it cannot block, because the caller has already received the streamed text by the time approvals are known, so block degrades to logging. The tool has still not executed, so a logged proposal continues to require human approval.

Actions are block and log only. Rewriting a proposed tool call would desynchronise the paused turn the provider recorded.

Dependencies

Depends on

  • promptphp/intercept-injection-guard
  • promptphp/intercept-pii-redactor
  • promptphp/intercept-support

Test Instructions

Run composer test

veeqtoh added 4 commits August 1, 2026 16:05
`PIIRedactor::defaultDetectors()` and `PromptInjectionGuard::$patterns` were both
protected, so nothing else could reuse them without duplicating detection logic.

Moves the detector list to `PIIRedactor\Detectors\DefaultDetectors::all()` and the
injection patterns to `InjectionGuardDefaults::patterns()`, with both call sites
delegating to the new seams. The pattern strings are byte-identical, which
matters because they appear in log context and are passed to custom callbacks as
`$detection['pattern']`.

Extracting the detectors exposed that the credit card detector was not usable on
its own. It emitted every 13 to 19 digit run and relied on `PIIRedactor` applying
the Luhn check afterwards in `shouldKeepDetection()`. The check now lives in the
detector's own validator closure, matching how the URL detectors already
validate, so a caller taking the detector gets the whole behaviour.

Detection results are unchanged. `PIIRedactor` loses its redundant filtering
branch and its private checksum copy, 109 lines lighter.

BREAKING: removes the protected `PIIRedactor::passesLuhn()` method. Only affects
code that subclassed `PIIRedactor` and called it directly.
`ScansApprovalDecisions` already walked the arguments edited when
resolving a paused run. The tool calls the model proposed carry arguments of
exactly the same shape, at the other end of the same approval cycle.

Adds `pendingApprovalSegments()`, reusing the existing dot-path walk and keying each segment by the pending approval ID.
Intercept inspects what goes out, but never saw what comes back in. Tool
results, retrieved documents and conversation history do not pass through the
middleware pipeline, which is where indirect prompt injection lives. The effect
of a successful injection almost always surfaces as a tool call.

`ToolApprovalGuard` inspects the tool calls an agent proposes while pausing for
approval, before they are surfaced for review. It checks tool allow and deny
lists, scans arguments for PII and secrets as an exfiltration signal, and scans
them for injection patterns. It reuses the PII Redactor detectors and the
Injection Guard patterns rather than keeping a second copy.

This is the first middleware to act on the response rather than the prompt,
since the tool calls it guards are proposed by the model. On a streamed run it
cannot block, because the caller has already received the streamed text by the
time approvals are known, so block degrades to logging. The tool has still not
executed, so a logged proposal continues to require human approval.

Actions are block and log only. Rewriting a proposed tool call would
desynchronise the paused turn the provider recorded.
The middleware works without it via internal defaults. This adds the section to the publishable config so the options are discoverable and can be set globally.
@veeqtoh veeqtoh changed the title Tool Approval Guard Tool Approval Guard middleware Aug 1, 2026
@veeqtoh veeqtoh changed the title Tool Approval Guard middleware Add the ToolApprovalGuard middleware package Aug 1, 2026
@veeqtoh
veeqtoh force-pushed the feat/tool-approval-guard branch from 8e261c5 to 846bb8a Compare August 1, 2026 15:36
@veeqtoh
veeqtoh force-pushed the feat/tool-approval-guard branch from 846bb8a to cac7ff7 Compare August 6, 2026 16:17
@veeqtoh
veeqtoh merged commit 45b4753 into main Aug 6, 2026
2 checks passed
@veeqtoh
veeqtoh deleted the feat/tool-approval-guard branch August 6, 2026 18:59
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.

1 participant