@openape/proxy 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2025 Patrick Hofmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PLAN.md ADDED
@@ -0,0 +1,261 @@
1
+ # openape-proxy — Agent HTTP Gateway
2
+
3
+ ## Übersicht
4
+
5
+ Ein HTTP-Forward-Proxy der den gesamten ausgehenden Traffic eines Agents kontrolliert. Agents haben keinen direkten Internet-Zugang — alles läuft durch den Proxy, der Grants prüft, Requests filtert und alles loggt.
6
+
7
+ ## Architektur
8
+
9
+ ```
10
+ ┌─────────┐ HTTP_PROXY ┌──────────────┐ HTTPS ┌──────────┐
11
+ │ Agent │ ──────────────→ │ openape-proxy │ ─────────→ │ Internet │
12
+ │ │ │ │ │ │
13
+ │ (HTTP │ ← 403 oder ─── │ • Auth │ └──────────┘
14
+ │ Client) │ Grant-Request │ • Grants │
15
+ └─────────┘ │ • Audit │
16
+ │ • Rules │
17
+ └──────┬───────┘
18
+ │ Grant-API
19
+
20
+ ┌──────────────┐
21
+ │ IdP │
22
+ │ (id.office. │
23
+ │ or.at) │
24
+ └──────────────┘
25
+ ```
26
+
27
+ ## Agent-Integration
28
+
29
+ Null-Config für den Agent:
30
+ ```bash
31
+ export HTTP_PROXY=http://localhost:9090
32
+ export HTTPS_PROXY=http://localhost:9090
33
+ ```
34
+
35
+ Jeder HTTP-Client (curl, fetch, axios, Python requests, etc.) respektiert diese Env-Vars automatisch.
36
+
37
+ ## Authentifizierung
38
+
39
+ Agent identifiziert sich am Proxy via Proxy-Authorization Header:
40
+ ```
41
+ CONNECT api.github.com:443 HTTP/1.1
42
+ Proxy-Authorization: Bearer <agent-jwt>
43
+ ```
44
+
45
+ Das Agent-JWT kommt aus dem bestehenden Ed25519 Challenge-Response Flow.
46
+
47
+ ## Grant-Matching
48
+
49
+ ### Regel-Hierarchie
50
+
51
+ 1. **Deny-List** — immer blockiert (z.B. interne Netzwerke, IdP selbst)
52
+ 2. **Allow without Grant** — immer erlaubt (z.B. DNS, NTP)
53
+ 3. **Standing Grants** — Agent hat `allow_always` für Domain+Method
54
+ 4. **TTL Grants** — Agent hat zeitlich begrenzten Grant
55
+ 5. **Prompt for Grant** — kein Grant vorhanden → Optionen:
56
+ a. `block` — sofort 403
57
+ b. `request` — Grant-Request erstellen, auf Approval warten (blocking)
58
+ c. `request-async` — Grant-Request erstellen, sofort 403, Agent kann später retry
59
+
60
+ ### Regel-Konfiguration
61
+
62
+ ```toml
63
+ [proxy]
64
+ listen = "127.0.0.1:9090"
65
+ idp_url = "https://id.office.or.at"
66
+ agent_key = "/etc/apes/agent.key"
67
+ agent_id = "mini-claw@office.or.at"
68
+ default_action = "request" # block | request | request-async
69
+ audit_log = "/var/log/openape-proxy/audit.jsonl"
70
+
71
+ # Immer erlaubt (kein Grant nötig)
72
+ [[allow]]
73
+ domain = "*.openape.at"
74
+
75
+ [[allow]]
76
+ domain = "api.github.com"
77
+ methods = ["GET"]
78
+
79
+ # Immer blockiert
80
+ [[deny]]
81
+ domain = "169.254.169.254" # AWS metadata
82
+ note = "cloud metadata endpoint"
83
+
84
+ [[deny]]
85
+ domain = "*.internal"
86
+
87
+ # Grant-gesteuert (Domain + Method + Path)
88
+ [[grant_required]]
89
+ domain = "api.github.com"
90
+ path = "/repos/*/issues"
91
+ methods = ["POST"]
92
+ grant_type = "once" # jeder neue Issue braucht Genehmigung
93
+
94
+ [[grant_required]]
95
+ domain = "api.github.com"
96
+ methods = ["PUT", "DELETE", "PATCH"]
97
+ grant_type = "once" # alle anderen Writes auch
98
+
99
+ [[grant_required]]
100
+ domain = "api.openai.com"
101
+ grant_type = "always" # Standing Grant, einmal genehmigen reicht
102
+
103
+ [[grant_required]]
104
+ domain = "*" # alles andere
105
+ grant_type = "once"
106
+ ```
107
+
108
+ ## Request-Lifecycle
109
+
110
+ ```
111
+ 1. Agent sendet CONNECT api.github.com:443
112
+ 2. Proxy prüft Agent-JWT (Proxy-Authorization)
113
+ 3. Proxy extrahiert: domain=api.github.com, method=POST, path=/repos/x/issues
114
+ 4. Regel-Matching:
115
+ a. In deny-list? → 403 Blocked
116
+ b. In allow-list? → Tunnel aufbauen, weiterleiten
117
+ c. Aktiver Grant vorhanden? → Tunnel aufbauen, weiterleiten
118
+ d. Kein Grant → default_action ausführen:
119
+ - "block": 403
120
+ - "request": Grant-Request an IdP, warte auf Approval, dann weiterleiten
121
+ - "request-async": Grant-Request erstellen, 407 Proxy Authentication Required
122
+ 5. Audit-Log schreiben (JSONL)
123
+ ```
124
+
125
+ ## Audit-Log Format
126
+
127
+ ```jsonl
128
+ {"ts":"2026-02-23T22:30:00Z","agent":"mini-claw@office.or.at","action":"allow","domain":"api.github.com","method":"GET","path":"/repos/x/issues","grant_id":null,"rule":"allow-list"}
129
+ {"ts":"2026-02-23T22:30:05Z","agent":"mini-claw@office.or.at","action":"grant_approved","domain":"api.github.com","method":"POST","path":"/repos/x/issues","grant_id":"abc123","rule":"grant_required","waited_ms":12000}
130
+ {"ts":"2026-02-23T22:30:10Z","agent":"mini-claw@office.or.at","action":"denied","domain":"169.254.169.254","method":"GET","path":"/latest/meta-data","grant_id":null,"rule":"deny-list"}
131
+ ```
132
+
133
+ ## Implementierung
134
+
135
+ ### Sprache: Rust
136
+
137
+ - Wie `apes` — konsistent im Ökosystem
138
+ - Performant für Proxy-Workload (viele gleichzeitige Connections)
139
+ - `tokio` + `hyper` für async HTTP
140
+ - Kein TLS-Aufbrechen nötig: CONNECT-Tunnel ist opak (nur Domain sichtbar, nicht der Inhalt)
141
+
142
+ ### Crates
143
+
144
+ - `hyper` — HTTP Server/Client
145
+ - `tokio` — Async Runtime
146
+ - `tokio-rustls` — TLS für Upstream-Verbindung zum IdP
147
+ - `serde` + `toml` — Config
148
+ - `tracing` — Structured Logging
149
+
150
+ ### Proxy-Modus: Application-Level (nicht CONNECT-Tunnel)
151
+
152
+ Ein klassischer HTTPS CONNECT-Tunnel sieht nur die Domain — Method, Path und Body sind verschlüsselt. Das reicht nicht für granulare Kontrolle.
153
+
154
+ Stattdessen: **Application-Level Forward Proxy**. Der Agent schickt den Request unverschlüsselt an den lokalen Proxy, der Proxy macht den HTTPS-Call zum Ziel:
155
+
156
+ ```
157
+ Agent ──HTTP──→ openape-proxy ──HTTPS──→ api.github.com
158
+ (lokal, plaintext) (sieht alles) (TLS zum Internet)
159
+ ```
160
+
161
+ **Was der Proxy sieht:**
162
+ - ✅ Domain
163
+ - ✅ HTTP Method (GET, POST, DELETE, ...)
164
+ - ✅ Full Path (`/repos/x/issues`)
165
+ - ✅ Headers
166
+ - ✅ Body (kann `cmd_hash` darüber bilden!)
167
+
168
+ **Sicherheit:** Die unverschlüsselte Strecke Agent → Proxy ist `localhost` — gleiche Maschine, kein Netzwerk. Kein Risiko.
169
+
170
+ **Kompatibilität:** Standard `HTTP_PROXY` Env-Var funktioniert — jeder HTTP-Client schickt den vollen Request an den Proxy wenn die Ziel-URL HTTPS ist und `HTTP_PROXY` gesetzt ist.
171
+
172
+ Damit können Rules auf **Domain + Method + Path-Pattern** matchen:
173
+
174
+ ```toml
175
+ [[grant_required]]
176
+ domain = "api.github.com"
177
+ path = "/repos/*/issues"
178
+ methods = ["POST"]
179
+ grant_type = "once" # jeder neue Issue braucht Approval
180
+
181
+ [[allow]]
182
+ domain = "api.github.com"
183
+ path = "/repos/*/issues"
184
+ methods = ["GET"] # Issues lesen immer erlaubt
185
+ ```
186
+
187
+ ## Phasen
188
+
189
+ ### Phase 1 — MVP (1-2 Tage)
190
+ Ziel: Ein Agent kann nur ins Internet wenn ein Mensch es erlaubt hat.
191
+
192
+ - [ ] Application-Level Forward Proxy (TypeScript/Bun)
193
+ - [ ] HTTP + HTTPS Forwarding (Proxy macht TLS zum Ziel)
194
+ - [ ] WebSocket + SSE Support (Grant-Check beim Aufbau, dann Tunnel)
195
+ - [ ] Agent-Auth (JWT Verification via Proxy-Authorization)
196
+ - [ ] Config-Datei (TOML oder JSON)
197
+ - [ ] Deny/Allow/Grant-Required Regeln (Domain + Method + Path-Pattern)
198
+ - [ ] Grant-Check gegen IdP API (`@openape/grants` direkt importiert)
199
+ - [ ] Blocking Grant-Request (warte auf Approval)
200
+ - [ ] Audit-Log (JSONL)
201
+
202
+ ### Phase 2 — Multi-Agent & Caching (1-2 Tage)
203
+ Ziel: Produktionsreif für mehrere Agents auf einem Server.
204
+
205
+ - [ ] Multiple Agent Support (verschiedene Keys, verschiedene Regeln)
206
+ - [ ] Per-Agent Config Profiles
207
+ - [ ] Grant-Caching (Standing Grants + TTL-Grants lokal cachen, kein IdP-Call pro Request)
208
+ - [ ] Connection Pooling (Keep-Alive zu häufig genutzten Upstreams)
209
+ - [ ] Wildcard-Domains + Glob-Patterns in Rules (`*.github.com`, `/repos/*/issues`)
210
+ - [ ] Graceful Shutdown + Auto-Restart
211
+ - [ ] Health Endpoint (`/healthz`)
212
+ - [ ] Systemd / launchd Service Unit
213
+
214
+ ### Phase 3 — Observability & Compliance (1-2 Tage)
215
+ Ziel: Volle Transparenz über Agent-Aktivität im Web.
216
+
217
+ - [ ] Dashboard UI (Nuxt-App oder in IdP integriert)
218
+ - Welcher Agent nutzt welche Domains
219
+ - Traffic-Volumen pro Agent/Domain
220
+ - Grant-Nutzung (wie oft, wann, welche)
221
+ - Blocked Requests (was wurde verweigert)
222
+ - [ ] Request/Response-Logging (opt-in, für Audit/Compliance)
223
+ - [ ] Body-Hashing (`cmd_hash` über Request-Body für exakte Bindung)
224
+ - [ ] Metrics-Export (Prometheus/OpenTelemetry)
225
+ - [ ] Rate Limiting (pro Agent, pro Domain — Schutz vor Runaway-Agents)
226
+ - [ ] Alerting (Webhook/Telegram wenn Agent ungewöhnlich viele Requests macht)
227
+
228
+ ### Phase 4 — Ecosystem-Integration
229
+ Ziel: Nahtlose Einbindung in OpenApe und Agent-Runtimes.
230
+
231
+ - [ ] `@openape/proxy` npm Package (programmatisch starten/konfigurieren)
232
+ - [ ] Integration mit `apes` (ein Setup: Proxy + sudo gemeinsam konfiguriert)
233
+ - [ ] OpenClaw Plugin (Proxy automatisch starten wenn Agent startet)
234
+ - [ ] Agent-Runtime SDKs (Python, Go) — für Agents die nicht auf HTTP_PROXY setzen
235
+ - [ ] Auto-Discovery (Agent fragt IdP: "Welche Domains darf ich?" → Config generieren)
236
+ - [ ] Temporary Bypass Token (Admin kann zeitlich begrenzten "Proxy-Skip" geben für Debugging)
237
+
238
+ ## CLI
239
+
240
+ ```bash
241
+ # Starten
242
+ openape-proxy --config /etc/openape-proxy/config.toml
243
+
244
+ # Test-Modus (loggt nur, blockiert nicht)
245
+ openape-proxy --config config.toml --dry-run
246
+
247
+ # Status
248
+ openape-proxy --status
249
+ ```
250
+
251
+ ## Zusammenspiel mit dem Ökosystem
252
+
253
+ | Komponente | Rolle |
254
+ |---|---|
255
+ | `openape-proxy` | Kontrolliert ausgehenden Agent-Traffic |
256
+ | `apes` (openape-sudo) | Kontrolliert lokale Privilege Elevation |
257
+ | `@openape/grants` | Grant-Logik (shared zwischen Proxy und sudo) |
258
+ | `@openape/nuxt-grants` | Web-UI für Grant-Approval |
259
+ | IdP (id.office.or.at) | Zentrale Autorität für Agents + Grants |
260
+
261
+ **Zusammen:** Ein Agent kann weder lokal (apes) noch im Web (proxy) etwas tun, ohne dass ein Mensch es erlaubt hat.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @openape/proxy
2
+
3
+ OpenAPE agent HTTP gateway — forward proxy with grant-based access control.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @openape/proxy
9
+ ```
10
+
11
+ ## License
12
+
13
+ MIT
@@ -0,0 +1,48 @@
1
+ [proxy]
2
+ listen = "127.0.0.1:9090"
3
+ idp_url = "https://id.office.or.at"
4
+ agent_email = "mini-claw@office.or.at"
5
+ default_action = "request" # block | request | request-async
6
+ audit_log = "./audit.jsonl"
7
+
8
+ # Always allowed (no grant needed)
9
+ [[allow]]
10
+ domain = "*.openape.at"
11
+
12
+ [[allow]]
13
+ domain = "api.github.com"
14
+ methods = ["GET"]
15
+
16
+ [[allow]]
17
+ domain = "registry.npmjs.org"
18
+
19
+ # Always blocked
20
+ [[deny]]
21
+ domain = "169.254.169.254"
22
+ note = "AWS/cloud metadata endpoint"
23
+
24
+ [[deny]]
25
+ domain = "*.internal"
26
+ note = "internal network"
27
+
28
+ # Grant-controlled access
29
+ [[grant_required]]
30
+ domain = "api.github.com"
31
+ methods = ["POST", "PUT", "DELETE", "PATCH"]
32
+ grant_type = "once"
33
+ permissions = ["github:write"]
34
+
35
+ [[grant_required]]
36
+ domain = "api.openai.com"
37
+ grant_type = "always"
38
+ permissions = ["openai:api"]
39
+
40
+ [[grant_required]]
41
+ domain = "smtp.gmail.com"
42
+ grant_type = "once"
43
+ permissions = ["email:send"]
44
+
45
+ # Catch-all: everything else needs a one-time grant
46
+ [[grant_required]]
47
+ domain = "*"
48
+ grant_type = "once"
@@ -0,0 +1,18 @@
1
+ import antfu from '@antfu/eslint-config'
2
+
3
+ export default antfu({
4
+ typescript: true,
5
+ ignores: [
6
+ '**/dist/**',
7
+ '**/.nuxt/**',
8
+ '**/.output/**',
9
+ '**/.turbo/**',
10
+ '**/.data/**',
11
+ '**/target/**',
12
+ ],
13
+ rules: {
14
+ 'node/prefer-global/process': 'off',
15
+ 'node/prefer-global/buffer': 'off',
16
+ 'no-new': 'off',
17
+ },
18
+ })
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@openape/proxy",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "OpenAPE agent HTTP gateway — forward proxy with grant-based access control",
6
+ "author": "Patrick Hofmann",
7
+ "license": "MIT",
8
+ "bin": {
9
+ "openape-proxy": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "start": "bun run src/index.ts",
13
+ "dev": "bun run --watch src/index.ts",
14
+ "build": "tsup",
15
+ "typecheck": "tsc --noEmit",
16
+ "lint": "eslint ."
17
+ },
18
+ "dependencies": {
19
+ "@openape/core": "^0.1.0",
20
+ "@openape/grants": "^0.1.0",
21
+ "jose": "^5.9.0",
22
+ "smol-toml": "^1.3.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "tsup": "^8.3.0",
27
+ "typescript": "^5.7.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/openape-ai/proxy.git"
35
+ }
36
+ }
package/src/audit.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { appendFileSync } from 'node:fs'
2
+ import type { AuditEntry } from './types.js'
3
+
4
+ let auditPath: string | undefined
5
+
6
+ export function initAudit(path?: string): void {
7
+ auditPath = path
8
+ }
9
+
10
+ export function writeAudit(entry: AuditEntry): void {
11
+ const line = JSON.stringify(entry)
12
+
13
+ // Always log to stderr
14
+ console.error(`[audit] ${entry.action} ${entry.method} ${entry.domain}${entry.path}${entry.grant_id ? ` grant=${entry.grant_id}` : ''}`)
15
+
16
+ // Write to file if configured
17
+ if (auditPath) {
18
+ appendFileSync(auditPath, line + '\n')
19
+ }
20
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { verifyJWT, createRemoteJWKS } from '@openape/core'
2
+
3
+ export interface AgentIdentity {
4
+ email: string
5
+ act: 'agent'
6
+ }
7
+
8
+ /**
9
+ * Verify agent JWT from Proxy-Authorization header.
10
+ * Returns the agent identity or null if invalid.
11
+ */
12
+ export async function verifyAgentAuth(
13
+ authHeader: string | null,
14
+ idpUrl: string,
15
+ ): Promise<AgentIdentity | null> {
16
+ if (!authHeader) return null
17
+
18
+ const match = authHeader.match(/^Bearer\s+(.+)$/i)
19
+ if (!match) return null
20
+
21
+ const token = match[1]
22
+
23
+ try {
24
+ const jwks = createRemoteJWKS(`${idpUrl}/.well-known/jwks.json`)
25
+ const { payload } = await verifyJWT(token, jwks, { issuer: idpUrl })
26
+
27
+ if (payload.act !== 'agent' || !payload.sub) {
28
+ return null
29
+ }
30
+
31
+ return {
32
+ email: payload.sub as string,
33
+ act: 'agent',
34
+ }
35
+ } catch {
36
+ return null
37
+ }
38
+ }
package/src/config.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { parse as parseTOML } from 'smol-toml'
3
+ import type { ProxyConfig } from './types.js'
4
+
5
+ export function loadConfig(path: string): ProxyConfig {
6
+ const raw = readFileSync(path, 'utf-8')
7
+
8
+ let parsed: Record<string, unknown>
9
+ if (path.endsWith('.json')) {
10
+ parsed = JSON.parse(raw)
11
+ } else {
12
+ parsed = parseTOML(raw) as Record<string, unknown>
13
+ }
14
+
15
+ const proxy = parsed.proxy as ProxyConfig['proxy']
16
+ if (!proxy?.listen || !proxy?.idp_url || !proxy?.agent_email) {
17
+ throw new Error('Config must have [proxy] with listen, idp_url, and agent_email')
18
+ }
19
+
20
+ proxy.default_action ??= 'block'
21
+
22
+ return {
23
+ proxy,
24
+ allow: (parsed.allow ?? []) as ProxyConfig['allow'],
25
+ deny: (parsed.deny ?? []) as ProxyConfig['deny'],
26
+ grant_required: (parsed.grant_required ?? []) as ProxyConfig['grant_required'],
27
+ }
28
+ }
@@ -0,0 +1,118 @@
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
+ target: string
33
+ grantType: GrantType
34
+ permissions?: string[]
35
+ reason?: string
36
+ duration?: number
37
+ }): Promise<OpenApeGrant> {
38
+ const res = await fetch(`${this.idpUrl}/api/grants`, {
39
+ method: 'POST',
40
+ headers: this.headers(),
41
+ body: JSON.stringify({
42
+ requester: opts.requester,
43
+ target: opts.target,
44
+ grant_type: opts.grantType,
45
+ permissions: opts.permissions,
46
+ reason: opts.reason,
47
+ duration: opts.duration,
48
+ }),
49
+ })
50
+
51
+ if (!res.ok) {
52
+ throw new Error(`Grant request failed: ${res.status} ${await res.text()}`)
53
+ }
54
+
55
+ return res.json() as Promise<OpenApeGrant>
56
+ }
57
+
58
+ /**
59
+ * Poll a grant until it's approved, denied, or timeout.
60
+ */
61
+ async waitForApproval(
62
+ grantId: string,
63
+ timeoutMs: number = 300_000,
64
+ pollIntervalMs: number = 2_000,
65
+ ): Promise<OpenApeGrant> {
66
+ const deadline = Date.now() + timeoutMs
67
+
68
+ while (Date.now() < deadline) {
69
+ const res = await fetch(`${this.idpUrl}/api/grants/${grantId}`, {
70
+ headers: this.headers(),
71
+ })
72
+
73
+ if (!res.ok) {
74
+ throw new Error(`Grant poll failed: ${res.status}`)
75
+ }
76
+
77
+ const grant = await res.json() as OpenApeGrant
78
+ if (grant.status !== 'pending') {
79
+ return grant
80
+ }
81
+
82
+ await new Promise(r => setTimeout(r, pollIntervalMs))
83
+ }
84
+
85
+ throw new Error(`Grant approval timed out after ${timeoutMs}ms`)
86
+ }
87
+
88
+ /**
89
+ * Check if there's an existing approved grant for a domain+permissions combo.
90
+ */
91
+ async findExistingGrant(
92
+ requester: string,
93
+ target: string,
94
+ permissions?: string[],
95
+ ): Promise<OpenApeGrant | null> {
96
+ const params = new URLSearchParams({
97
+ requester,
98
+ target,
99
+ status: 'approved',
100
+ })
101
+ if (permissions?.length) {
102
+ params.set('permissions', permissions.join(','))
103
+ }
104
+
105
+ const res = await fetch(`${this.idpUrl}/api/grants?${params}`, {
106
+ headers: this.headers(),
107
+ })
108
+
109
+ if (!res.ok) return null
110
+
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
117
+ }
118
+ }
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from 'node:util'
3
+ import { loadConfig } from './config.js'
4
+ import { createProxy } from './proxy.js'
5
+ import { initAudit } from './audit.js'
6
+
7
+ const { values } = parseArgs({
8
+ options: {
9
+ config: { type: 'string', short: 'c', default: 'config.toml' },
10
+ 'dry-run': { type: 'boolean', default: false },
11
+ },
12
+ })
13
+
14
+ const configPath = values.config!
15
+
16
+ console.log(`[openape-proxy] Loading config from ${configPath}`)
17
+ const config = loadConfig(configPath)
18
+
19
+ // Init audit log
20
+ initAudit(config.proxy.audit_log)
21
+
22
+ if (values['dry-run']) {
23
+ 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
+ console.log('[openape-proxy] Config loaded:')
27
+ console.log(` Listen: ${config.proxy.listen}`)
28
+ console.log(` IdP: ${config.proxy.idp_url}`)
29
+ console.log(` Agent: ${config.proxy.agent_email}`)
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}`)
34
+ process.exit(0)
35
+ }
36
+
37
+ const proxy = createProxy(config)
38
+
39
+ const server = Bun.serve({
40
+ port: proxy.port,
41
+ hostname: proxy.hostname,
42
+ fetch: proxy.fetch,
43
+ })
44
+
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}`)
50
+
51
+ // Graceful shutdown
52
+ process.on('SIGINT', () => {
53
+ console.log('\n[openape-proxy] Shutting down...')
54
+ server.stop()
55
+ process.exit(0)
56
+ })
57
+
58
+ process.on('SIGTERM', () => {
59
+ console.log('[openape-proxy] Shutting down...')
60
+ server.stop()
61
+ process.exit(0)
62
+ })
package/src/matcher.ts ADDED
@@ -0,0 +1,80 @@
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 ADDED
@@ -0,0 +1,215 @@
1
+ import type { ProxyConfig, AuditEntry } from './types.js'
2
+ import { evaluateRules } from './matcher.js'
3
+ import { verifyAgentAuth } from './auth.js'
4
+ import { GrantsClient } from './grants-client.js'
5
+ import { writeAudit } from './audit.js'
6
+
7
+ export function createProxy(config: ProxyConfig) {
8
+ const grantsClient = new GrantsClient(config.proxy.idp_url)
9
+
10
+ return {
11
+ port: parseInt(config.proxy.listen.split(':')[1] || '9090'),
12
+ hostname: config.proxy.listen.split(':')[0] || '127.0.0.1',
13
+
14
+ async fetch(req: Request): Promise<Response> {
15
+ const url = new URL(req.url)
16
+ const startTime = Date.now()
17
+
18
+ // Health endpoint
19
+ if (url.pathname === '/healthz') {
20
+ return Response.json({ status: 'ok', agent: config.proxy.agent_email })
21
+ }
22
+
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 /
26
+ const targetUrl = url.pathname.slice(1) + url.search
27
+ let targetParsed: URL
28
+ try {
29
+ targetParsed = new URL(targetUrl)
30
+ } catch {
31
+ return new Response(
32
+ 'Invalid target URL. Send requests as: http://proxy:port/https://target.com/path',
33
+ { status: 400 },
34
+ )
35
+ }
36
+
37
+ const domain = targetParsed.hostname
38
+ const method = req.method
39
+ const path = targetParsed.pathname
40
+
41
+ // Verify agent identity
42
+ const agent = await verifyAgentAuth(
43
+ req.headers.get('proxy-authorization'),
44
+ config.proxy.idp_url,
45
+ )
46
+
47
+ const agentEmail = agent?.email ?? config.proxy.agent_email
48
+
49
+ // Evaluate rules
50
+ const action = evaluateRules(config, domain, method, path)
51
+
52
+ const baseAudit: Omit<AuditEntry, 'action' | 'rule'> = {
53
+ ts: new Date().toISOString(),
54
+ agent: agentEmail,
55
+ domain,
56
+ method,
57
+ path,
58
+ }
59
+
60
+ // DENY
61
+ if (action.type === 'deny') {
62
+ writeAudit({ ...baseAudit, action: 'deny', rule: 'deny-list', grant_id: null })
63
+ return new Response(`Blocked: ${action.note || 'deny rule'}`, { status: 403 })
64
+ }
65
+
66
+ // ALLOW (no grant needed)
67
+ if (action.type === 'allow') {
68
+ writeAudit({ ...baseAudit, action: 'allow', rule: 'allow-list', grant_id: null })
69
+ return forwardRequest(req, targetUrl)
70
+ }
71
+
72
+ // GRANT REQUIRED
73
+ const rule = action.rule
74
+ const permissions = rule.permissions ?? [`${method.toLowerCase()}:${domain}`]
75
+
76
+ // Check for existing grant
77
+ const existing = await grantsClient.findExistingGrant(
78
+ agentEmail,
79
+ domain,
80
+ permissions,
81
+ ).catch(() => null)
82
+
83
+ if (existing) {
84
+ writeAudit({
85
+ ...baseAudit,
86
+ action: 'grant_approved',
87
+ rule: 'standing-grant',
88
+ grant_id: existing.id,
89
+ })
90
+ return forwardRequest(req, targetUrl)
91
+ }
92
+
93
+ // No existing grant — behavior depends on default_action
94
+ if (config.proxy.default_action === 'block') {
95
+ writeAudit({ ...baseAudit, action: 'deny', rule: 'no-grant (block mode)', grant_id: null })
96
+ return new Response('No grant — blocked', { status: 403 })
97
+ }
98
+
99
+ if (config.proxy.default_action === 'request-async') {
100
+ // Create grant request, return 407 immediately
101
+ const grant = await grantsClient.requestGrant({
102
+ requester: agentEmail,
103
+ target: domain,
104
+ grantType: rule.grant_type,
105
+ permissions,
106
+ reason: `${method} ${targetParsed.pathname}`,
107
+ duration: rule.duration,
108
+ }).catch(() => null)
109
+
110
+ writeAudit({
111
+ ...baseAudit,
112
+ action: 'grant_denied',
113
+ rule: 'grant_required (async)',
114
+ grant_id: grant?.id ?? null,
115
+ })
116
+
117
+ return new Response(
118
+ JSON.stringify({
119
+ error: 'Grant required',
120
+ grant_id: grant?.id,
121
+ message: 'Grant request created. Retry after approval.',
122
+ }),
123
+ { status: 407, headers: { 'Content-Type': 'application/json' } },
124
+ )
125
+ }
126
+
127
+ // BLOCKING mode: create grant request and wait
128
+ console.error(`[proxy] Requesting grant for ${method} ${domain}${path} — waiting for approval...`)
129
+
130
+ try {
131
+ const grant = await grantsClient.requestGrant({
132
+ requester: agentEmail,
133
+ target: domain,
134
+ grantType: rule.grant_type,
135
+ permissions,
136
+ reason: `${method} ${targetParsed.pathname}`,
137
+ duration: rule.duration,
138
+ })
139
+
140
+ const approved = await grantsClient.waitForApproval(grant.id)
141
+
142
+ const waitedMs = Date.now() - startTime
143
+
144
+ if (approved.status === 'approved') {
145
+ writeAudit({
146
+ ...baseAudit,
147
+ action: 'grant_approved',
148
+ rule: 'grant_required',
149
+ grant_id: approved.id,
150
+ waited_ms: waitedMs,
151
+ })
152
+ return forwardRequest(req, targetUrl)
153
+ }
154
+
155
+ writeAudit({
156
+ ...baseAudit,
157
+ action: 'grant_denied',
158
+ rule: 'grant_required',
159
+ grant_id: approved.id,
160
+ waited_ms: waitedMs,
161
+ })
162
+ return new Response(`Grant denied by ${approved.decided_by}`, { status: 403 })
163
+
164
+ } catch (err) {
165
+ const msg = err instanceof Error ? err.message : 'Unknown error'
166
+ writeAudit({
167
+ ...baseAudit,
168
+ action: 'grant_timeout',
169
+ rule: 'grant_required',
170
+ error: msg,
171
+ })
172
+ return new Response(`Grant request failed: ${msg}`, { status: 504 })
173
+ }
174
+ },
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Forward a request to the target URL.
180
+ * Strips proxy-specific headers, preserves the rest.
181
+ */
182
+ async function forwardRequest(originalReq: Request, targetUrl: string): Promise<Response> {
183
+ const headers = new Headers(originalReq.headers)
184
+ // Remove proxy-specific headers
185
+ headers.delete('proxy-authorization')
186
+ headers.delete('proxy-connection')
187
+ // Don't send host of the proxy
188
+ headers.delete('host')
189
+
190
+ try {
191
+ const res = await fetch(targetUrl, {
192
+ method: originalReq.method,
193
+ headers,
194
+ body: originalReq.body,
195
+ // @ts-expect-error Bun supports duplex
196
+ duplex: 'half',
197
+ redirect: 'manual',
198
+ })
199
+
200
+ // Stream the response back
201
+ const responseHeaders = new Headers(res.headers)
202
+ // Remove hop-by-hop headers
203
+ responseHeaders.delete('transfer-encoding')
204
+ responseHeaders.delete('connection')
205
+
206
+ return new Response(res.body, {
207
+ status: res.status,
208
+ statusText: res.statusText,
209
+ headers: responseHeaders,
210
+ })
211
+ } catch (err) {
212
+ const msg = err instanceof Error ? err.message : 'Upstream error'
213
+ return new Response(`Proxy error: ${msg}`, { status: 502 })
214
+ }
215
+ }
package/src/types.ts ADDED
@@ -0,0 +1,44 @@
1
+ /** Proxy configuration (parsed from TOML/JSON) */
2
+ export interface ProxyConfig {
3
+ proxy: {
4
+ listen: string
5
+ idp_url: string
6
+ agent_email: string
7
+ default_action: 'block' | 'request' | 'request-async'
8
+ audit_log?: string
9
+ }
10
+ allow: RuleEntry[]
11
+ deny: RuleEntry[]
12
+ grant_required: GrantRuleEntry[]
13
+ }
14
+
15
+ export interface RuleEntry {
16
+ domain: string
17
+ methods?: string[]
18
+ path?: string
19
+ note?: string
20
+ }
21
+
22
+ export interface GrantRuleEntry extends RuleEntry {
23
+ grant_type: 'once' | 'timed' | 'always'
24
+ permissions?: string[]
25
+ duration?: number
26
+ }
27
+
28
+ export type RuleAction =
29
+ | { type: 'allow' }
30
+ | { type: 'deny'; note?: string }
31
+ | { type: 'grant_required'; rule: GrantRuleEntry }
32
+
33
+ export interface AuditEntry {
34
+ ts: string
35
+ agent: string
36
+ action: 'allow' | 'deny' | 'grant_approved' | 'grant_denied' | 'grant_timeout' | 'error'
37
+ domain: string
38
+ method: string
39
+ path: string
40
+ grant_id?: string | null
41
+ rule: string
42
+ waited_ms?: number
43
+ error?: string
44
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "outDir": "dist",
17
+ "rootDir": "src"
18
+ },
19
+ "include": ["src"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm', 'cjs'],
6
+ dts: false,
7
+ clean: true,
8
+ sourcemap: true,
9
+ })