@@ -2,6 +2,7 @@ import { test, expect, mock } from "bun:test";
22import type { ToolDefinition , ToolCall } from "@intx/types/runtime" ;
33import { TOOL_NAMES } from "@intx/tools-posix" ;
44import type { PermissionGate } from "../../../src/permission/gate.js" ;
5+ import { mcpServerFingerprint } from "../../../src/trust/project-trust.js" ;
56import { withMockedModule } from "../../helpers/mock-module.js" ;
67
78const mockDispose = mock ( async ( ) => { } ) ;
@@ -352,6 +353,136 @@ test("headless MCP connection does not wait for interactive OAuth", async () =>
352353 expect ( mockConnectMCPServer . mock . calls [ 0 ] ?. [ 1 ] ?. onAuthURL ) . toBeUndefined ( ) ;
353354} ) ;
354355
356+ const localStdioServer = { name : "evil" , command : "evil-bin" } ;
357+ const globalHttpServer = {
358+ name : "linear" ,
359+ type : "http" as const ,
360+ url : "https://mcp.example.test/mcp" ,
361+ } ;
362+
363+ test ( "late connect of an untrusted local-source server does not spawn" , async ( ) => {
364+ mockConnectMCPServer . mockClear ( ) ;
365+ const statuses : { name : string ; state : string ; error ?: string } [ ] = [ ] ;
366+ const toolset = await createAgentToolset ( {
367+ cwd : "/fake" ,
368+ permissionGate : fakePermissionGate ,
369+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
370+ mcpServers : [ localStdioServer ] ,
371+ mcpServersSource : "local" ,
372+ projectTrust : { trustedPluginPaths : [ ] , trustedMcpFingerprints : [ ] } ,
373+ } ) ;
374+
375+ await toolset . connectMCPServer ( localStdioServer , {
376+ interactiveAuth : false ,
377+ onStatus : ( status ) => statuses . push ( status ) ,
378+ onToolsChanged : ( ) => { } ,
379+ } ) ;
380+
381+ expect ( mockConnectMCPServer ) . not . toHaveBeenCalled ( ) ;
382+ expect ( statuses ) . toHaveLength ( 1 ) ;
383+ expect ( statuses [ 0 ] ?. name ) . toBe ( "evil" ) ;
384+ expect ( statuses [ 0 ] ?. state ) . toBe ( "failed" ) ;
385+ expect ( statuses [ 0 ] ?. error ) . toMatch ( / N o t t r u s t e d f o r t h i s p r o j e c t / ) ;
386+ await toolset . dispose ( ) ;
387+ } ) ;
388+
389+ test ( "late connect of an untrusted local-source server fail-closes when requestMcpTrust denies" , async ( ) => {
390+ mockConnectMCPServer . mockClear ( ) ;
391+ let trustAsks = 0 ;
392+ const toolset = await createAgentToolset ( {
393+ cwd : "/fake" ,
394+ permissionGate : fakePermissionGate ,
395+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
396+ mcpServers : [ localStdioServer ] ,
397+ mcpServersSource : "local" ,
398+ projectTrust : { trustedPluginPaths : [ ] , trustedMcpFingerprints : [ ] } ,
399+ requestMcpTrust : async ( ) => {
400+ trustAsks += 1 ;
401+ return false ;
402+ } ,
403+ } ) ;
404+
405+ await toolset . connectMCPServer ( localStdioServer , {
406+ interactiveAuth : false ,
407+ onStatus : ( ) => { } ,
408+ onToolsChanged : ( ) => { } ,
409+ } ) ;
410+
411+ expect ( trustAsks ) . toBe ( 1 ) ;
412+ expect ( mockConnectMCPServer ) . not . toHaveBeenCalled ( ) ;
413+ await toolset . dispose ( ) ;
414+ } ) ;
415+
416+ test ( "late connect of a trusted local-source server still connects" , async ( ) => {
417+ mockConnectMCPServer . mockClear ( ) ;
418+ const toolset = await createAgentToolset ( {
419+ cwd : "/fake" ,
420+ permissionGate : fakePermissionGate ,
421+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
422+ mcpServers : [ localStdioServer ] ,
423+ mcpServersSource : "local" ,
424+ projectTrust : {
425+ trustedPluginPaths : [ ] ,
426+ trustedMcpFingerprints : [ mcpServerFingerprint ( localStdioServer ) ] ,
427+ } ,
428+ } ) ;
429+
430+ await toolset . connectMCPServer ( localStdioServer , {
431+ interactiveAuth : false ,
432+ onStatus : ( ) => { } ,
433+ onToolsChanged : ( ) => { } ,
434+ } ) ;
435+
436+ expect ( mockConnectMCPServer ) . toHaveBeenCalledTimes ( 1 ) ;
437+ expect ( mockConnectMCPServer . mock . calls [ 0 ] ?. [ 0 ] ) . toEqual ( localStdioServer ) ;
438+ await toolset . dispose ( ) ;
439+ } ) ;
440+
441+ test ( "late connect of a global-source HTTP server does not require trust" , async ( ) => {
442+ mockConnectMCPServer . mockClear ( ) ;
443+ const toolset = await createAgentToolset ( {
444+ cwd : "/fake" ,
445+ permissionGate : fakePermissionGate ,
446+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
447+ mcpServers : [ globalHttpServer ] ,
448+ mcpServersSource : "global" ,
449+ projectTrust : { trustedPluginPaths : [ ] , trustedMcpFingerprints : [ ] } ,
450+ } ) ;
451+
452+ await toolset . connectMCPServer ( globalHttpServer , {
453+ interactiveAuth : false ,
454+ onStatus : ( ) => { } ,
455+ onToolsChanged : ( ) => { } ,
456+ } ) ;
457+
458+ expect ( mockConnectMCPServer ) . toHaveBeenCalledTimes ( 1 ) ;
459+ expect ( mockConnectMCPServer . mock . calls [ 0 ] ?. [ 0 ] ) . toEqual ( globalHttpServer ) ;
460+ await toolset . dispose ( ) ;
461+ } ) ;
462+
463+ test ( "startup connectMCP still fail-closes untrusted local servers" , async ( ) => {
464+ mockConnectMCPServer . mockClear ( ) ;
465+ const statuses : { name : string ; state : string ; error ?: string } [ ] = [ ] ;
466+ const toolset = await createAgentToolset ( {
467+ cwd : "/fake" ,
468+ permissionGate : fakePermissionGate ,
469+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
470+ mcpServers : [ localStdioServer ] ,
471+ mcpServersSource : "local" ,
472+ projectTrust : { trustedPluginPaths : [ ] , trustedMcpFingerprints : [ ] } ,
473+ } ) ;
474+
475+ await toolset . connectMCP ( {
476+ interactiveAuth : false ,
477+ onStatus : ( status ) => statuses . push ( status ) ,
478+ onToolsChanged : ( ) => { } ,
479+ } ) ;
480+
481+ expect ( mockConnectMCPServer ) . not . toHaveBeenCalled ( ) ;
482+ expect ( statuses . some ( ( s ) => s . name === "evil" && s . state === "failed" ) ) . toBe ( true ) ;
483+ await toolset . dispose ( ) ;
484+ } ) ;
485+
355486test ( "dispose calls posixTools.dispose" , async ( ) => {
356487 mockDispose . mockClear ( ) ;
357488
0 commit comments