@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 +21 -0
- package/PLAN.md +261 -0
- package/README.md +13 -0
- package/config.example.toml +48 -0
- package/eslint.config.mjs +18 -0
- package/package.json +36 -0
- package/src/audit.ts +20 -0
- package/src/auth.ts +38 -0
- package/src/config.ts +28 -0
- package/src/grants-client.ts +118 -0
- package/src/index.ts +62 -0
- package/src/matcher.ts +80 -0
- package/src/proxy.ts +215 -0
- package/src/types.ts +44 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +9 -0
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,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
|
+
}
|