@@ -122,6 +122,7 @@ const MAX_NATIVE_HOOK_RELAY_HISTORY_ARRAY_ITEMS = 50;
122122const MAX_NATIVE_HOOK_RELAY_HISTORY_OBJECT_KEYS = 50 ;
123123const MAX_PERMISSION_FALLBACK_KEYS = 200 ;
124124const MAX_PERMISSION_FALLBACK_KEY_CHARS = 240 ;
125+ const MAX_PERMISSION_FINGERPRINT_SORT_KEYS = 200 ;
125126const MAX_APPROVAL_TITLE_LENGTH = 80 ;
126127const MAX_APPROVAL_DESCRIPTION_LENGTH = 700 ;
127128const MAX_PERMISSION_APPROVALS_PER_WINDOW = 12 ;
@@ -442,7 +443,7 @@ async function runNativeHookRelayPermissionRequest(params: {
442443 const pendingApproval = pendingPermissionApprovals . get ( approvalKey ) ;
443444 try {
444445 const decision = await ( pendingApproval ??
445- requestNativeHookRelayPermissionApprovalWithBudget ( {
446+ startNativeHookRelayPermissionApprovalWithBudget ( {
446447 registration : params . registration ,
447448 approvalKey,
448449 request,
@@ -463,7 +464,7 @@ async function runNativeHookRelayPermissionRequest(params: {
463464 return params . adapter . renderNoopResponse ( params . invocation . event ) ;
464465}
465466
466- async function requestNativeHookRelayPermissionApprovalWithBudget ( params : {
467+ async function startNativeHookRelayPermissionApprovalWithBudget ( params : {
467468 registration : NativeHookRelayRegistration ;
468469 approvalKey : string ;
469470 request : NativeHookRelayPermissionApprovalRequest ;
@@ -505,18 +506,18 @@ function permissionRequestFallbackKey(request: NativeHookRelayPermissionApproval
505506
506507function permissionRequestToolInputKeyFingerprint ( toolInput : Record < string , unknown > ) : string {
507508 let fingerprint = "" ;
508- let processed = 0 ;
509- for ( const key of Object . keys ( toolInput ) . toSorted ( ) ) {
510- if ( processed >= MAX_PERMISSION_FALLBACK_KEYS ) {
511- break ;
512- }
509+ const { keys, truncated } = readBoundedOwnKeys ( toolInput , MAX_PERMISSION_FALLBACK_KEYS ) ;
510+ for ( const key of keys ) {
513511 const separator = fingerprint ? "," : "" ;
514512 const remaining = MAX_PERMISSION_FALLBACK_KEY_CHARS - fingerprint . length - separator . length ;
515513 if ( remaining <= 0 ) {
516514 break ;
517515 }
518516 fingerprint += `${ separator } ${ key . slice ( 0 , remaining ) } ` ;
519- processed += 1 ;
517+ }
518+ if ( truncated && fingerprint . length < MAX_PERMISSION_FALLBACK_KEY_CHARS ) {
519+ const marker = `${ fingerprint ? "," : "" } ...` ;
520+ fingerprint += marker . slice ( 0 , MAX_PERMISSION_FALLBACK_KEY_CHARS - fingerprint . length ) ;
520521 }
521522 return fingerprint || "none" ;
522523}
@@ -559,15 +560,51 @@ function updateJsonHash(hash: ReturnType<typeof createHash>, value: JsonValue):
559560 return ;
560561 }
561562 hash . update ( "{" ) ;
562- for ( const key of Object . keys ( value ) . toSorted ( ) ) {
563+ const { keys, truncated } = readBoundedOwnKeys ( value , MAX_PERMISSION_FINGERPRINT_SORT_KEYS ) ;
564+ for ( const key of keys ) {
563565 hash . update ( JSON . stringify ( key ) ) ;
564566 hash . update ( ":" ) ;
565567 updateJsonHash ( hash , value [ key ] ) ;
566568 hash . update ( "," ) ;
567569 }
570+ if ( truncated ) {
571+ // Keep ordinary objects order-independent without sorting a broad native
572+ // hook payload. The tail remains content-sensitive in traversal order.
573+ const sortedKeySet = new Set ( keys ) ;
574+ hash . update ( "#object-tail:" ) ;
575+ for ( const key in value ) {
576+ if ( ! Object . prototype . hasOwnProperty . call ( value , key ) || sortedKeySet . has ( key ) ) {
577+ continue ;
578+ }
579+ hash . update ( JSON . stringify ( key ) ) ;
580+ hash . update ( ":" ) ;
581+ updateJsonHash ( hash , value [ key ] ) ;
582+ hash . update ( "," ) ;
583+ }
584+ }
568585 hash . update ( "}" ) ;
569586}
570587
588+ function readBoundedOwnKeys (
589+ value : Record < string , unknown > ,
590+ maxKeys : number ,
591+ ) : { keys : string [ ] ; truncated : boolean } {
592+ const keys : string [ ] = [ ] ;
593+ let truncated = false ;
594+ for ( const key in value ) {
595+ if ( ! Object . prototype . hasOwnProperty . call ( value , key ) ) {
596+ continue ;
597+ }
598+ if ( keys . length >= maxKeys ) {
599+ truncated = true ;
600+ break ;
601+ }
602+ keys . push ( key ) ;
603+ }
604+ keys . sort ( ) ;
605+ return { keys, truncated } ;
606+ }
607+
571608function consumeNativeHookRelayPermissionBudget ( relayId : string , now = Date . now ( ) ) : boolean {
572609 const windowStart = now - PERMISSION_APPROVAL_WINDOW_MS ;
573610 const timestamps = ( permissionApprovalWindows . get ( relayId ) ?? [ ] ) . filter (
@@ -1032,6 +1069,14 @@ export const __testing = {
10321069 ) : string {
10331070 return formatPermissionApprovalDescription ( request ) ;
10341071 } ,
1072+ permissionRequestContentFingerprintForTests (
1073+ request : NativeHookRelayPermissionApprovalRequest ,
1074+ ) : string {
1075+ return permissionRequestContentFingerprint ( request ) ;
1076+ } ,
1077+ permissionRequestToolInputKeyFingerprintForTests ( toolInput : Record < string , unknown > ) : string {
1078+ return permissionRequestToolInputKeyFingerprint ( toolInput ) ;
1079+ } ,
10351080 setNativeHookRelayPermissionApprovalRequesterForTests (
10361081 requester : NativeHookRelayPermissionApprovalRequester ,
10371082 ) : void {
0 commit comments