@openape/proxy 0.1.2 → 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openape/proxy",
3
3
  "type": "module",
4
- "version": "0.1.2",
4
+ "version": "0.2.0",
5
5
  "description": "OpenApe agent HTTP gateway — forward proxy with grant-based access control",
6
6
  "author": "Patrick Hofmann",
7
7
  "license": "AGPL-3.0-or-later",
@@ -0,0 +1,7 @@
1
+ shellEmulator: true
2
+
3
+ trustPolicy: no-downgrade
4
+
5
+ overrides:
6
+ '@openape/core': link:../core
7
+ '@openape/grants': link:../grants
package/src/auth.ts CHANGED
@@ -5,18 +5,33 @@ export interface AgentIdentity {
5
5
  act: 'agent'
6
6
  }
7
7
 
8
+ export class AuthError extends Error {
9
+ constructor(message: string) {
10
+ super(message)
11
+ this.name = 'AuthError'
12
+ }
13
+ }
14
+
8
15
  /**
9
16
  * Verify agent JWT from Proxy-Authorization header.
10
- * Returns the agent identity or null if invalid.
17
+ * Returns the agent identity or null if invalid/missing.
18
+ * When mandatory is true, throws AuthError if no valid JWT is provided.
11
19
  */
12
20
  export async function verifyAgentAuth(
13
21
  authHeader: string | null,
14
22
  idpUrl: string,
23
+ mandatory: boolean = false,
15
24
  ): Promise<AgentIdentity | null> {
16
- if (!authHeader) return null
25
+ if (!authHeader) {
26
+ if (mandatory) throw new AuthError('JWT required')
27
+ return null
28
+ }
17
29
 
18
30
  const match = authHeader.match(/^Bearer\s+(.+)$/i)
19
- if (!match) return null
31
+ if (!match) {
32
+ if (mandatory) throw new AuthError('Invalid authorization header')
33
+ return null
34
+ }
20
35
 
21
36
  const token = match[1]
22
37
 
@@ -25,6 +40,7 @@ export async function verifyAgentAuth(
25
40
  const { payload } = await verifyJWT(token, jwks, { issuer: idpUrl })
26
41
 
27
42
  if (payload.act !== 'agent' || !payload.sub) {
43
+ if (mandatory) throw new AuthError('Invalid agent token')
28
44
  return null
29
45
  }
30
46
 
@@ -32,7 +48,10 @@ export async function verifyAgentAuth(
32
48
  email: payload.sub as string,
33
49
  act: 'agent',
34
50
  }
35
- } catch {
51
+ }
52
+ catch (err) {
53
+ if (err instanceof AuthError) throw err
54
+ if (mandatory) throw new AuthError('JWT verification failed')
36
55
  return null
37
56
  }
38
57
  }
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { parse as parseTOML } from 'smol-toml'
3
- import type { ProxyConfig } from './types.js'
3
+ import type { AgentConfig, MultiAgentProxyConfig, ProxyConfig } from './types.js'
4
4
 
5
5
  export function loadConfig(path: string): ProxyConfig {
6
6
  const raw = readFileSync(path, 'utf-8')
@@ -8,7 +8,8 @@ export function loadConfig(path: string): ProxyConfig {
8
8
  let parsed: Record<string, unknown>
9
9
  if (path.endsWith('.json')) {
10
10
  parsed = JSON.parse(raw)
11
- } else {
11
+ }
12
+ else {
12
13
  parsed = parseTOML(raw) as Record<string, unknown>
13
14
  }
14
15
 
@@ -26,3 +27,58 @@ export function loadConfig(path: string): ProxyConfig {
26
27
  grant_required: (parsed.grant_required ?? []) as ProxyConfig['grant_required'],
27
28
  }
28
29
  }
30
+
31
+ /**
32
+ * Load config as multi-agent format.
33
+ * If the config has an `agents` array, use it directly.
34
+ * Otherwise, convert single-agent format to multi-agent for backward-compat.
35
+ */
36
+ export function loadMultiAgentConfig(path: string, overrides?: { mandatoryAuth?: boolean }): MultiAgentProxyConfig {
37
+ const raw = readFileSync(path, 'utf-8')
38
+
39
+ let parsed: Record<string, unknown>
40
+ if (path.endsWith('.json')) {
41
+ parsed = JSON.parse(raw)
42
+ }
43
+ else {
44
+ parsed = parseTOML(raw) as Record<string, unknown>
45
+ }
46
+
47
+ const proxy = parsed.proxy as Record<string, unknown>
48
+ if (!proxy?.listen) {
49
+ throw new Error('Config must have [proxy] with listen')
50
+ }
51
+
52
+ const baseProxy: MultiAgentProxyConfig['proxy'] = {
53
+ listen: proxy.listen as string,
54
+ default_action: (proxy.default_action as MultiAgentProxyConfig['proxy']['default_action']) ?? 'block',
55
+ audit_log: proxy.audit_log as string | undefined,
56
+ mandatory_auth: overrides?.mandatoryAuth ?? (proxy.mandatory_auth as boolean | undefined),
57
+ }
58
+
59
+ // Multi-agent format: has agents array
60
+ if (Array.isArray(parsed.agents)) {
61
+ return {
62
+ proxy: baseProxy,
63
+ agents: parsed.agents as AgentConfig[],
64
+ }
65
+ }
66
+
67
+ // Single-agent format: convert to multi-agent
68
+ const idpUrl = proxy.idp_url as string
69
+ const agentEmail = proxy.agent_email as string
70
+ if (!idpUrl || !agentEmail) {
71
+ throw new Error('Single-agent config requires proxy.idp_url and proxy.agent_email')
72
+ }
73
+
74
+ return {
75
+ proxy: baseProxy,
76
+ agents: [{
77
+ email: agentEmail,
78
+ idp_url: idpUrl,
79
+ allow: (parsed.allow ?? []) as AgentConfig['allow'],
80
+ deny: (parsed.deny ?? []) as AgentConfig['deny'],
81
+ grant_required: (parsed.grant_required ?? []) as AgentConfig['grant_required'],
82
+ }],
83
+ }
84
+ }
package/src/connect.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type { IncomingMessage } from 'node:http'
2
+ import type { Socket } from 'node:net'
3
+ import { connect } from 'node:net'
4
+ import type { MultiAgentProxyConfig } from './types.js'
5
+ import { AuthError, verifyAgentAuth } from './auth.js'
6
+ import { isPrivateOrLoopback } from './ssrf.js'
7
+ import { writeAudit } from './audit.js'
8
+
9
+ /**
10
+ * Handle HTTP CONNECT requests for tunneling (used by HTTP_PROXY clients).
11
+ * Flow: Auth check → SSRF check → TCP connect → bidirectional pipe.
12
+ */
13
+ export async function handleConnect(
14
+ config: MultiAgentProxyConfig,
15
+ req: IncomingMessage,
16
+ clientSocket: Socket,
17
+ _head: Buffer,
18
+ ): Promise<void> {
19
+ const target = req.url ?? ''
20
+ const [host, portStr] = target.split(':')
21
+ const port = Number.parseInt(portStr || '443')
22
+
23
+ if (!host || !port) {
24
+ clientSocket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
25
+ clientSocket.destroy()
26
+ return
27
+ }
28
+
29
+ const mandatoryAuth = config.proxy.mandatory_auth ?? false
30
+
31
+ // Auth check — CONNECT always requires auth in mandatory mode
32
+ let agentEmail: string | undefined
33
+ try {
34
+ const authHeader = req.headers['proxy-authorization'] as string | undefined
35
+ let identity: { email: string, act: 'agent' } | null = null
36
+
37
+ for (const agentConf of config.agents) {
38
+ identity = await verifyAgentAuth(
39
+ authHeader ?? null,
40
+ agentConf.idp_url,
41
+ mandatoryAuth && config.agents.length === 1,
42
+ )
43
+ if (identity) break
44
+ }
45
+
46
+ if (mandatoryAuth && !identity) {
47
+ throw new AuthError('JWT required')
48
+ }
49
+
50
+ agentEmail = identity?.email
51
+
52
+ // Verify agent is known
53
+ if (agentEmail) {
54
+ const known = config.agents.find(a => a.email === agentEmail)
55
+ if (!known) {
56
+ clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
57
+ clientSocket.destroy()
58
+ return
59
+ }
60
+ }
61
+ else if (config.agents.length > 1) {
62
+ throw new AuthError('JWT required for multi-agent proxy')
63
+ }
64
+ }
65
+ catch (err) {
66
+ if (err instanceof AuthError) {
67
+ clientSocket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
68
+ clientSocket.destroy()
69
+ return
70
+ }
71
+ throw err
72
+ }
73
+
74
+ // SSRF check
75
+ if (await isPrivateOrLoopback(host)) {
76
+ writeAudit({
77
+ ts: new Date().toISOString(),
78
+ agent: agentEmail ?? config.agents[0]?.email ?? 'unknown',
79
+ action: 'deny',
80
+ domain: host,
81
+ method: 'CONNECT',
82
+ path: target,
83
+ rule: 'ssrf-blocked',
84
+ })
85
+ clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
86
+ clientSocket.destroy()
87
+ return
88
+ }
89
+
90
+ // Connect to target
91
+ const targetSocket = connect(port, host, () => {
92
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
93
+
94
+ // Bidirectional pipe
95
+ targetSocket.pipe(clientSocket)
96
+ clientSocket.pipe(targetSocket)
97
+ })
98
+
99
+ targetSocket.on('error', () => {
100
+ clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
101
+ clientSocket.destroy()
102
+ })
103
+
104
+ clientSocket.on('error', () => {
105
+ targetSocket.destroy()
106
+ })
107
+
108
+ // Cleanup on close
109
+ clientSocket.on('close', () => targetSocket.destroy())
110
+ targetSocket.on('close', () => clientSocket.destroy())
111
+ }
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,9 +1,13 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { Socket } from 'node:net'
1
3
  import { createHash } from 'node:crypto'
2
- import type { ProxyConfig, AuditEntry } from './types.js'
4
+ import type { AgentConfig, AuditEntry, MultiAgentProxyConfig, ProxyConfig } from './types.js'
3
5
  import { evaluateRules } from './matcher.js'
4
- import { verifyAgentAuth } from './auth.js'
6
+ import { AuthError, verifyAgentAuth } from './auth.js'
5
7
  import { GrantsClient } from './grants-client.js'
6
8
  import { writeAudit } from './audit.js'
9
+ import { isPrivateOrLoopback } from './ssrf.js'
10
+ import { handleConnect } from './connect.js'
7
11
 
8
12
  /**
9
13
  * Compute a request hash that uniquely identifies the intent.
@@ -19,11 +23,37 @@ async function computeRequestHash(method: string, targetUrl: string, body: Array
19
23
  return hash.digest('hex')
20
24
  }
21
25
 
26
+ /** Legacy single-agent proxy */
22
27
  export function createProxy(config: ProxyConfig) {
23
- 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
24
54
 
25
55
  return {
26
- port: parseInt(config.proxy.listen.split(':')[1] || '9090'),
56
+ port: Number.parseInt(config.proxy.listen.split(':')[1] || '9090'),
27
57
  hostname: config.proxy.listen.split(':')[0] || '127.0.0.1',
28
58
 
29
59
  async fetch(req: Request): Promise<Response> {
@@ -32,17 +62,19 @@ export function createProxy(config: ProxyConfig) {
32
62
 
33
63
  // Health endpoint
34
64
  if (url.pathname === '/healthz') {
35
- 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
+ })
36
69
  }
37
70
 
38
- // Parse target URL from the path.
39
- // The agent sends: http://proxy:9090/https://api.github.com/repos/x/issues
40
- // So the target URL is everything after the first /
71
+ // Parse target URL from the path
41
72
  const targetUrl = url.pathname.slice(1) + url.search
42
73
  let targetParsed: URL
43
74
  try {
44
75
  targetParsed = new URL(targetUrl)
45
- } catch {
76
+ }
77
+ catch {
46
78
  return new Response(
47
79
  'Invalid target URL. Send requests as: http://proxy:port/https://target.com/path',
48
80
  { status: 400 },
@@ -53,23 +85,80 @@ export function createProxy(config: ProxyConfig) {
53
85
  const method = req.method
54
86
  const path = targetParsed.pathname
55
87
 
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
+
56
93
  // Read body once (needed for hash + forwarding)
57
94
  const bodyBuffer = req.body ? await req.arrayBuffer() : null
58
95
 
59
- // Verify agent identity
60
- const agent = await verifyAgentAuth(
61
- req.headers.get('proxy-authorization'),
62
- config.proxy.idp_url,
63
- )
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
+ }
64
139
 
65
- const agentEmail = agent?.email ?? config.proxy.agent_email
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
+ }
66
155
 
67
156
  // Evaluate rules
68
- const action = evaluateRules(config, domain, method, path)
157
+ const action = evaluateRules(rulesConfig, domain, method, path)
69
158
 
70
159
  const baseAudit: Omit<AuditEntry, 'action' | 'rule'> = {
71
160
  ts: new Date().toISOString(),
72
- agent: agentEmail,
161
+ agent: effectiveEmail,
73
162
  domain,
74
163
  method,
75
164
  path,
@@ -96,7 +185,7 @@ export function createProxy(config: ProxyConfig) {
96
185
 
97
186
  // Check for existing grant
98
187
  const existing = await grantsClient.findExistingGrant(
99
- agentEmail,
188
+ effectiveEmail,
100
189
  domain,
101
190
  permissions,
102
191
  ).catch(() => null)
@@ -121,7 +210,7 @@ export function createProxy(config: ProxyConfig) {
121
210
  if (config.proxy.default_action === 'request-async') {
122
211
  // Create grant request, return 407 immediately
123
212
  const grant = await grantsClient.requestGrant({
124
- requester: agentEmail,
213
+ requester: effectiveEmail,
125
214
  target: domain,
126
215
  grantType: rule.grant_type,
127
216
  permissions,
@@ -152,7 +241,7 @@ export function createProxy(config: ProxyConfig) {
152
241
 
153
242
  try {
154
243
  const grant = await grantsClient.requestGrant({
155
- requester: agentEmail,
244
+ requester: effectiveEmail,
156
245
  target: domain,
157
246
  grantType: rule.grant_type,
158
247
  permissions,
@@ -185,8 +274,8 @@ export function createProxy(config: ProxyConfig) {
185
274
  waited_ms: waitedMs,
186
275
  })
187
276
  return new Response(`Grant denied by ${approved.decided_by}`, { status: 403 })
188
-
189
- } catch (err) {
277
+ }
278
+ catch (err) {
190
279
  const msg = err instanceof Error ? err.message : 'Unknown error'
191
280
  writeAudit({
192
281
  ...baseAudit,
@@ -200,6 +289,73 @@ export function createProxy(config: ProxyConfig) {
200
289
  }
201
290
  }
202
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
+
203
359
  /**
204
360
  * Forward a request to the target URL.
205
361
  * Strips proxy-specific headers, preserves the rest.
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[]
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { AuthError, verifyAgentAuth } from '../src/auth.js'
3
+
4
+ describe('verifyAgentAuth', () => {
5
+ const idpUrl = 'https://id.example.com'
6
+
7
+ describe('non-mandatory mode', () => {
8
+ it('returns null when no header provided', async () => {
9
+ const result = await verifyAgentAuth(null, idpUrl, false)
10
+ expect(result).toBeNull()
11
+ })
12
+
13
+ it('returns null for invalid header format', async () => {
14
+ const result = await verifyAgentAuth('Basic abc123', idpUrl, false)
15
+ expect(result).toBeNull()
16
+ })
17
+
18
+ it('returns null for invalid JWT', async () => {
19
+ const result = await verifyAgentAuth('Bearer invalid.token.here', idpUrl, false)
20
+ expect(result).toBeNull()
21
+ })
22
+ })
23
+
24
+ describe('mandatory mode', () => {
25
+ it('throws AuthError when no header provided', async () => {
26
+ await expect(
27
+ verifyAgentAuth(null, idpUrl, true),
28
+ ).rejects.toThrow(AuthError)
29
+ })
30
+
31
+ it('throws AuthError with "JWT required" message', async () => {
32
+ await expect(
33
+ verifyAgentAuth(null, idpUrl, true),
34
+ ).rejects.toThrow('JWT required')
35
+ })
36
+
37
+ it('throws AuthError for invalid header format', async () => {
38
+ await expect(
39
+ verifyAgentAuth('Basic abc123', idpUrl, true),
40
+ ).rejects.toThrow(AuthError)
41
+ })
42
+
43
+ it('throws AuthError for invalid JWT token', async () => {
44
+ await expect(
45
+ verifyAgentAuth('Bearer invalid.token.here', idpUrl, true),
46
+ ).rejects.toThrow(AuthError)
47
+ })
48
+ })
49
+
50
+ describe('authError', () => {
51
+ it('is an instance of Error', () => {
52
+ const err = new AuthError('test')
53
+ expect(err).toBeInstanceOf(Error)
54
+ expect(err.name).toBe('AuthError')
55
+ })
56
+ })
57
+ })
@@ -0,0 +1,131 @@
1
+ import { createServer } from 'node:http'
2
+ import { connect } from 'node:net'
3
+ import { describe, expect, it, afterEach } from 'vitest'
4
+ import type { MultiAgentProxyConfig } from '../src/types.js'
5
+ import { handleConnect } from '../src/connect.js'
6
+
7
+ function makeConfig(overrides?: Partial<MultiAgentProxyConfig['proxy']>): MultiAgentProxyConfig {
8
+ return {
9
+ proxy: {
10
+ listen: '127.0.0.1:0',
11
+ default_action: 'block',
12
+ mandatory_auth: true,
13
+ ...overrides,
14
+ },
15
+ agents: [{
16
+ email: 'bot@example.com',
17
+ idp_url: 'https://id.example.com',
18
+ }],
19
+ }
20
+ }
21
+
22
+ function startProxy(config: MultiAgentProxyConfig): Promise<{ port: number, close: () => Promise<void> }> {
23
+ return new Promise((resolve) => {
24
+ const server = createServer((_req, res) => {
25
+ res.writeHead(200)
26
+ res.end('ok')
27
+ })
28
+ server.on('connect', (req, socket, head) => {
29
+ handleConnect(config, req, socket, head)
30
+ })
31
+ server.listen(0, '127.0.0.1', () => {
32
+ const addr = server.address() as { port: number }
33
+ resolve({
34
+ port: addr.port,
35
+ close: () => new Promise<void>(r => server.close(() => r())),
36
+ })
37
+ })
38
+ })
39
+ }
40
+
41
+ function sendConnect(proxyPort: number, target: string, headers?: Record<string, string>): Promise<{ statusLine: string }> {
42
+ return new Promise((resolve, reject) => {
43
+ const socket = connect(proxyPort, '127.0.0.1', () => {
44
+ let headerStr = `CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n`
45
+ if (headers) {
46
+ for (const [k, v] of Object.entries(headers)) {
47
+ headerStr += `${k}: ${v}\r\n`
48
+ }
49
+ }
50
+ headerStr += '\r\n'
51
+ socket.write(headerStr)
52
+ })
53
+
54
+ let data = ''
55
+ socket.on('data', (chunk) => {
56
+ data += chunk.toString()
57
+ if (data.includes('\r\n\r\n')) {
58
+ const statusLine = data.split('\r\n')[0]
59
+ socket.destroy()
60
+ resolve({ statusLine })
61
+ }
62
+ })
63
+
64
+ socket.on('error', reject)
65
+ socket.setTimeout(3000, () => {
66
+ socket.destroy()
67
+ reject(new Error('Timeout'))
68
+ })
69
+ })
70
+ }
71
+
72
+ describe('connect handler', () => {
73
+ let cleanup: (() => Promise<void>) | undefined
74
+
75
+ afterEach(async () => {
76
+ if (cleanup) {
77
+ await cleanup()
78
+ cleanup = undefined
79
+ }
80
+ })
81
+
82
+ it('blocks CONNECT to loopback IP (SSRF)', async () => {
83
+ const config = makeConfig({ mandatory_auth: false })
84
+ config.agents = [{ email: 'bot@example.com', idp_url: 'https://id.example.com' }]
85
+ const { port, close } = await startProxy(config)
86
+ cleanup = close
87
+
88
+ const { statusLine } = await sendConnect(port, '127.0.0.1:3000')
89
+ expect(statusLine).toContain('403')
90
+ })
91
+
92
+ it('blocks CONNECT to private IP (SSRF)', async () => {
93
+ const config = makeConfig({ mandatory_auth: false })
94
+ config.agents = [{ email: 'bot@example.com', idp_url: 'https://id.example.com' }]
95
+ const { port, close } = await startProxy(config)
96
+ cleanup = close
97
+
98
+ const { statusLine } = await sendConnect(port, '10.0.0.1:80')
99
+ expect(statusLine).toContain('403')
100
+ })
101
+
102
+ it('returns 401 without JWT when mandatory auth is enabled', async () => {
103
+ const config = makeConfig({ mandatory_auth: true })
104
+ const { port, close } = await startProxy(config)
105
+ cleanup = close
106
+
107
+ const { statusLine } = await sendConnect(port, 'httpbin.org:443')
108
+ expect(statusLine).toContain('401')
109
+ })
110
+
111
+ it('returns 401 with invalid JWT when mandatory auth is enabled', async () => {
112
+ const config = makeConfig({ mandatory_auth: true })
113
+ const { port, close } = await startProxy(config)
114
+ cleanup = close
115
+
116
+ const { statusLine } = await sendConnect(port, 'httpbin.org:443', {
117
+ 'Proxy-Authorization': 'Bearer invalid.token.here',
118
+ })
119
+ expect(statusLine).toContain('401')
120
+ })
121
+
122
+ it('blocks malformed target (treated as SSRF)', async () => {
123
+ const config = makeConfig({ mandatory_auth: false })
124
+ config.agents = [{ email: 'bot@example.com', idp_url: 'https://id.example.com' }]
125
+ const { port, close } = await startProxy(config)
126
+ cleanup = close
127
+
128
+ const { statusLine } = await sendConnect(port, 'not-a-host')
129
+ expect(statusLine).toContain('403')
130
+ })
131
+ })
@@ -0,0 +1,122 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { loadMultiAgentConfig } from '../src/config.js'
6
+
7
+ function tmpFile(name: string, content: string): string {
8
+ const dir = mkdtempSync(join(tmpdir(), 'proxy-test-'))
9
+ const path = join(dir, name)
10
+ writeFileSync(path, content)
11
+ return path
12
+ }
13
+
14
+ describe('loadMultiAgentConfig', () => {
15
+ it('loads multi-agent TOML config', () => {
16
+ const path = tmpFile('proxy.toml', `
17
+ [proxy]
18
+ listen = "127.0.0.1:9090"
19
+ default_action = "block"
20
+ mandatory_auth = true
21
+
22
+ [[agents]]
23
+ email = "bot1@example.com"
24
+ idp_url = "https://id1.example.com"
25
+
26
+ [[agents.allow]]
27
+ domain = "api.github.com"
28
+
29
+ [[agents]]
30
+ email = "bot2@example.com"
31
+ idp_url = "https://id2.example.com"
32
+ `)
33
+
34
+ const config = loadMultiAgentConfig(path)
35
+
36
+ expect(config.proxy.listen).toBe('127.0.0.1:9090')
37
+ expect(config.proxy.default_action).toBe('block')
38
+ expect(config.proxy.mandatory_auth).toBe(true)
39
+ expect(config.agents).toHaveLength(2)
40
+ expect(config.agents[0].email).toBe('bot1@example.com')
41
+ expect(config.agents[0].idp_url).toBe('https://id1.example.com')
42
+ expect(config.agents[0].allow).toHaveLength(1)
43
+ expect(config.agents[0].allow![0].domain).toBe('api.github.com')
44
+ expect(config.agents[1].email).toBe('bot2@example.com')
45
+ })
46
+
47
+ it('converts single-agent config to multi-agent format', () => {
48
+ const path = tmpFile('legacy.toml', `
49
+ [proxy]
50
+ listen = "127.0.0.1:9090"
51
+ idp_url = "https://id.example.com"
52
+ agent_email = "agent@example.com"
53
+ default_action = "request-async"
54
+
55
+ [[allow]]
56
+ domain = "*.github.com"
57
+
58
+ [[deny]]
59
+ domain = "evil.com"
60
+
61
+ [[grant_required]]
62
+ domain = "api.openai.com"
63
+ grant_type = "once"
64
+ `)
65
+
66
+ const config = loadMultiAgentConfig(path)
67
+
68
+ expect(config.proxy.listen).toBe('127.0.0.1:9090')
69
+ expect(config.proxy.default_action).toBe('request-async')
70
+ expect(config.agents).toHaveLength(1)
71
+ expect(config.agents[0].email).toBe('agent@example.com')
72
+ expect(config.agents[0].idp_url).toBe('https://id.example.com')
73
+ expect(config.agents[0].allow).toHaveLength(1)
74
+ expect(config.agents[0].deny).toHaveLength(1)
75
+ expect(config.agents[0].grant_required).toHaveLength(1)
76
+ })
77
+
78
+ it('applies mandatory-auth override', () => {
79
+ const path = tmpFile('override.toml', `
80
+ [proxy]
81
+ listen = "127.0.0.1:9090"
82
+ idp_url = "https://id.example.com"
83
+ agent_email = "agent@example.com"
84
+ `)
85
+
86
+ const config = loadMultiAgentConfig(path, { mandatoryAuth: true })
87
+ expect(config.proxy.mandatory_auth).toBe(true)
88
+ })
89
+
90
+ it('loads multi-agent JSON config', () => {
91
+ const path = tmpFile('proxy.json', JSON.stringify({
92
+ proxy: {
93
+ listen: '127.0.0.1:9090',
94
+ default_action: 'block',
95
+ },
96
+ agents: [
97
+ { email: 'a@b.com', idp_url: 'https://id.example.com' },
98
+ ],
99
+ }))
100
+
101
+ const config = loadMultiAgentConfig(path)
102
+ expect(config.agents).toHaveLength(1)
103
+ expect(config.agents[0].email).toBe('a@b.com')
104
+ })
105
+
106
+ it('throws on missing listen', () => {
107
+ const path = tmpFile('bad.toml', `
108
+ [proxy]
109
+ default_action = "block"
110
+ `)
111
+ expect(() => loadMultiAgentConfig(path)).toThrow('listen')
112
+ })
113
+
114
+ it('throws on single-agent without idp_url', () => {
115
+ const path = tmpFile('bad2.toml', `
116
+ [proxy]
117
+ listen = "127.0.0.1:9090"
118
+ agent_email = "agent@example.com"
119
+ `)
120
+ expect(() => loadMultiAgentConfig(path)).toThrow('idp_url')
121
+ })
122
+ })
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isPrivateOrLoopback } from '../src/ssrf.js'
3
+
4
+ describe('isPrivateOrLoopback', () => {
5
+ // IPv4 loopback
6
+ it('blocks 127.0.0.1', async () => {
7
+ expect(await isPrivateOrLoopback('127.0.0.1')).toBe(true)
8
+ })
9
+
10
+ it('blocks 127.255.255.255', async () => {
11
+ expect(await isPrivateOrLoopback('127.255.255.255')).toBe(true)
12
+ })
13
+
14
+ // RFC 1918
15
+ it('blocks 10.0.0.1', async () => {
16
+ expect(await isPrivateOrLoopback('10.0.0.1')).toBe(true)
17
+ })
18
+
19
+ it('blocks 172.16.0.1', async () => {
20
+ expect(await isPrivateOrLoopback('172.16.0.1')).toBe(true)
21
+ })
22
+
23
+ it('blocks 172.31.255.255', async () => {
24
+ expect(await isPrivateOrLoopback('172.31.255.255')).toBe(true)
25
+ })
26
+
27
+ it('blocks 192.168.1.1', async () => {
28
+ expect(await isPrivateOrLoopback('192.168.1.1')).toBe(true)
29
+ })
30
+
31
+ // Link-local
32
+ it('blocks 169.254.0.1', async () => {
33
+ expect(await isPrivateOrLoopback('169.254.0.1')).toBe(true)
34
+ })
35
+
36
+ // Unspecified
37
+ it('blocks 0.0.0.0', async () => {
38
+ expect(await isPrivateOrLoopback('0.0.0.0')).toBe(true)
39
+ })
40
+
41
+ // IPv6
42
+ it('blocks ::1', async () => {
43
+ expect(await isPrivateOrLoopback('::1')).toBe(true)
44
+ })
45
+
46
+ it('blocks ::', async () => {
47
+ expect(await isPrivateOrLoopback('::')).toBe(true)
48
+ })
49
+
50
+ // localhost
51
+ it('blocks localhost', async () => {
52
+ expect(await isPrivateOrLoopback('localhost')).toBe(true)
53
+ })
54
+
55
+ // Public IPs — should NOT be blocked
56
+ it('allows 8.8.8.8', async () => {
57
+ expect(await isPrivateOrLoopback('8.8.8.8')).toBe(false)
58
+ })
59
+
60
+ it('allows 1.1.1.1', async () => {
61
+ expect(await isPrivateOrLoopback('1.1.1.1')).toBe(false)
62
+ })
63
+
64
+ // 172.15.x.x is NOT in 172.16.0.0/12
65
+ it('allows 172.15.255.255', async () => {
66
+ expect(await isPrivateOrLoopback('172.15.255.255')).toBe(false)
67
+ })
68
+
69
+ // 172.32.x.x is NOT in 172.16.0.0/12
70
+ it('allows 172.32.0.1', async () => {
71
+ expect(await isPrivateOrLoopback('172.32.0.1')).toBe(false)
72
+ })
73
+ })