@openape/proxy 0.2.13 → 0.2.15

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.
@@ -1,129 +0,0 @@
1
- import type { OpenApeGrant, GrantType } from '@openape/core'
2
-
3
- /**
4
- * Client for the IdP's grant management API.
5
- * Creates grant requests and polls for approval.
6
- */
7
- export class GrantsClient {
8
- private idpUrl: string
9
- private agentToken: string | undefined
10
-
11
- constructor(idpUrl: string) {
12
- this.idpUrl = idpUrl.replace(/\/$/, '')
13
- }
14
-
15
- setAgentToken(token: string): void {
16
- this.agentToken = token
17
- }
18
-
19
- private headers(): Record<string, string> {
20
- const h: Record<string, string> = { 'Content-Type': 'application/json' }
21
- if (this.agentToken) {
22
- h.Authorization = `Bearer ${this.agentToken}`
23
- }
24
- return h
25
- }
26
-
27
- /**
28
- * Create a grant request on the IdP.
29
- */
30
- async requestGrant(opts: {
31
- requester: string
32
- targetHost: string
33
- audience: string
34
- grantType: GrantType
35
- permissions?: string[]
36
- reason?: string
37
- requestHash?: string
38
- duration?: number
39
- }): Promise<OpenApeGrant> {
40
- const res = await fetch(`${this.idpUrl}/api/grants`, {
41
- method: 'POST',
42
- headers: this.headers(),
43
- body: JSON.stringify({
44
- requester: opts.requester,
45
- target_host: opts.targetHost,
46
- audience: opts.audience,
47
- grant_type: opts.grantType,
48
- permissions: opts.permissions,
49
- reason: opts.reason,
50
- request_hash: opts.requestHash,
51
- duration: opts.duration,
52
- }),
53
- })
54
-
55
- if (!res.ok) {
56
- throw new Error(`Grant request failed: ${res.status} ${await res.text()}`)
57
- }
58
-
59
- return res.json() as Promise<OpenApeGrant>
60
- }
61
-
62
- /**
63
- * Poll a grant until it's approved, denied, or timeout.
64
- */
65
- async waitForApproval(
66
- grantId: string,
67
- timeoutMs: number = 300_000,
68
- pollIntervalMs: number = 2_000,
69
- ): Promise<OpenApeGrant> {
70
- const deadline = Date.now() + timeoutMs
71
-
72
- while (Date.now() < deadline) {
73
- const res = await fetch(`${this.idpUrl}/api/grants/${grantId}`, {
74
- headers: this.headers(),
75
- })
76
-
77
- if (!res.ok) {
78
- throw new Error(`Grant poll failed: ${res.status}`)
79
- }
80
-
81
- const grant = await res.json() as OpenApeGrant
82
- if (grant.status !== 'pending') {
83
- return grant
84
- }
85
-
86
- await new Promise(r => setTimeout(r, pollIntervalMs))
87
- }
88
-
89
- throw new Error(`Grant approval timed out after ${timeoutMs}ms`)
90
- }
91
-
92
- /**
93
- * Check if there's an existing approved grant for a host+audience+permissions combo.
94
- * Ignores `once` grants (they're single-use).
95
- */
96
- async findExistingGrant(
97
- requester: string,
98
- targetHost: string,
99
- audience: string,
100
- permissions?: string[],
101
- ): Promise<OpenApeGrant | null> {
102
- const params = new URLSearchParams({
103
- requester,
104
- status: 'approved',
105
- })
106
-
107
- const res = await fetch(`${this.idpUrl}/api/grants?${params}`, {
108
- headers: this.headers(),
109
- })
110
-
111
- if (!res.ok) return null
112
-
113
- const grants = await res.json() as OpenApeGrant[]
114
- const now = Math.floor(Date.now() / 1000)
115
-
116
- return grants.find((g) => {
117
- if (g.status !== 'approved') return false
118
- if (g.expires_at && g.expires_at <= now) return false
119
- if (g.request?.grant_type === 'once') return false
120
- if (g.request?.target_host !== targetHost) return false
121
- if (g.request?.audience !== audience) return false
122
- if (permissions?.length && g.request?.permissions?.length) {
123
- const grantedPerms = new Set(g.request.permissions)
124
- if (!permissions.every(p => grantedPerms.has(p))) return false
125
- }
126
- return true
127
- }) ?? null
128
- }
129
- }
package/src/index.ts DELETED
@@ -1,69 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { createServer } from 'node:http'
3
- import { parseArgs } from 'node:util'
4
- import { loadMultiAgentConfig } from './config.js'
5
- import { createNodeHandler } from './proxy.js'
6
- import { initAudit } from './audit.js'
7
-
8
- const { values } = parseArgs({
9
- options: {
10
- config: { type: 'string', short: 'c', default: 'config.toml' },
11
- 'dry-run': { type: 'boolean', default: false },
12
- 'mandatory-auth': { type: 'boolean', default: false },
13
- },
14
- })
15
-
16
- const configPath = values.config!
17
-
18
- console.log(`[openape-proxy] Loading config from ${configPath}`)
19
- const config = loadMultiAgentConfig(configPath, {
20
- mandatoryAuth: values['mandatory-auth'] || undefined,
21
- })
22
-
23
- // Init audit log
24
- initAudit(config.proxy.audit_log)
25
-
26
- if (values['dry-run']) {
27
- console.log('[openape-proxy] DRY RUN mode — logging only, not blocking')
28
- console.log('[openape-proxy] Config loaded:')
29
- console.log(` Listen: ${config.proxy.listen}`)
30
- console.log(` Default action: ${config.proxy.default_action}`)
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
- }
39
- process.exit(0)
40
- }
41
-
42
- const handler = createNodeHandler(config)
43
-
44
- const port = Number.parseInt(config.proxy.listen.split(':')[1] || '9090')
45
- const hostname = config.proxy.listen.split(':')[0] || '127.0.0.1'
46
-
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
- })
57
-
58
- // Graceful shutdown
59
- process.on('SIGINT', () => {
60
- console.log('\n[openape-proxy] Shutting down...')
61
- server.close()
62
- process.exit(0)
63
- })
64
-
65
- process.on('SIGTERM', () => {
66
- console.log('[openape-proxy] Shutting down...')
67
- server.close()
68
- process.exit(0)
69
- })
package/src/matcher.ts DELETED
@@ -1,80 +0,0 @@
1
- import type { ProxyConfig, RuleAction, RuleEntry } from './types.js'
2
-
3
- /**
4
- * Match a glob pattern against a string.
5
- * Supports * (any segment) and ** (any number of segments).
6
- */
7
- function globMatch(pattern: string, value: string): boolean {
8
- // Simple glob: convert * to regex
9
- const regex = new RegExp(
10
- `^${
11
- pattern
12
- .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex chars except *
13
- .replace(/\*\*/g, '<<<DOUBLESTAR>>>')
14
- .replace(/\*/g, '[^/]*')
15
- .replace(/<<<DOUBLESTAR>>>/g, '.*')
16
- }$`,
17
- )
18
- return regex.test(value)
19
- }
20
-
21
- function matchesRule(rule: RuleEntry, domain: string, method: string, path: string): boolean {
22
- // Domain match (supports wildcards like *.github.com)
23
- if (!globMatch(rule.domain, domain)) return false
24
-
25
- // Method match (if specified)
26
- if (rule.methods && rule.methods.length > 0) {
27
- if (!rule.methods.includes(method.toUpperCase())) return false
28
- }
29
-
30
- // Path match (if specified)
31
- if (rule.path) {
32
- if (!globMatch(rule.path, path)) return false
33
- }
34
-
35
- return true
36
- }
37
-
38
- /**
39
- * Evaluate rules in order: deny → allow → grant_required → default_action
40
- */
41
- export function evaluateRules(
42
- config: ProxyConfig,
43
- domain: string,
44
- method: string,
45
- path: string,
46
- ): RuleAction {
47
- // 1. Check deny list first
48
- for (const rule of config.deny) {
49
- if (matchesRule(rule, domain, method, path)) {
50
- return { type: 'deny', note: rule.note }
51
- }
52
- }
53
-
54
- // 2. Check allow list
55
- for (const rule of config.allow) {
56
- if (matchesRule(rule, domain, method, path)) {
57
- return { type: 'allow' }
58
- }
59
- }
60
-
61
- // 3. Check grant_required rules (most specific first)
62
- for (const rule of config.grant_required) {
63
- if (matchesRule(rule, domain, method, path)) {
64
- return { type: 'grant_required', rule }
65
- }
66
- }
67
-
68
- // 4. Default: treat as grant_required with 'once'
69
- if (config.proxy.default_action === 'block') {
70
- return { type: 'deny', note: 'No matching rule (default: block)' }
71
- }
72
-
73
- return {
74
- type: 'grant_required',
75
- rule: {
76
- domain: '*',
77
- grant_type: 'once',
78
- },
79
- }
80
- }
package/src/proxy.ts DELETED
@@ -1,401 +0,0 @@
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'
5
- import { evaluateRules } from './matcher.js'
6
- import { AuthError, verifyAgentAuth } from './auth.js'
7
- import { GrantsClient } from './grants-client.js'
8
- import { writeAudit } from './audit.js'
9
- import { isPrivateOrLoopback } from './ssrf.js'
10
- import { handleConnect } from './connect.js'
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 */
27
- export function createProxy(config: ProxyConfig) {
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
54
-
55
- return {
56
- port: Number.parseInt(config.proxy.listen.split(':')[1] || '9090'),
57
- hostname: config.proxy.listen.split(':')[0] || '127.0.0.1',
58
-
59
- async fetch(req: Request): Promise<Response> {
60
- const url = new URL(req.url)
61
- const startTime = Date.now()
62
-
63
- // Health endpoint
64
- if (url.pathname === '/healthz') {
65
- return Response.json({
66
- status: 'ok',
67
- agents: config.agents.map(a => a.email),
68
- })
69
- }
70
-
71
- // Parse target URL from the path
72
- const targetUrl = url.pathname.slice(1) + url.search
73
- let targetParsed: URL
74
- try {
75
- targetParsed = new URL(targetUrl)
76
- }
77
- catch {
78
- return new Response(
79
- 'Invalid target URL. Send requests as: http://proxy:port/https://target.com/path',
80
- { status: 400 },
81
- )
82
- }
83
-
84
- const domain = targetParsed.hostname
85
- const method = req.method
86
- const path = targetParsed.pathname
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
-
93
- // Read body once (needed for hash + forwarding)
94
- const bodyBuffer = req.body ? await req.arrayBuffer() : null
95
-
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
- }
155
-
156
- // Evaluate rules
157
- const action = evaluateRules(rulesConfig, domain, method, path)
158
-
159
- const baseAudit: Omit<AuditEntry, 'action' | 'rule'> = {
160
- ts: new Date().toISOString(),
161
- agent: effectiveEmail,
162
- domain,
163
- method,
164
- path,
165
- }
166
-
167
- // DENY
168
- if (action.type === 'deny') {
169
- writeAudit({ ...baseAudit, action: 'deny', rule: 'deny-list', grant_id: null })
170
- return new Response(`Blocked: ${action.note || 'deny rule'}`, { status: 403 })
171
- }
172
-
173
- // ALLOW (no grant needed)
174
- if (action.type === 'allow') {
175
- writeAudit({ ...baseAudit, action: 'allow', rule: 'allow-list', grant_id: null })
176
- return forwardRequest(req, targetUrl, bodyBuffer)
177
- }
178
-
179
- // GRANT REQUIRED
180
- const rule = action.rule
181
- const permissions = rule.permissions ?? [`${method.toLowerCase()}:${domain}`]
182
-
183
- // Compute request hash — binds grant to exact method + URL + body
184
- const requestHash = await computeRequestHash(method, targetUrl, bodyBuffer)
185
-
186
- // Check for existing grant
187
- const existing = await grantsClient.findExistingGrant(
188
- effectiveEmail,
189
- domain,
190
- 'proxy',
191
- permissions,
192
- ).catch(() => null)
193
-
194
- if (existing) {
195
- writeAudit({
196
- ...baseAudit,
197
- action: 'grant_approved',
198
- rule: 'standing-grant',
199
- grant_id: existing.id,
200
- request_hash: requestHash,
201
- })
202
- return forwardRequest(req, targetUrl, bodyBuffer)
203
- }
204
-
205
- // No existing grant — behavior depends on default_action
206
- if (config.proxy.default_action === 'block') {
207
- writeAudit({ ...baseAudit, action: 'deny', rule: 'no-grant (block mode)', grant_id: null })
208
- return new Response('No grant — blocked', { status: 403 })
209
- }
210
-
211
- if (config.proxy.default_action === 'request-async') {
212
- // Create grant request, return 407 immediately
213
- const grant = await grantsClient.requestGrant({
214
- requester: effectiveEmail,
215
- targetHost: domain,
216
- audience: 'proxy',
217
- grantType: rule.grant_type,
218
- permissions,
219
- reason: `${method} ${targetUrl}`,
220
- requestHash,
221
- duration: rule.duration,
222
- }).catch(() => null)
223
-
224
- writeAudit({
225
- ...baseAudit,
226
- action: 'grant_denied',
227
- rule: 'grant_required (async)',
228
- grant_id: grant?.id ?? null,
229
- })
230
-
231
- return new Response(
232
- JSON.stringify({
233
- error: 'Grant required',
234
- grant_id: grant?.id,
235
- message: 'Grant request created. Retry after approval.',
236
- }),
237
- { status: 407, headers: { 'Content-Type': 'application/json' } },
238
- )
239
- }
240
-
241
- // BLOCKING mode: create grant request and wait
242
- console.error(`[proxy] Requesting grant for ${method} ${domain}${path} — waiting for approval...`)
243
-
244
- try {
245
- const grant = await grantsClient.requestGrant({
246
- requester: effectiveEmail,
247
- targetHost: domain,
248
- audience: 'proxy',
249
- grantType: rule.grant_type,
250
- permissions,
251
- reason: `${method} ${targetUrl}`,
252
- requestHash,
253
- duration: rule.duration,
254
- })
255
-
256
- const approved = await grantsClient.waitForApproval(grant.id)
257
-
258
- const waitedMs = Date.now() - startTime
259
-
260
- if (approved.status === 'approved') {
261
- writeAudit({
262
- ...baseAudit,
263
- action: 'grant_approved',
264
- rule: 'grant_required',
265
- grant_id: approved.id,
266
- request_hash: requestHash,
267
- waited_ms: waitedMs,
268
- })
269
- return forwardRequest(req, targetUrl, bodyBuffer)
270
- }
271
-
272
- writeAudit({
273
- ...baseAudit,
274
- action: 'grant_denied',
275
- rule: 'grant_required',
276
- grant_id: approved.id,
277
- waited_ms: waitedMs,
278
- })
279
- return new Response(`Grant denied by ${approved.decided_by}`, { status: 403 })
280
- }
281
- catch (err) {
282
- const msg = err instanceof Error ? err.message : 'Unknown error'
283
- writeAudit({
284
- ...baseAudit,
285
- action: 'grant_timeout',
286
- rule: 'grant_required',
287
- error: msg,
288
- })
289
- return new Response(`Grant request failed: ${msg}`, { status: 504 })
290
- }
291
- },
292
- }
293
- }
294
-
295
- /**
296
- * Create a node:http compatible handler for use with http.createServer().
297
- * Returns both a request handler and a CONNECT handler.
298
- */
299
- export function createNodeHandler(config: MultiAgentProxyConfig): {
300
- handleRequest: (req: IncomingMessage, res: ServerResponse) => void
301
- handleConnect: (req: IncomingMessage, socket: Socket, head: Buffer) => void
302
- } {
303
- const proxy = createMultiAgentProxy(config)
304
-
305
- return {
306
- handleRequest(req: IncomingMessage, res: ServerResponse) {
307
- // Convert IncomingMessage to Request and use existing fetch logic
308
- const url = `http://${req.headers.host || 'localhost'}${req.url || '/'}`
309
- const chunks: Buffer[] = []
310
-
311
- req.on('data', (chunk: Buffer) => chunks.push(chunk))
312
- req.on('end', async () => {
313
- try {
314
- const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined
315
- const headers = new Headers()
316
- for (const [key, value] of Object.entries(req.headers)) {
317
- if (value) {
318
- headers.set(key, Array.isArray(value) ? value.join(', ') : value)
319
- }
320
- }
321
-
322
- const request = new Request(url, {
323
- method: req.method,
324
- headers,
325
- body: body && req.method !== 'GET' && req.method !== 'HEAD' ? body : undefined,
326
- duplex: 'half',
327
- } as RequestInit)
328
-
329
- const response = await proxy.fetch(request)
330
-
331
- res.writeHead(response.status, response.statusText, Object.fromEntries(response.headers))
332
- if (response.body) {
333
- const reader = response.body.getReader()
334
- const pump = async (): Promise<void> => {
335
- const { done, value } = await reader.read()
336
- if (done) {
337
- res.end()
338
- return
339
- }
340
- res.write(value)
341
- return pump()
342
- }
343
- await pump()
344
- }
345
- else {
346
- res.end()
347
- }
348
- }
349
- catch {
350
- res.writeHead(502)
351
- res.end('Proxy error')
352
- }
353
- })
354
- },
355
-
356
- handleConnect(req: IncomingMessage, socket: Socket, head: Buffer) {
357
- handleConnect(config, req, socket, head)
358
- },
359
- }
360
- }
361
-
362
- /**
363
- * Forward a request to the target URL.
364
- * Strips proxy-specific headers, preserves the rest.
365
- */
366
- async function forwardRequest(originalReq: Request, targetUrl: string, cachedBody?: ArrayBuffer | null): Promise<Response> {
367
- const headers = new Headers(originalReq.headers)
368
- // Remove proxy-specific headers
369
- headers.delete('proxy-authorization')
370
- headers.delete('proxy-connection')
371
- // Don't send host of the proxy
372
- headers.delete('host')
373
-
374
- const body = cachedBody && cachedBody.byteLength > 0 ? cachedBody : null
375
-
376
- try {
377
- const res = await fetch(targetUrl, {
378
- method: originalReq.method,
379
- headers,
380
- body,
381
- duplex: 'half',
382
- redirect: 'manual',
383
- })
384
-
385
- // Stream the response back
386
- const responseHeaders = new Headers(res.headers)
387
- // Remove hop-by-hop headers
388
- responseHeaders.delete('transfer-encoding')
389
- responseHeaders.delete('connection')
390
-
391
- return new Response(res.body, {
392
- status: res.status,
393
- statusText: res.statusText,
394
- headers: responseHeaders,
395
- })
396
- }
397
- catch (err) {
398
- const msg = err instanceof Error ? err.message : 'Upstream error'
399
- return new Response(`Proxy error: ${msg}`, { status: 502 })
400
- }
401
- }