11import type { ToolCall , ToolResult } from "@intx/types/runtime" ;
22import type { ToolPlugin } from "@intx/tools-posix" ;
3+ import type { PermissionGate } from "../permission/gate.js" ;
34import { scrubSecrets } from "../web/secret-scrub.js" ;
5+ import { gateToolCall } from "./permission-plugin.js" ;
6+ import { secretGuardPlugin } from "./secret-guard-plugin.js" ;
47import {
58 resolvePluginWarningHandler ,
69 stderrPluginWarning ,
@@ -15,12 +18,14 @@ import type { PluginModule } from "./loader.js";
1518// beforePrompt rewrites prompt text, beforeModel observes/rewrites a tool
1619// call (or skips it), afterTool observes/rewrites the tool result.
1720//
18- // Ordering is the safety property, not re-gating : the composed middleware
21+ // Ordering plus re-gating is the safety property: the composed middleware
1922// sits after secret-guard/permission in buildCorePosixToolPlugins, so a
20- // denied call short-circuits at the gate and never reaches a hook. A hook
21- // can only narrow (skip) or annotate an allowed call — never revive a
22- // denied one. Hook-mutated calls still flow through the downstream guards
23- // (shell-guard, read-file-guard, …), which re-validate the final shape.
23+ // denied call short-circuits at the gate and never reaches a hook. A hook can
24+ // still rewrite an allowed call into a denied shape, so a hook-replaced call
25+ // is re-run through secret-guard and the permission gate before it executes —
26+ // mutated still gated, denied never reach hooks. Hook-mutated calls also still
27+ // flow through the downstream guards (shell-guard, read-file-guard, …), which
28+ // re-validate the final shape.
2429
2530export type InterceptHookKind = "beforePrompt" | "beforeModel" | "afterTool" ;
2631
@@ -182,28 +187,34 @@ export async function applyBeforePromptHooks(
182187
183188// Run beforeModel hooks in order over the call. The first `{ skip }` wins and
184189// stops the remaining pre-hooks; the skip result still flows through
185- // afterTool so results stay observable in one place.
190+ // afterTool so results stay observable in one place. `mutated` is true when
191+ // any hook replaced the call — the middleware re-gates exactly then, so an
192+ // untouched call is never evaluated (or re-prompted) twice.
186193export async function applyBeforeModelHooks (
187194 hooks : readonly BeforeModelHook [ ] ,
188195 call : ToolCall ,
189196 signal : AbortSignal ,
190197 opts : InterceptHookWarningOptions = { } ,
191- ) : Promise < { call : ToolCall ; skip ?: ToolResult } > {
198+ ) : Promise < { call : ToolCall ; skip ?: ToolResult ; mutated : boolean } > {
192199 const onWarning = resolveInterceptWarning ( opts ) ;
193200 let current = call ;
201+ let mutated = false ;
194202 for ( let i = 0 ; i < hooks . length ; i ++ ) {
195203 const hook = hooks [ i ] ;
196204 if ( hook === undefined ) continue ;
197205 try {
198206 const outcome = await hook ( { call : current } , signal ) ;
199- if ( outcome ?. call !== undefined ) current = outcome . call ;
207+ if ( outcome ?. call !== undefined ) {
208+ current = outcome . call ;
209+ mutated = true ;
210+ }
200211 if ( outcome ?. skip !== undefined )
201- return { call : current , skip : outcome . skip } ;
212+ return { call : current , skip : outcome . skip , mutated } ;
202213 } catch ( err ) {
203214 onWarning ( hookFailureText ( "beforeModel" , i , err ) ) ;
204215 }
205216 }
206- return { call : current } ;
217+ return { call : current , mutated } ;
207218}
208219
209220// Run afterTool hooks in order over the result. A throwing hook is reported
@@ -230,12 +241,59 @@ export async function applyAfterToolHooks(
230241 return current ;
231242}
232243
244+ export interface InterceptHookPluginOptions extends InterceptHookWarningOptions {
245+ /**
246+ * Re-gate hook-mutated calls. A beforeModel hook can rewrite an allowed
247+ * call into a secret-guard/permission-denied shape, so a hook-replaced call
248+ * is re-run through secret-guard plus this gate before it executes.
249+ * buildCorePosixToolPlugins always wires the stack gate here; bare stacks
250+ * pass their gate for the same guarantee. Secret-guard revalidation runs on
251+ * every mutated call even without a gate.
252+ */
253+ permissionGate ?: PermissionGate ;
254+ secretGuardExtraDeniedPaths ?: readonly string [ ] ;
255+ }
256+
257+ // Unique pass-through sentinel: the probes below invoke `next` only on allow,
258+ // so identity against this object tells allow from deny without
259+ // reimplementing either denial shape.
260+ const REGATE_PASS : ToolResult = { callId : "" , content : "" } ;
261+
262+ // Re-run a hook-replaced call through the same two checks the original passed
263+ // upstream. Returns the denial when the mutated shape is denied, undefined
264+ // when it stays allowed.
265+ async function revalidateMutatedCall (
266+ mutated : ToolCall ,
267+ signal : AbortSignal ,
268+ opts : InterceptHookPluginOptions ,
269+ ) : Promise < ToolResult | undefined > {
270+ const pass = async ( ) : Promise < ToolResult > => REGATE_PASS ;
271+ const secretGuard = secretGuardPlugin (
272+ opts . secretGuardExtraDeniedPaths !== undefined
273+ ? { extraDeniedPaths : opts . secretGuardExtraDeniedPaths }
274+ : undefined ,
275+ ) ;
276+ const secretHandler = secretGuard . middleware ?.( pass ) ?? pass ;
277+ const secretVerdict = await secretHandler ( mutated , signal ) ;
278+ if ( secretVerdict !== REGATE_PASS ) return secretVerdict ;
279+ if ( opts . permissionGate !== undefined ) {
280+ const gateVerdict = await gateToolCall (
281+ opts . permissionGate ,
282+ mutated ,
283+ signal ,
284+ pass ,
285+ ) ;
286+ if ( gateVerdict !== REGATE_PASS ) return gateVerdict ;
287+ }
288+ return undefined ;
289+ }
290+
233291// Tool middleware for the posix chain. Compose AFTER secret-guard/permission
234292// (see buildCorePosixToolPlugins): denials short-circuit at the gate above
235293// this layer, so hooks only ever see allowed calls.
236294export function interceptHookPlugin (
237295 input : InterceptHookRegistration | InterceptHookRegistry ,
238- opts : InterceptHookWarningOptions = { } ,
296+ opts : InterceptHookPluginOptions = { } ,
239297) : ToolPlugin {
240298 const registry = normalizeInterceptHooks ( input ) ;
241299 return {
@@ -248,7 +306,22 @@ export function interceptHookPlugin(
248306 signal ,
249307 opts ,
250308 ) ;
251- const raw = pre . skip ?? ( await next ( pre . call , signal ) ) ;
309+ if ( pre . skip !== undefined ) {
310+ return applyAfterToolHooks (
311+ registry . afterTool ,
312+ pre . call ,
313+ pre . skip ,
314+ signal ,
315+ opts ,
316+ ) ;
317+ }
318+ if ( pre . mutated ) {
319+ // Like an upstream gate denial, a mutated-to-denied call never
320+ // executes and never reaches afterTool annotation.
321+ const denial = await revalidateMutatedCall ( pre . call , signal , opts ) ;
322+ if ( denial !== undefined ) return denial ;
323+ }
324+ const raw = await next ( pre . call , signal ) ;
252325 return applyAfterToolHooks (
253326 registry . afterTool ,
254327 pre . call ,
0 commit comments