@@ -4,16 +4,20 @@ import readline from 'node:readline';
44import { Readable , Writable } from 'node:stream' ;
55import { initializeCache } from '../cache/index.js' ;
66import { stopAdminServer , startAdminServer } from '../admin/index.js' ;
7+ import { EpistemicSecurityException , TrustGateError } from '../errors.js' ;
8+ import { validateAstEgress } from '../middleware/ast-egress-filter.js' ;
9+ import { parseNhiAuthorizationHeader } from '../middleware/nhi-auth-validator.js' ;
10+ import { validatePreflight } from '../middleware/preflight-validator.js' ;
11+ import { validateScopes } from '../middleware/scope-validator.js' ;
712import { sanitizeResponse } from '../proxy/shadow-leak-sanitizer.js' ;
813import { auditLog } from '../utils/auditLogger.js' ;
9- import { getPrimaryToolInvocation , isRecord } from '../utils/mcp-request.js' ;
14+ import { extractAuthorizationFromBody , extractToolInvocations , getPrimaryToolInvocation , isRecord } from '../utils/mcp-request.js' ;
1015
1116const OOM_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 ;
1217const DEFAULT_TARGET_TIMEOUT_MS = 30000 ;
1318
14- const PREFLIGHT_REQUIRED_TOOLS = new Set ( [ 'execute_command' , 'execute' ] ) ;
15-
1619type JsonRpcId = string | number | null ;
20+ type SessionColor = 'red' | 'blue' | null ;
1721
1822interface JsonRpcRequest {
1923 jsonrpc : '2.0' ;
@@ -66,32 +70,6 @@ export interface StdioFirewallProxy {
6670 stop : ( ) => Promise < void > ;
6771}
6872
69- const isShadowLeakUrl = ( url : string ) : boolean => {
70- let parsed : URL ;
71- try {
72- parsed = new URL ( url ) ;
73- } catch {
74- return false ;
75- }
76-
77- const params = [ ...parsed . searchParams . entries ( ) ] ;
78- if ( params . length === 0 ) return false ;
79-
80- const singleCharParams = params . filter ( ( [ k ] ) => k . length === 1 ) ;
81- if ( singleCharParams . length >= 3 ) return true ;
82-
83- const valueLengths = params . map ( ( [ , v ] ) => v . length ) ;
84- const shortValues = valueLengths . filter ( len => len <= 2 ) ;
85- const byKey = new Map < string , number > ( ) ;
86- for ( const [ k ] of params ) {
87- byKey . set ( k , ( byKey . get ( k ) ?? 0 ) + 1 ) ;
88- }
89- const repeatedKeys = [ ...byKey . values ( ) ] . filter ( count => count > 1 ) ;
90- if ( repeatedKeys . length > 0 && shortValues . length >= 4 ) return true ;
91-
92- return false ;
93- } ;
94-
9573const isJsonRpcRequest = ( value : unknown ) : value is JsonRpcRequest => {
9674 if ( ! isRecord ( value ) ) return false ;
9775 return value . jsonrpc === '2.0' && typeof value . method === 'string' ;
@@ -107,25 +85,70 @@ const buildRpcErrorResponse = (id: JsonRpcId, code: number, message: string, dat
10785 jsonrpc : '2.0' , id, error : { code, message, ...( data !== undefined ? { data } : { } ) } ,
10886} ) ;
10987
110- const validateAuth = ( proxyAuthToken : string | undefined , message : JsonRpcRequest ) : boolean => {
111- if ( ! proxyAuthToken ) return true ;
88+ const extractStdioAuthorization = ( message : JsonRpcRequest ) : string | undefined => {
89+ const fromBody = extractAuthorizationFromBody ( message as unknown as Record < string , unknown > ) ;
90+ if ( fromBody ) return fromBody ;
11291
113- const params = message . params as Record < string , unknown > | undefined ;
114- const meta = params ?. _meta as Record < string , unknown > | undefined ;
115- const authorization = meta ?. authorization as string | undefined ;
116- if ( ! authorization ) return false ;
92+ if ( ! isRecord ( message . params ) || ! isRecord ( message . params . _meta ) ) {
93+ return undefined ;
94+ }
11795
118- const bearerPrefix = 'Bearer ' ;
119- if ( ! authorization . startsWith ( bearerPrefix ) ) return false ;
96+ const authorization = message . params . _meta . authorization ;
97+ return typeof authorization === 'string' ? authorization : undefined ;
98+ } ;
12099
100+ const parseStdioAuthScopes = ( proxyAuthToken : string | undefined , message : JsonRpcRequest ) : string [ ] => {
101+ if ( ! proxyAuthToken ) return [ ] ;
102+ const parsed = parseNhiAuthorizationHeader ( extractStdioAuthorization ( message ) , proxyAuthToken , 'stdio' ) ;
103+ return parsed . scopes ;
104+ } ;
105+
106+ const createSnippet = ( message : JsonRpcRequest ) : string => {
107+ const tool = getPrimaryToolInvocation ( message as unknown as Record < string , unknown > ) ;
121108 try {
122- const encoded = authorization . slice ( bearerPrefix . length ) ;
123- const decoded = Buffer . from ( encoded , 'base64' ) . toString ( 'utf8' ) ;
124- const payload = JSON . parse ( decoded ) as Record < string , unknown > ;
125- return payload . token === proxyAuthToken ;
109+ return JSON . stringify ( tool ?. arguments ?? message . params ?? { } ) . slice ( 0 , 240 ) ;
126110 } catch {
127- return false ;
111+ return String ( tool ?. name ?? message . method ) . slice ( 0 , 240 ) ;
112+ }
113+ } ;
114+
115+ const validateColorBoundary = ( message : JsonRpcRequest , sessionColor : SessionColor ) : SessionColor => {
116+ const tools = extractToolInvocations ( message as unknown as Record < string , unknown > ) ;
117+ const reds = tools . filter ( ( tool ) => tool . _meta ?. color === 'red' ) . map ( ( tool ) => tool . name ?? 'unknown' ) ;
118+ const blues = tools . filter ( ( tool ) => tool . _meta ?. color === 'blue' ) . map ( ( tool ) => tool . name ?? 'unknown' ) ;
119+
120+ if ( reds . length > 0 && blues . length > 0 ) {
121+ throw new TrustGateError (
122+ `Cross-Tool Hijack Attempt detected: ${ [ ...reds , ...blues ] . join ( ', ' ) } ` ,
123+ 'CROSS_TOOL_HIJACK_ATTEMPT' ,
124+ 403 ,
125+ { redTools : reds , blueTools : blues } ,
126+ ) ;
128127 }
128+
129+ const requestColor : SessionColor = reds . length > 0 ? 'red' : blues . length > 0 ? 'blue' : null ;
130+ if ( requestColor !== null && sessionColor !== null && requestColor !== sessionColor ) {
131+ throw new TrustGateError (
132+ `Cross-Tool Hijack Attempt detected: ${ tools . map ( ( tool ) => tool . name ?? 'unknown' ) . join ( ', ' ) } ` ,
133+ 'CROSS_TOOL_HIJACK_ATTEMPT' ,
134+ 403 ,
135+ { sessionColor, requestColor } ,
136+ ) ;
137+ }
138+
139+ return requestColor ?? sessionColor ;
140+ } ;
141+
142+ const getSecurityErrorCode = ( error : unknown ) : string => {
143+ if ( error instanceof EpistemicSecurityException || error instanceof TrustGateError ) {
144+ return error . code ;
145+ }
146+
147+ return 'SECURITY_VALIDATION_FAILED' ;
148+ } ;
149+
150+ const getSecurityErrorMessage = ( error : unknown ) : string => {
151+ return error instanceof Error ? error . message : 'Fail-Closed: security validation failed.' ;
129152} ;
130153
131154export const createStdioFirewallProxy = ( options : StdioFirewallOptions ) => {
@@ -150,6 +173,7 @@ export const createStdioFirewallProxy = (options: StdioFirewallOptions) => {
150173 let targetProcess : ChildProcessWithoutNullStreams | null = null ;
151174 let stopped = false ;
152175 let draining = false ;
176+ let stdioSessionColor : SessionColor = null ;
153177
154178 const writeRawJson = ( message : unknown ) : void => {
155179 output . write ( JSON . stringify ( message ) + '\n' ) ;
@@ -213,45 +237,53 @@ export const createStdioFirewallProxy = (options: StdioFirewallOptions) => {
213237
214238 const requestId = message . id ?? null ;
215239 const tool = getPrimaryToolInvocation ( message as unknown as Record < string , unknown > ) ;
216-
217- if ( message . method === 'tools/call' && ! validateAuth ( options . proxyAuthToken , message ) ) {
218- auditLog ( 'AUTH_FAILURE' , {
219- code : 'AUTH_FAILURE' ,
220- reason : 'Missing or invalid NHI authorization' ,
221- toolName : tool ?. name ,
222- snippet : JSON . stringify ( tool ?. arguments ?? { } ) . slice ( 0 , 240 ) ,
223- } ) ;
224- writeRawJson ( buildRpcErrorResponse ( requestId , - 32001 , 'Fail-Closed: authentication required.' , { code : 'AUTH_FAILURE' } ) ) ;
225- return ;
240+ let availableScopes : string [ ] = [ ] ;
241+
242+ if ( message . method === 'tools/call' && options . proxyAuthToken ) {
243+ try {
244+ availableScopes = parseStdioAuthScopes ( options . proxyAuthToken , message ) ;
245+ } catch {
246+ auditLog ( 'AUTH_FAILURE' , {
247+ code : 'AUTH_FAILURE' ,
248+ reason : 'Missing or invalid NHI authorization' ,
249+ toolName : tool ?. name ,
250+ snippet : createSnippet ( message ) ,
251+ } ) ;
252+ writeRawJson ( buildRpcErrorResponse ( requestId , - 32001 , 'Fail-Closed: authentication required.' , { code : 'AUTH_FAILURE' } ) ) ;
253+ return ;
254+ }
226255 }
227256
228257 if ( stopped || ! targetProcess ?. stdin . writable ) {
229258 writeRawJson ( buildRpcErrorResponse ( requestId , - 32004 , 'Fail-Closed: target process is unavailable.' , { code : 'TARGET_UNAVAILABLE' } ) ) ;
230259 return ;
231260 }
232261
233- if ( message . method === 'tools/call' && tool ?. name ) {
234- if ( PREFLIGHT_REQUIRED_TOOLS . has ( tool . name ) ) {
235- writeRawJson ( buildRpcErrorResponse ( requestId , - 32002 , 'Fail-Closed: preflight approval required.' , { code : 'PREFLIGHT_REQUIRED' } ) ) ;
236- return ;
237- }
238-
239- if ( ( tool . name === 'fetch_url' || tool . name === 'fetch' ) && tool . arguments ) {
240- const args = tool . arguments as Record < string , unknown > ;
241- for ( const val of Object . values ( args ) ) {
242- if ( typeof val === 'string' && isShadowLeakUrl ( val ) ) {
243- auditLog ( 'SHADOWLEAK_BLOCKED_STDIO' , {
244- code : 'SHADOWLEAK_DETECTED' ,
245- reason : 'ShadowLeak exfiltration pattern detected' ,
246- toolName : tool . name ,
247- url : val ,
248- } ) ;
249- writeRawJson ( buildRpcErrorResponse ( requestId , - 32003 , 'Fail-Closed: ShadowLeak exfiltration pattern detected.' , { code : 'SHADOWLEAK_DETECTED' } ) ) ;
250- return ;
251- }
262+ if ( message . method === 'tools/call' ) {
263+ try {
264+ validateAstEgress ( message as unknown as Record < string , unknown > ) ;
265+ if ( options . proxyAuthToken ) {
266+ validateScopes ( message as unknown as Record < string , unknown > , availableScopes , 'stdio' ) ;
267+ }
268+ stdioSessionColor = validateColorBoundary ( message , stdioSessionColor ) ;
269+ validatePreflight ( message as unknown as Record < string , unknown > , 'stdio' ) ;
270+ } catch ( error ) {
271+ const code = getSecurityErrorCode ( error ) ;
272+ const messageText = getSecurityErrorMessage ( error ) ;
273+ if ( error instanceof EpistemicSecurityException || code === 'CROSS_TOOL_HIJACK_ATTEMPT' ) {
274+ auditLog ( code , {
275+ code,
276+ reason : messageText ,
277+ toolName : tool ?. name ,
278+ snippet : createSnippet ( message ) ,
279+ } ) ;
252280 }
281+ writeRawJson ( buildRpcErrorResponse ( requestId , - 32003 , messageText , { code } ) ) ;
282+ return ;
253283 }
284+ }
254285
286+ if ( message . method === 'tools/call' && tool ?. name ) {
255287 if ( requestId !== null ) {
256288 const cached = cacheManager . get ( tool . name , tool . arguments ?? { } ) ;
257289 if ( cached !== undefined ) {
0 commit comments