1- /**
2- * Per-session MCP loopback **attach grants**.
3- *
4- * The loopback MCP server (`mcp-http.ts`) authenticates the gateway-spawned cli-backend with two
5- * process-global bearer tokens (owner / non-owner) and scopes tools from client-supplied headers —
6- * adequate because that client is cooperative and gateway-launched. An **attach** grant is the
7- * primitive for a *less-trusted* external/interactive harness (Claude Code, OpenCode, or a harness
8- * reached via a node/companion app over its existing gateway connection): a short-lived, revocable
9- * bearer whose **scope is bound to the grant**, not to the request headers.
10- *
11- * Security properties:
12- * - The bound `sessionKey` comes from the grant, so an attach caller cannot scope-shop by setting
13- * `x-session-key` (the request layer ignores the header when a grant matches).
14- * - Grants are always treated as **non-owner** (`senderIsOwner=false`) — the loopback surface
15- * already excludes the destructive native tools (`read/write/edit/apply_patch/exec/process`).
16- * - TTL + explicit revoke bound the blast radius of a leaked token.
17- *
18- * Transport-independent: the same grant is presented whether the harness reaches the loopback over
19- * 127.0.0.1 (gateway host) or tunnelled in over a node/app's existing authenticated channel.
20- */
211import crypto from "node:crypto" ;
222
233export interface McpAttachGrant {
@@ -32,7 +12,7 @@ export interface McpAttachGrant {
3212}
3313
3414const DEFAULT_TTL_MS = 60 * 60 * 1000 ; // 1h
35- const MAX_TTL_MS = 12 * 60 * 60 * 1000 ; // hard ceiling so a caller can't request a forever-grant
15+ const MAX_TTL_MS = 12 * 60 * 60 * 1000 ;
3616
3717const grantsByToken = new Map < string , McpAttachGrant > ( ) ;
3818
@@ -43,7 +23,6 @@ function clampTtlMs(ttlMs: number | undefined): number {
4323 return Math . min ( ttlMs as number , MAX_TTL_MS ) ;
4424}
4525
46- /** Mint a grant bound to `sessionKey`. Returns the grant (the caller hands `token` to the harness). */
4726export function mintAttachGrant ( params : {
4827 sessionKey : string ;
4928 ttlMs ?: number ;
@@ -54,8 +33,7 @@ export function mintAttachGrant(params: {
5433 throw new Error ( "mintAttachGrant: sessionKey is required" ) ;
5534 }
5635 const nowMs = params . nowMs ?? Date . now ( ) ;
57- // Sweep on mint so grants that are minted but never looked up again (harness never connects)
58- // don't accumulate — lookup self-sweeps only the token it touches, so this bounds the map.
36+ // Mint sweeps stale entries so abandoned grants do not accumulate.
5937 sweepExpiredAttachGrants ( nowMs ) ;
6038 const grant : McpAttachGrant = {
6139 token : crypto . randomBytes ( 32 ) . toString ( "hex" ) ,
@@ -67,11 +45,6 @@ export function mintAttachGrant(params: {
6745 return grant ;
6846}
6947
70- /**
71- * Resolve a bearer token to a live grant, or `undefined` if unknown/expired. An expired grant is
72- * dropped on lookup (lazy sweep). Lookup is by full-token map key: a caller must already hold the
73- * complete 256-bit token to get a hit, so there is no partial-match timing oracle to defend.
74- */
7548export function resolveAttachGrant (
7649 token : string ,
7750 nowMs : number = Date . now ( ) ,
@@ -87,12 +60,10 @@ export function resolveAttachGrant(
8760 return grant ;
8861}
8962
90- /** Revoke a grant by token. Returns true if a grant was removed. */
9163export function revokeAttachGrant ( token : string ) : boolean {
9264 return grantsByToken . delete ( token ) ;
9365}
9466
95- /** Revoke every live grant for a session (e.g. on session teardown). Returns the count removed. */
9667export function revokeAttachGrantsForSession ( sessionKey : string ) : number {
9768 const key = sessionKey . trim ( ) ;
9869 let removed = 0 ;
@@ -105,7 +76,6 @@ export function revokeAttachGrantsForSession(sessionKey: string): number {
10576 return removed ;
10677}
10778
108- /** Drop expired grants. Returns the count swept. Call opportunistically; lookup also self-sweeps. */
10979export function sweepExpiredAttachGrants ( nowMs : number = Date . now ( ) ) : number {
11080 let removed = 0 ;
11181 for ( const [ token , grant ] of grantsByToken ) {
@@ -117,12 +87,10 @@ export function sweepExpiredAttachGrants(nowMs: number = Date.now()): number {
11787 return removed ;
11888}
11989
120- /** Number of entries currently held (test/diagnostics). Does not sweep; reflects raw store size. */
12190export function attachGrantStoreSize ( ) : number {
12291 return grantsByToken . size ;
12392}
12493
125- /** Clear all grants (test isolation only). */
12694export function resetAttachGrantsForTest ( ) : void {
12795 grantsByToken . clear ( ) ;
12896}
0 commit comments