@openape/proxy 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@ export class GrantsClient {
33
33
  grantType: GrantType
34
34
  permissions?: string[]
35
35
  reason?: string
36
+ requestHash?: string
36
37
  duration?: number
37
38
  }): Promise<OpenApeGrant> {
38
39
  const res = await fetch(`${this.idpUrl}/api/grants`, {
@@ -44,6 +45,7 @@ export class GrantsClient {
44
45
  grant_type: opts.grantType,
45
46
  permissions: opts.permissions,
46
47
  reason: opts.reason,
48
+ request_hash: opts.requestHash,
47
49
  duration: opts.duration,
48
50
  }),
49
51
  })
@@ -87,6 +89,8 @@ export class GrantsClient {
87
89
 
88
90
  /**
89
91
  * Check if there's an existing approved grant for a domain+permissions combo.
92
+ * Only matches grants whose target matches the requested domain and that
93
+ * have matching permissions. Ignores `allow_once` grants (they're single-use).
90
94
  */
91
95
  async findExistingGrant(
92
96
  requester: string,
@@ -95,12 +99,8 @@ export class GrantsClient {
95
99
  ): Promise<OpenApeGrant | null> {
96
100
  const params = new URLSearchParams({
97
101
  requester,
98
- target,
99
102
  status: 'approved',
100
103
  })
101
- if (permissions?.length) {
102
- params.set('permissions', permissions.join(','))
103
- }
104
104
 
105
105
  const res = await fetch(`${this.idpUrl}/api/grants?${params}`, {
106
106
  headers: this.headers(),
@@ -109,10 +109,23 @@ export class GrantsClient {
109
109
  if (!res.ok) return null
110
110
 
111
111
  const grants = await res.json() as OpenApeGrant[]
112
- // Return first active grant
113
- return grants.find(g =>
114
- g.status === 'approved' &&
115
- (!g.expires_at || g.expires_at > Math.floor(Date.now() / 1000))
116
- ) ?? null
112
+ const now = Math.floor(Date.now() / 1000)
113
+
114
+ return grants.find((g) => {
115
+ // Must be approved
116
+ if (g.status !== 'approved') return false
117
+ // Must not be expired
118
+ if (g.expires_at && g.expires_at <= now) return false
119
+ // allow_once grants are single-use — don't reuse them
120
+ if (g.request?.grant_type === 'once') return false
121
+ // Target must match the requested domain
122
+ if (g.request?.target !== target) return false
123
+ // If permissions specified, grant must cover them
124
+ if (permissions?.length && g.request?.permissions?.length) {
125
+ const grantedPerms = new Set(g.request.permissions)
126
+ if (!permissions.every(p => grantedPerms.has(p))) return false
127
+ }
128
+ return true
129
+ }) ?? null
117
130
  }
118
131
  }
package/src/index.ts CHANGED
@@ -1,62 +1,69 @@
1
1
  #!/usr/bin/env bun
2
+ import { createServer } from 'node:http'
2
3
  import { parseArgs } from 'node:util'
3
- import { loadConfig } from './config.js'
4
- import { createProxy } from './proxy.js'
4
+ import { loadMultiAgentConfig } from './config.js'
5
+ import { createNodeHandler } from './proxy.js'
5
6
  import { initAudit } from './audit.js'
6
7
 
7
8
  const { values } = parseArgs({
8
9
  options: {
9
10
  config: { type: 'string', short: 'c', default: 'config.toml' },
10
11
  'dry-run': { type: 'boolean', default: false },
12
+ 'mandatory-auth': { type: 'boolean', default: false },
11
13
  },
12
14
  })
13
15
 
14
16
  const configPath = values.config!
15
17
 
16
18
  console.log(`[openape-proxy] Loading config from ${configPath}`)
17
- const config = loadConfig(configPath)
19
+ const config = loadMultiAgentConfig(configPath, {
20
+ mandatoryAuth: values['mandatory-auth'] || undefined,
21
+ })
18
22
 
19
23
  // Init audit log
20
24
  initAudit(config.proxy.audit_log)
21
25
 
22
26
  if (values['dry-run']) {
23
27
  console.log('[openape-proxy] DRY RUN mode — logging only, not blocking')
24
- // In dry-run mode, we could override deny rules to just log
25
- // For now, just print config and exit
26
28
  console.log('[openape-proxy] Config loaded:')
27
29
  console.log(` Listen: ${config.proxy.listen}`)
28
- console.log(` IdP: ${config.proxy.idp_url}`)
29
- console.log(` Agent: ${config.proxy.agent_email}`)
30
30
  console.log(` Default action: ${config.proxy.default_action}`)
31
- console.log(` Allow rules: ${config.allow.length}`)
32
- console.log(` Deny rules: ${config.deny.length}`)
33
- console.log(` Grant rules: ${config.grant_required.length}`)
31
+ console.log(` Mandatory auth: ${config.proxy.mandatory_auth ?? false}`)
32
+ console.log(` Agents: ${config.agents.length}`)
33
+ for (const agent of config.agents) {
34
+ const allowCount = agent.allow?.length ?? 0
35
+ const denyCount = agent.deny?.length ?? 0
36
+ const grantCount = agent.grant_required?.length ?? 0
37
+ console.log(` ${agent.email} (${agent.idp_url}) — ${allowCount} allow, ${denyCount} deny, ${grantCount} grant`)
38
+ }
34
39
  process.exit(0)
35
40
  }
36
41
 
37
- const proxy = createProxy(config)
42
+ const handler = createNodeHandler(config)
38
43
 
39
- const server = Bun.serve({
40
- port: proxy.port,
41
- hostname: proxy.hostname,
42
- fetch: proxy.fetch,
43
- })
44
+ const port = Number.parseInt(config.proxy.listen.split(':')[1] || '9090')
45
+ const hostname = config.proxy.listen.split(':')[0] || '127.0.0.1'
44
46
 
45
- console.log(`[openape-proxy] 🐾 Listening on http://${server.hostname}:${server.port}`)
46
- console.log(`[openape-proxy] IdP: ${config.proxy.idp_url}`)
47
- console.log(`[openape-proxy] Agent: ${config.proxy.agent_email}`)
48
- console.log(`[openape-proxy] Rules: ${config.allow.length} allow, ${config.deny.length} deny, ${config.grant_required.length} grant-required`)
49
- console.log(`[openape-proxy] Default action: ${config.proxy.default_action}`)
47
+ const server = createServer(handler.handleRequest)
48
+ server.on('connect', handler.handleConnect)
49
+
50
+ server.listen(port, hostname, () => {
51
+ console.log(`[openape-proxy] Listening on http://${hostname}:${port}`)
52
+ console.log(`[openape-proxy] CONNECT tunneling enabled`)
53
+ console.log(`[openape-proxy] Mandatory auth: ${config.proxy.mandatory_auth ?? false}`)
54
+ console.log(`[openape-proxy] Agents: ${config.agents.map(a => a.email).join(', ')}`)
55
+ console.log(`[openape-proxy] Default action: ${config.proxy.default_action}`)
56
+ })
50
57
 
51
58
  // Graceful shutdown
52
59
  process.on('SIGINT', () => {
53
60
  console.log('\n[openape-proxy] Shutting down...')
54
- server.stop()
61
+ server.close()
55
62
  process.exit(0)
56
63
  })
57
64
 
58
65
  process.on('SIGTERM', () => {
59
66
  console.log('[openape-proxy] Shutting down...')
60
- server.stop()
67
+ server.close()
61
68
  process.exit(0)
62
69
  })
package/src/proxy.ts CHANGED
@@ -1,14 +1,59 @@
1
- import type { ProxyConfig, AuditEntry } from './types.js'
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { Socket } from 'node:net'
3
+ import { createHash } from 'node:crypto'
4
+ import type { AgentConfig, AuditEntry, MultiAgentProxyConfig, ProxyConfig } from './types.js'
2
5
  import { evaluateRules } from './matcher.js'
3
- import { verifyAgentAuth } from './auth.js'
6
+ import { AuthError, verifyAgentAuth } from './auth.js'
4
7
  import { GrantsClient } from './grants-client.js'
5
8
  import { writeAudit } from './audit.js'
9
+ import { isPrivateOrLoopback } from './ssrf.js'
10
+ import { handleConnect } from './connect.js'
6
11
 
12
+ /**
13
+ * Compute a request hash that uniquely identifies the intent.
14
+ * hash = sha256(METHOD + " " + FULL_URL + "\n" + BODY)
15
+ * This binds the grant to the exact request — no bait-and-switch.
16
+ */
17
+ async function computeRequestHash(method: string, targetUrl: string, body: ArrayBuffer | null): Promise<string> {
18
+ const hash = createHash('sha256')
19
+ hash.update(`${method} ${targetUrl}\n`)
20
+ if (body && body.byteLength > 0) {
21
+ hash.update(new Uint8Array(body))
22
+ }
23
+ return hash.digest('hex')
24
+ }
25
+
26
+ /** Legacy single-agent proxy */
7
27
  export function createProxy(config: ProxyConfig) {
8
- const grantsClient = new GrantsClient(config.proxy.idp_url)
28
+ const multiConfig: MultiAgentProxyConfig = {
29
+ proxy: {
30
+ listen: config.proxy.listen,
31
+ default_action: config.proxy.default_action,
32
+ audit_log: config.proxy.audit_log,
33
+ mandatory_auth: config.proxy.mandatory_auth,
34
+ },
35
+ agents: [{
36
+ email: config.proxy.agent_email,
37
+ idp_url: config.proxy.idp_url,
38
+ allow: config.allow,
39
+ deny: config.deny,
40
+ grant_required: config.grant_required,
41
+ }],
42
+ }
43
+ return createMultiAgentProxy(multiConfig)
44
+ }
45
+
46
+ /** Multi-agent proxy with SSRF protection and mandatory auth */
47
+ export function createMultiAgentProxy(config: MultiAgentProxyConfig) {
48
+ const grantsClients = new Map<string, GrantsClient>()
49
+ for (const agent of config.agents) {
50
+ grantsClients.set(agent.email, new GrantsClient(agent.idp_url))
51
+ }
52
+
53
+ const mandatoryAuth = config.proxy.mandatory_auth ?? false
9
54
 
10
55
  return {
11
- port: parseInt(config.proxy.listen.split(':')[1] || '9090'),
56
+ port: Number.parseInt(config.proxy.listen.split(':')[1] || '9090'),
12
57
  hostname: config.proxy.listen.split(':')[0] || '127.0.0.1',
13
58
 
14
59
  async fetch(req: Request): Promise<Response> {
@@ -17,17 +62,19 @@ export function createProxy(config: ProxyConfig) {
17
62
 
18
63
  // Health endpoint
19
64
  if (url.pathname === '/healthz') {
20
- return Response.json({ status: 'ok', agent: config.proxy.agent_email })
65
+ return Response.json({
66
+ status: 'ok',
67
+ agents: config.agents.map(a => a.email),
68
+ })
21
69
  }
22
70
 
23
- // Parse target URL from the path.
24
- // The agent sends: http://proxy:9090/https://api.github.com/repos/x/issues
25
- // So the target URL is everything after the first /
71
+ // Parse target URL from the path
26
72
  const targetUrl = url.pathname.slice(1) + url.search
27
73
  let targetParsed: URL
28
74
  try {
29
75
  targetParsed = new URL(targetUrl)
30
- } catch {
76
+ }
77
+ catch {
31
78
  return new Response(
32
79
  'Invalid target URL. Send requests as: http://proxy:port/https://target.com/path',
33
80
  { status: 400 },
@@ -38,20 +85,80 @@ export function createProxy(config: ProxyConfig) {
38
85
  const method = req.method
39
86
  const path = targetParsed.pathname
40
87
 
41
- // Verify agent identity
42
- const agent = await verifyAgentAuth(
43
- req.headers.get('proxy-authorization'),
44
- config.proxy.idp_url,
45
- )
88
+ // SSRF protection — block private/loopback IPs before any rule evaluation
89
+ if (await isPrivateOrLoopback(domain)) {
90
+ return new Response('Blocked: private/loopback IP', { status: 403 })
91
+ }
92
+
93
+ // Read body once (needed for hash + forwarding)
94
+ const bodyBuffer = req.body ? await req.arrayBuffer() : null
46
95
 
47
- const agentEmail = agent?.email ?? config.proxy.agent_email
96
+ // Verify agent identity — find IdP URL from first agent (for JWKS verification)
97
+ // In multi-agent mode, we need the JWT to identify the agent first.
98
+ // We try verification against each agent's IdP until one succeeds.
99
+ let agentIdentity: { email: string, act: 'agent' } | null = null
100
+ try {
101
+ for (const agentConf of config.agents) {
102
+ agentIdentity = await verifyAgentAuth(
103
+ req.headers.get('proxy-authorization'),
104
+ agentConf.idp_url,
105
+ mandatoryAuth && config.agents.length === 1,
106
+ )
107
+ if (agentIdentity) break
108
+ }
109
+
110
+ // If mandatory auth and no identity found from any IdP
111
+ if (mandatoryAuth && !agentIdentity) {
112
+ throw new AuthError('JWT required')
113
+ }
114
+ }
115
+ catch (err) {
116
+ if (err instanceof AuthError) {
117
+ return new Response(`Unauthorized: ${err.message}`, { status: 401 })
118
+ }
119
+ throw err
120
+ }
121
+
122
+ // Find the matching agent config
123
+ const agentEmail = agentIdentity?.email
124
+ let agentConf: AgentConfig | undefined
125
+
126
+ if (agentEmail) {
127
+ agentConf = config.agents.find(a => a.email === agentEmail)
128
+ if (!agentConf) {
129
+ return new Response(`Forbidden: unknown agent ${agentEmail}`, { status: 403 })
130
+ }
131
+ }
132
+ else if (config.agents.length === 1) {
133
+ // Non-mandatory auth, single agent: use the only agent config
134
+ agentConf = config.agents[0]
135
+ }
136
+ else {
137
+ return new Response('Unauthorized: JWT required for multi-agent proxy', { status: 401 })
138
+ }
139
+
140
+ const effectiveEmail = agentEmail ?? agentConf.email
141
+ const grantsClient = grantsClients.get(agentConf.email)!
142
+
143
+ // Build a ProxyConfig-shaped object for evaluateRules
144
+ const rulesConfig: ProxyConfig = {
145
+ proxy: {
146
+ listen: config.proxy.listen,
147
+ idp_url: agentConf.idp_url,
148
+ agent_email: agentConf.email,
149
+ default_action: config.proxy.default_action,
150
+ },
151
+ allow: agentConf.allow ?? [],
152
+ deny: agentConf.deny ?? [],
153
+ grant_required: agentConf.grant_required ?? [],
154
+ }
48
155
 
49
156
  // Evaluate rules
50
- const action = evaluateRules(config, domain, method, path)
157
+ const action = evaluateRules(rulesConfig, domain, method, path)
51
158
 
52
159
  const baseAudit: Omit<AuditEntry, 'action' | 'rule'> = {
53
160
  ts: new Date().toISOString(),
54
- agent: agentEmail,
161
+ agent: effectiveEmail,
55
162
  domain,
56
163
  method,
57
164
  path,
@@ -66,16 +173,19 @@ export function createProxy(config: ProxyConfig) {
66
173
  // ALLOW (no grant needed)
67
174
  if (action.type === 'allow') {
68
175
  writeAudit({ ...baseAudit, action: 'allow', rule: 'allow-list', grant_id: null })
69
- return forwardRequest(req, targetUrl)
176
+ return forwardRequest(req, targetUrl, bodyBuffer)
70
177
  }
71
178
 
72
179
  // GRANT REQUIRED
73
180
  const rule = action.rule
74
181
  const permissions = rule.permissions ?? [`${method.toLowerCase()}:${domain}`]
75
182
 
183
+ // Compute request hash — binds grant to exact method + URL + body
184
+ const requestHash = await computeRequestHash(method, targetUrl, bodyBuffer)
185
+
76
186
  // Check for existing grant
77
187
  const existing = await grantsClient.findExistingGrant(
78
- agentEmail,
188
+ effectiveEmail,
79
189
  domain,
80
190
  permissions,
81
191
  ).catch(() => null)
@@ -86,8 +196,9 @@ export function createProxy(config: ProxyConfig) {
86
196
  action: 'grant_approved',
87
197
  rule: 'standing-grant',
88
198
  grant_id: existing.id,
199
+ request_hash: requestHash,
89
200
  })
90
- return forwardRequest(req, targetUrl)
201
+ return forwardRequest(req, targetUrl, bodyBuffer)
91
202
  }
92
203
 
93
204
  // No existing grant — behavior depends on default_action
@@ -99,11 +210,12 @@ export function createProxy(config: ProxyConfig) {
99
210
  if (config.proxy.default_action === 'request-async') {
100
211
  // Create grant request, return 407 immediately
101
212
  const grant = await grantsClient.requestGrant({
102
- requester: agentEmail,
213
+ requester: effectiveEmail,
103
214
  target: domain,
104
215
  grantType: rule.grant_type,
105
216
  permissions,
106
- reason: `${method} ${targetParsed.pathname}`,
217
+ reason: `${method} ${targetUrl}`,
218
+ requestHash,
107
219
  duration: rule.duration,
108
220
  }).catch(() => null)
109
221
 
@@ -129,11 +241,12 @@ export function createProxy(config: ProxyConfig) {
129
241
 
130
242
  try {
131
243
  const grant = await grantsClient.requestGrant({
132
- requester: agentEmail,
244
+ requester: effectiveEmail,
133
245
  target: domain,
134
246
  grantType: rule.grant_type,
135
247
  permissions,
136
- reason: `${method} ${targetParsed.pathname}`,
248
+ reason: `${method} ${targetUrl}`,
249
+ requestHash,
137
250
  duration: rule.duration,
138
251
  })
139
252
 
@@ -147,9 +260,10 @@ export function createProxy(config: ProxyConfig) {
147
260
  action: 'grant_approved',
148
261
  rule: 'grant_required',
149
262
  grant_id: approved.id,
263
+ request_hash: requestHash,
150
264
  waited_ms: waitedMs,
151
265
  })
152
- return forwardRequest(req, targetUrl)
266
+ return forwardRequest(req, targetUrl, bodyBuffer)
153
267
  }
154
268
 
155
269
  writeAudit({
@@ -160,8 +274,8 @@ export function createProxy(config: ProxyConfig) {
160
274
  waited_ms: waitedMs,
161
275
  })
162
276
  return new Response(`Grant denied by ${approved.decided_by}`, { status: 403 })
163
-
164
- } catch (err) {
277
+ }
278
+ catch (err) {
165
279
  const msg = err instanceof Error ? err.message : 'Unknown error'
166
280
  writeAudit({
167
281
  ...baseAudit,
@@ -175,11 +289,78 @@ export function createProxy(config: ProxyConfig) {
175
289
  }
176
290
  }
177
291
 
292
+ /**
293
+ * Create a node:http compatible handler for use with http.createServer().
294
+ * Returns both a request handler and a CONNECT handler.
295
+ */
296
+ export function createNodeHandler(config: MultiAgentProxyConfig): {
297
+ handleRequest: (req: IncomingMessage, res: ServerResponse) => void
298
+ handleConnect: (req: IncomingMessage, socket: Socket, head: Buffer) => void
299
+ } {
300
+ const proxy = createMultiAgentProxy(config)
301
+
302
+ return {
303
+ handleRequest(req: IncomingMessage, res: ServerResponse) {
304
+ // Convert IncomingMessage to Request and use existing fetch logic
305
+ const url = `http://${req.headers.host || 'localhost'}${req.url || '/'}`
306
+ const chunks: Buffer[] = []
307
+
308
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
309
+ req.on('end', async () => {
310
+ try {
311
+ const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined
312
+ const headers = new Headers()
313
+ for (const [key, value] of Object.entries(req.headers)) {
314
+ if (value) {
315
+ headers.set(key, Array.isArray(value) ? value.join(', ') : value)
316
+ }
317
+ }
318
+
319
+ const request = new Request(url, {
320
+ method: req.method,
321
+ headers,
322
+ body: body && req.method !== 'GET' && req.method !== 'HEAD' ? body : undefined,
323
+ duplex: 'half',
324
+ } as RequestInit)
325
+
326
+ const response = await proxy.fetch(request)
327
+
328
+ res.writeHead(response.status, response.statusText, Object.fromEntries(response.headers))
329
+ if (response.body) {
330
+ const reader = response.body.getReader()
331
+ const pump = async (): Promise<void> => {
332
+ const { done, value } = await reader.read()
333
+ if (done) {
334
+ res.end()
335
+ return
336
+ }
337
+ res.write(value)
338
+ return pump()
339
+ }
340
+ await pump()
341
+ }
342
+ else {
343
+ res.end()
344
+ }
345
+ }
346
+ catch {
347
+ res.writeHead(502)
348
+ res.end('Proxy error')
349
+ }
350
+ })
351
+ },
352
+
353
+ handleConnect(req: IncomingMessage, socket: Socket, head: Buffer) {
354
+ handleConnect(config, req, socket, head)
355
+ },
356
+ }
357
+ }
358
+
178
359
  /**
179
360
  * Forward a request to the target URL.
180
361
  * Strips proxy-specific headers, preserves the rest.
181
362
  */
182
- async function forwardRequest(originalReq: Request, targetUrl: string): Promise<Response> {
363
+ async function forwardRequest(originalReq: Request, targetUrl: string, cachedBody?: ArrayBuffer | null): Promise<Response> {
183
364
  const headers = new Headers(originalReq.headers)
184
365
  // Remove proxy-specific headers
185
366
  headers.delete('proxy-authorization')
@@ -187,12 +368,13 @@ async function forwardRequest(originalReq: Request, targetUrl: string): Promise<
187
368
  // Don't send host of the proxy
188
369
  headers.delete('host')
189
370
 
371
+ const body = cachedBody && cachedBody.byteLength > 0 ? cachedBody : null
372
+
190
373
  try {
191
374
  const res = await fetch(targetUrl, {
192
375
  method: originalReq.method,
193
376
  headers,
194
- body: originalReq.body,
195
- // @ts-expect-error Bun supports duplex
377
+ body,
196
378
  duplex: 'half',
197
379
  redirect: 'manual',
198
380
  })
package/src/ssrf.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { resolve4, resolve6 } from 'node:dns/promises'
2
+ import { isIP } from 'node:net'
3
+
4
+ const PRIVATE_RANGES_V4 = [
5
+ { prefix: 0x7F000000, mask: 0xFF000000 }, // 127.0.0.0/8
6
+ { prefix: 0x0A000000, mask: 0xFF000000 }, // 10.0.0.0/8
7
+ { prefix: 0xAC100000, mask: 0xFFF00000 }, // 172.16.0.0/12
8
+ { prefix: 0xC0A80000, mask: 0xFFFF0000 }, // 192.168.0.0/16
9
+ { prefix: 0xA9FE0000, mask: 0xFFFF0000 }, // 169.254.0.0/16
10
+ { prefix: 0x00000000, mask: 0xFF000000 }, // 0.0.0.0/8
11
+ ]
12
+
13
+ function ipv4ToNumber(ip: string): number {
14
+ const parts = ip.split('.').map(Number)
15
+ return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0
16
+ }
17
+
18
+ function isPrivateIPv4(ip: string): boolean {
19
+ const num = ipv4ToNumber(ip)
20
+ return PRIVATE_RANGES_V4.some(r => ((num & r.mask) >>> 0) === r.prefix)
21
+ }
22
+
23
+ function isPrivateIPv6(ip: string): boolean {
24
+ const normalized = ip.toLowerCase()
25
+
26
+ // Loopback ::1
27
+ if (normalized === '::1') return true
28
+
29
+ // Unspecified ::
30
+ if (normalized === '::') return true
31
+
32
+ // Link-local fe80::/10
33
+ if (normalized.startsWith('fe8') || normalized.startsWith('fe9')
34
+ || normalized.startsWith('fea') || normalized.startsWith('feb')) {
35
+ return true
36
+ }
37
+
38
+ // Unique local fd00::/8
39
+ if (normalized.startsWith('fd')) return true
40
+
41
+ // IPv4-mapped ::ffff:x.x.x.x
42
+ const v4mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
43
+ if (v4mapped) return isPrivateIPv4(v4mapped[1])
44
+
45
+ return false
46
+ }
47
+
48
+ function isPrivateIP(ip: string): boolean {
49
+ if (isIP(ip) === 4) return isPrivateIPv4(ip)
50
+ if (isIP(ip) === 6) return isPrivateIPv6(ip)
51
+ return false
52
+ }
53
+
54
+ /**
55
+ * Check if a hostname resolves to a private or loopback IP.
56
+ * If the hostname is already an IP literal, check directly.
57
+ * Otherwise, resolve via DNS and check all results.
58
+ */
59
+ export async function isPrivateOrLoopback(hostname: string): Promise<boolean> {
60
+ // Direct IP literal
61
+ if (isIP(hostname)) {
62
+ return isPrivateIP(hostname)
63
+ }
64
+
65
+ // localhost shortcut
66
+ if (hostname === 'localhost') return true
67
+
68
+ // DNS resolution — check both A and AAAA records
69
+ try {
70
+ const [v4, v6] = await Promise.allSettled([
71
+ resolve4(hostname),
72
+ resolve6(hostname),
73
+ ])
74
+ const addrs: string[] = []
75
+ if (v4.status === 'fulfilled') addrs.push(...v4.value)
76
+ if (v6.status === 'fulfilled') addrs.push(...v6.value)
77
+
78
+ if (addrs.length === 0) return true // no records — block to be safe
79
+ return addrs.some(addr => isPrivateIP(addr))
80
+ }
81
+ catch {
82
+ // DNS failure — block to be safe
83
+ return true
84
+ }
85
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** Proxy configuration (parsed from TOML/JSON) */
1
+ /** Single-agent proxy configuration (legacy format, parsed from TOML/JSON) */
2
2
  export interface ProxyConfig {
3
3
  proxy: {
4
4
  listen: string
@@ -6,12 +6,32 @@ export interface ProxyConfig {
6
6
  agent_email: string
7
7
  default_action: 'block' | 'request' | 'request-async'
8
8
  audit_log?: string
9
+ mandatory_auth?: boolean
9
10
  }
10
11
  allow: RuleEntry[]
11
12
  deny: RuleEntry[]
12
13
  grant_required: GrantRuleEntry[]
13
14
  }
14
15
 
16
+ /** Multi-agent proxy configuration */
17
+ export interface MultiAgentProxyConfig {
18
+ proxy: {
19
+ listen: string
20
+ default_action: 'block' | 'request' | 'request-async'
21
+ audit_log?: string
22
+ mandatory_auth?: boolean
23
+ }
24
+ agents: AgentConfig[]
25
+ }
26
+
27
+ export interface AgentConfig {
28
+ email: string
29
+ idp_url: string
30
+ allow?: RuleEntry[]
31
+ deny?: RuleEntry[]
32
+ grant_required?: GrantRuleEntry[]
33
+ }
34
+
15
35
  export interface RuleEntry {
16
36
  domain: string
17
37
  methods?: string[]
@@ -38,6 +58,7 @@ export interface AuditEntry {
38
58
  method: string
39
59
  path: string
40
60
  grant_id?: string | null
61
+ request_hash?: string
41
62
  rule: string
42
63
  waited_ms?: number
43
64
  error?: string