11import { describe , expect , test } from "bun:test" ;
2+ import { mkdtemp , writeFile } from "node:fs/promises" ;
3+ import { tmpdir } from "node:os" ;
4+ import { join } from "node:path" ;
25
6+ import type { AuthorizeFn } from "@intx/agent" ;
37import type { ToolCall } from "@intx/types/runtime" ;
48
59import { createPermissionGate } from "../src/permission/gate.js" ;
610import { createReactorAuthorize } from "../src/permission/reactor-authorize.js" ;
11+ import type { PermissionRequest } from "../src/permission/types.js" ;
12+ import type { PluginModule } from "../src/plugins/loader.js" ;
13+ import {
14+ collectToolPlugins ,
15+ resolveToolPlugins ,
16+ } from "../src/plugins/tool-plugins.js" ;
717import {
818 createApprovalResume ,
919 resolveParkedCallIdFromStore ,
@@ -12,6 +22,7 @@ import {
1222import {
1323 closeIntegrationSession ,
1424 openIntegrationSession ,
25+ runUntilDone ,
1526 runUntilSuspended ,
1627 toolDoneEvents ,
1728} from "./integration-harness.js" ;
@@ -65,6 +76,208 @@ async function waitForAsk(
6576}
6677
6778describe ( "integration — reactor approval suspend/resume" , ( ) => {
79+ test . serial (
80+ "installed tool-plugin authorization asks before read dispatch and denies without dispatch" ,
81+ async ( ) => {
82+ const authorizationCalls : {
83+ resource : string ;
84+ action : string ;
85+ context : unknown ;
86+ } [ ] = [ ] ;
87+ const pluginAuthorize : AuthorizeFn = async (
88+ resource ,
89+ action ,
90+ context ,
91+ ) => {
92+ authorizationCalls . push ( { resource, action, context } ) ;
93+ const path =
94+ typeof context === "object" &&
95+ context !== null &&
96+ "arguments" in context &&
97+ typeof context . arguments === "object" &&
98+ context . arguments !== null &&
99+ "path" in context . arguments &&
100+ typeof context . arguments . path === "string"
101+ ? context . arguments . path
102+ : "" ;
103+ if ( path . endsWith ( "deny.txt" ) ) {
104+ return {
105+ effect : "deny" ,
106+ reason : "installed guard blocked deny.txt" ,
107+ matchingGrants : [ ] ,
108+ resolvedBy : null ,
109+ } ;
110+ }
111+ return {
112+ effect : "ask" ,
113+ matchingGrants : [ ] ,
114+ resolvedBy : null ,
115+ } ;
116+ } ;
117+ const installedGuard : PluginModule = {
118+ manifest : {
119+ id : "installed-guard" ,
120+ name : "Installed guard" ,
121+ kind : "tool" ,
122+ } ,
123+ createToolPlugin : ( ) => ( { authorize : pluginAuthorize } ) ,
124+ } ;
125+ const resolved = await resolveToolPlugins ( {
126+ candidates : collectToolPlugins ( [ installedGuard ] ) ,
127+ pluginConfig : {
128+ "installed-guard" : { enabled : true , consented : true } ,
129+ } ,
130+ } ) ;
131+ const authorize = defined ( resolved . authorize , "resolved authorizer" ) ;
132+
133+ const approvalRequests : PermissionRequest [ ] = [ ] ;
134+ let approve : ( ( ) => void ) | undefined ;
135+ const askCwd = await mkdtemp ( join ( tmpdir ( ) , "corbits-plugin-ask-" ) ) ;
136+ const askGate = createPermissionGate ( {
137+ approvals : [ ] ,
138+ cwd : askCwd ,
139+ interactive : true ,
140+ skipPermissions : false ,
141+ reactorGated : true ,
142+ pluginAuthorize : authorize ,
143+ requestApproval : ( request ) => {
144+ approvalRequests . push ( request ) ;
145+ return new Promise ( ( resolve ) => {
146+ approve = ( ) => resolve ( { allow : true } ) ;
147+ } ) ;
148+ } ,
149+ } ) ;
150+ const askSession = await openIntegrationSession ( {
151+ permissionGate : askGate ,
152+ authorize : createReactorAuthorize ( askGate ) ,
153+ cwd : askCwd ,
154+ } ) ;
155+ try {
156+ const askPath = join ( askSession . cwd , "ask.txt" ) ;
157+ await writeFile ( askPath , "plugin ask content" ) ;
158+ askSession . harness . scenario . replyOnce ( "anthropic" , {
159+ toolCalls : [ { name : "read_file" , args : { path : askPath } } ] ,
160+ } ) ;
161+ askSession . harness . scenario . replyOnce ( "anthropic" , {
162+ text : "Read after approval." ,
163+ } ) ;
164+
165+ const turn = await runUntilSuspended (
166+ askSession ,
167+ "Read ask.txt after authorization." ,
168+ ) ;
169+ expect ( turn . result . type ) . toBe ( "suspended" ) ;
170+ if ( turn . result . type !== "suspended" ) return ;
171+ expect ( turn . result . approvalSnapshot ?. approvalMode ) . toBe ( "allow-once" ) ;
172+ expect (
173+ turn . events . filter (
174+ ( event ) =>
175+ event . type === "tool.start" || event . type === "tool.done" ,
176+ ) ,
177+ ) . toHaveLength ( 0 ) ;
178+ expect ( approvalRequests ) . toHaveLength ( 0 ) ;
179+
180+ const resume = createApprovalResume ( {
181+ getAgent : ( ) => askSession . agent ,
182+ resolveParkedCallId : ( correlationId ) =>
183+ resolveParkedCallIdFromStore ( askSession . storage , correlationId ) ,
184+ gate : askGate ,
185+ } ) ;
186+ const handling = resume . handle ( turn . result ) ;
187+ for ( let i = 0 ; i < 100 && approvalRequests . length === 0 ; i ++ ) {
188+ await new Promise ( ( resolve ) => setTimeout ( resolve , 5 ) ) ;
189+ }
190+ expect ( approvalRequests ) . toHaveLength ( 1 ) ;
191+ expect ( defined ( approvalRequests [ 0 ] ) . scopes ) . toEqual ( [ ] ) ;
192+ defined ( approve , "approval resolver" ) ( ) ;
193+ expect ( await handling ) . toBe ( true ) ;
194+ expect ( await turn . reply ( ) ) . toBe ( "Read after approval." ) ;
195+
196+ expect ( approvalRequests ) . toHaveLength ( 1 ) ;
197+ expect (
198+ turn . events . filter (
199+ ( event ) =>
200+ event . type === "tool.start" &&
201+ event . data . call . name === "read_file" ,
202+ ) ,
203+ ) . toHaveLength ( 1 ) ;
204+ const askDone = toolDoneEvents ( turn . events ) ;
205+ expect ( askDone ) . toHaveLength ( 1 ) ;
206+ expect ( defined ( askDone [ 0 ] ) . data . result . content ) . toContain (
207+ "plugin ask content" ,
208+ ) ;
209+ expect ( authorizationCalls ) . toHaveLength ( 2 ) ;
210+ for ( const call of authorizationCalls ) {
211+ expect ( call ) . toEqual ( {
212+ resource : "tool:read_file" ,
213+ action : "invoke" ,
214+ context : expect . objectContaining ( {
215+ name : "read_file" ,
216+ arguments : { path : askPath } ,
217+ } ) ,
218+ } ) ;
219+ }
220+ } finally {
221+ await closeIntegrationSession ( askSession ) ;
222+ }
223+
224+ const denyRequests : unknown [ ] = [ ] ;
225+ const denyCwd = await mkdtemp ( join ( tmpdir ( ) , "corbits-plugin-deny-" ) ) ;
226+ const denyGate = createPermissionGate ( {
227+ approvals : [ ] ,
228+ cwd : denyCwd ,
229+ interactive : true ,
230+ skipPermissions : false ,
231+ reactorGated : true ,
232+ pluginAuthorize : authorize ,
233+ requestApproval : async ( request ) => {
234+ denyRequests . push ( request ) ;
235+ return { allow : true } ;
236+ } ,
237+ } ) ;
238+ const denySession = await openIntegrationSession ( {
239+ permissionGate : denyGate ,
240+ authorize : createReactorAuthorize ( denyGate ) ,
241+ cwd : denyCwd ,
242+ } ) ;
243+ try {
244+ const denyPath = join ( denySession . cwd , "deny.txt" ) ;
245+ await writeFile ( denyPath , "content must not dispatch" ) ;
246+ denySession . harness . scenario . replyOnce ( "anthropic" , {
247+ toolCalls : [ { name : "read_file" , args : { path : denyPath } } ] ,
248+ } ) ;
249+ denySession . harness . scenario . replyOnce ( "anthropic" , {
250+ text : "Read was blocked." ,
251+ } ) ;
252+
253+ const turn = await runUntilDone ( denySession , "Try to read deny.txt." ) ;
254+ expect (
255+ turn . events . filter ( ( event ) => event . type === "tool.start" ) ,
256+ ) . toHaveLength ( 0 ) ;
257+ const denyDone = toolDoneEvents ( turn . events ) ;
258+ expect ( denyDone ) . toHaveLength ( 1 ) ;
259+ const denied = defined ( denyDone [ 0 ] ) . data . result ;
260+ expect ( denied . isError ) . toBe ( true ) ;
261+ expect ( denied . content ) . toContain (
262+ "Denied by policy: installed guard blocked deny.txt" ,
263+ ) ;
264+ expect ( denied . content ) . not . toContain ( "content must not dispatch" ) ;
265+ expect ( denyRequests ) . toHaveLength ( 0 ) ;
266+ expect ( authorizationCalls ) . toHaveLength ( 3 ) ;
267+ expect ( defined ( authorizationCalls [ 2 ] ) ) . toEqual ( {
268+ resource : "tool:read_file" ,
269+ action : "invoke" ,
270+ context : expect . objectContaining ( {
271+ name : "read_file" ,
272+ arguments : { path : denyPath } ,
273+ } ) ,
274+ } ) ;
275+ } finally {
276+ await closeIntegrationSession ( denySession ) ;
277+ }
278+ } ,
279+ ) ;
280+
68281 test . serial (
69282 "ask-tier call suspends with a correlationId, resumes on approval, and re-dispatches without re-asking" ,
70283 async ( ) => {
0 commit comments