@getaegis/cli 0.8.0 → 0.9.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.
Files changed (242) hide show
  1. package/README.md +43 -14
  2. package/dist/agent/agent.d.ts +98 -0
  3. package/dist/agent/agent.d.ts.map +1 -0
  4. package/dist/agent/agent.js +212 -0
  5. package/dist/agent/agent.js.map +1 -0
  6. package/dist/agent/index.d.ts +3 -0
  7. package/dist/agent/index.d.ts.map +1 -0
  8. package/dist/agent/index.js +2 -0
  9. package/dist/agent/index.js.map +1 -0
  10. package/dist/cli/auth.d.ts +19 -0
  11. package/dist/cli/auth.d.ts.map +1 -0
  12. package/dist/cli/auth.js +44 -0
  13. package/dist/cli/auth.js.map +1 -0
  14. package/dist/cli/commands/agent.d.ts +6 -0
  15. package/dist/cli/commands/agent.d.ts.map +1 -0
  16. package/dist/cli/commands/agent.js +241 -0
  17. package/dist/cli/commands/agent.js.map +1 -0
  18. package/dist/cli/commands/config.d.ts +6 -0
  19. package/dist/cli/commands/config.d.ts.map +1 -0
  20. package/dist/cli/commands/config.js +125 -0
  21. package/dist/cli/commands/config.js.map +1 -0
  22. package/dist/cli/commands/dashboard.d.ts +6 -0
  23. package/dist/cli/commands/dashboard.d.ts.map +1 -0
  24. package/dist/cli/commands/dashboard.js +195 -0
  25. package/dist/cli/commands/dashboard.js.map +1 -0
  26. package/dist/cli/commands/db.d.ts +6 -0
  27. package/dist/cli/commands/db.d.ts.map +1 -0
  28. package/dist/cli/commands/db.js +139 -0
  29. package/dist/cli/commands/db.js.map +1 -0
  30. package/dist/cli/commands/doctor.d.ts +6 -0
  31. package/dist/cli/commands/doctor.d.ts.map +1 -0
  32. package/dist/cli/commands/doctor.js +39 -0
  33. package/dist/cli/commands/doctor.js.map +1 -0
  34. package/dist/cli/commands/gate.d.ts +6 -0
  35. package/dist/cli/commands/gate.d.ts.map +1 -0
  36. package/dist/cli/commands/gate.js +202 -0
  37. package/dist/cli/commands/gate.js.map +1 -0
  38. package/dist/cli/commands/init.d.ts +6 -0
  39. package/dist/cli/commands/init.d.ts.map +1 -0
  40. package/dist/cli/commands/init.js +175 -0
  41. package/dist/cli/commands/init.js.map +1 -0
  42. package/dist/cli/commands/key.d.ts +6 -0
  43. package/dist/cli/commands/key.d.ts.map +1 -0
  44. package/dist/cli/commands/key.js +49 -0
  45. package/dist/cli/commands/key.js.map +1 -0
  46. package/dist/cli/commands/ledger.d.ts +6 -0
  47. package/dist/cli/commands/ledger.d.ts.map +1 -0
  48. package/dist/cli/commands/ledger.js +140 -0
  49. package/dist/cli/commands/ledger.js.map +1 -0
  50. package/dist/cli/commands/mcp.d.ts +6 -0
  51. package/dist/cli/commands/mcp.d.ts.map +1 -0
  52. package/dist/cli/commands/mcp.js +224 -0
  53. package/dist/cli/commands/mcp.js.map +1 -0
  54. package/dist/cli/commands/policy.d.ts +6 -0
  55. package/dist/cli/commands/policy.d.ts.map +1 -0
  56. package/dist/cli/commands/policy.js +126 -0
  57. package/dist/cli/commands/policy.js.map +1 -0
  58. package/dist/cli/commands/user.d.ts +6 -0
  59. package/dist/cli/commands/user.d.ts.map +1 -0
  60. package/dist/cli/commands/user.js +150 -0
  61. package/dist/cli/commands/user.js.map +1 -0
  62. package/dist/cli/commands/vault-manager.d.ts +6 -0
  63. package/dist/cli/commands/vault-manager.d.ts.map +1 -0
  64. package/dist/cli/commands/vault-manager.js +240 -0
  65. package/dist/cli/commands/vault-manager.js.map +1 -0
  66. package/dist/cli/commands/vault.d.ts +6 -0
  67. package/dist/cli/commands/vault.d.ts.map +1 -0
  68. package/dist/cli/commands/vault.js +265 -0
  69. package/dist/cli/commands/vault.js.map +1 -0
  70. package/dist/cli/commands/webhook.d.ts +6 -0
  71. package/dist/cli/commands/webhook.d.ts.map +1 -0
  72. package/dist/cli/commands/webhook.js +151 -0
  73. package/dist/cli/commands/webhook.js.map +1 -0
  74. package/dist/cli/helpers.d.ts +12 -0
  75. package/dist/cli/helpers.d.ts.map +1 -0
  76. package/dist/cli/helpers.js +61 -0
  77. package/dist/cli/helpers.js.map +1 -0
  78. package/dist/cli/index.d.ts +19 -0
  79. package/dist/cli/index.d.ts.map +1 -0
  80. package/dist/cli/index.js +19 -0
  81. package/dist/cli/index.js.map +1 -0
  82. package/dist/cli/validation.d.ts +37 -0
  83. package/dist/cli/validation.d.ts.map +1 -0
  84. package/dist/cli/validation.js +104 -0
  85. package/dist/cli/validation.js.map +1 -0
  86. package/dist/cli.d.ts +3 -0
  87. package/dist/cli.d.ts.map +1 -0
  88. package/dist/cli.js +37 -0
  89. package/dist/cli.js.map +1 -0
  90. package/dist/config.d.ts +120 -0
  91. package/dist/config.d.ts.map +1 -0
  92. package/dist/config.js +401 -0
  93. package/dist/config.js.map +1 -0
  94. package/dist/dashboard/dashboard-server.d.ts +95 -0
  95. package/dist/dashboard/dashboard-server.d.ts.map +1 -0
  96. package/dist/dashboard/dashboard-server.js +329 -0
  97. package/dist/dashboard/dashboard-server.js.map +1 -0
  98. package/dist/dashboard/index.d.ts +3 -0
  99. package/dist/dashboard/index.d.ts.map +1 -0
  100. package/dist/dashboard/index.js +2 -0
  101. package/dist/dashboard/index.js.map +1 -0
  102. package/dist/dashboard/public/assets/index-Cah0_BKk.js +148 -0
  103. package/dist/dashboard/public/assets/index-CpMruPNh.css +1 -0
  104. package/dist/dashboard/public/favicon.svg +6 -0
  105. package/dist/dashboard/public/index.html +14 -0
  106. package/dist/db.d.ts +27 -0
  107. package/dist/db.d.ts.map +1 -0
  108. package/dist/db.js +209 -0
  109. package/dist/db.js.map +1 -0
  110. package/dist/doctor.d.ts +37 -0
  111. package/dist/doctor.d.ts.map +1 -0
  112. package/dist/doctor.js +216 -0
  113. package/dist/doctor.js.map +1 -0
  114. package/dist/gate/body-inspector.d.ts +31 -0
  115. package/dist/gate/body-inspector.d.ts.map +1 -0
  116. package/dist/gate/body-inspector.js +193 -0
  117. package/dist/gate/body-inspector.js.map +1 -0
  118. package/dist/gate/gate.d.ts +190 -0
  119. package/dist/gate/gate.d.ts.map +1 -0
  120. package/dist/gate/gate.js +1243 -0
  121. package/dist/gate/gate.js.map +1 -0
  122. package/dist/gate/index.d.ts +7 -0
  123. package/dist/gate/index.d.ts.map +1 -0
  124. package/dist/gate/index.js +4 -0
  125. package/dist/gate/index.js.map +1 -0
  126. package/dist/gate/rate-limiter.d.ts +59 -0
  127. package/dist/gate/rate-limiter.d.ts.map +1 -0
  128. package/dist/gate/rate-limiter.js +120 -0
  129. package/dist/gate/rate-limiter.js.map +1 -0
  130. package/dist/index.d.ts +28 -0
  131. package/dist/index.d.ts.map +1 -0
  132. package/dist/index.js +17 -0
  133. package/dist/index.js.map +1 -0
  134. package/dist/key-storage/credential-manager-windows.d.ts +19 -0
  135. package/dist/key-storage/credential-manager-windows.d.ts.map +1 -0
  136. package/dist/key-storage/credential-manager-windows.js +87 -0
  137. package/dist/key-storage/credential-manager-windows.js.map +1 -0
  138. package/dist/key-storage/file-fallback.d.ts +21 -0
  139. package/dist/key-storage/file-fallback.d.ts.map +1 -0
  140. package/dist/key-storage/file-fallback.js +62 -0
  141. package/dist/key-storage/file-fallback.js.map +1 -0
  142. package/dist/key-storage/index.d.ts +6 -0
  143. package/dist/key-storage/index.d.ts.map +1 -0
  144. package/dist/key-storage/index.js +6 -0
  145. package/dist/key-storage/index.js.map +1 -0
  146. package/dist/key-storage/key-storage.d.ts +41 -0
  147. package/dist/key-storage/key-storage.d.ts.map +1 -0
  148. package/dist/key-storage/key-storage.js +70 -0
  149. package/dist/key-storage/key-storage.js.map +1 -0
  150. package/dist/key-storage/keychain-macos.d.ts +19 -0
  151. package/dist/key-storage/keychain-macos.d.ts.map +1 -0
  152. package/dist/key-storage/keychain-macos.js +51 -0
  153. package/dist/key-storage/keychain-macos.js.map +1 -0
  154. package/dist/key-storage/secret-service-linux.d.ts +19 -0
  155. package/dist/key-storage/secret-service-linux.d.ts.map +1 -0
  156. package/dist/key-storage/secret-service-linux.js +55 -0
  157. package/dist/key-storage/secret-service-linux.js.map +1 -0
  158. package/dist/ledger/index.d.ts +3 -0
  159. package/dist/ledger/index.d.ts.map +1 -0
  160. package/dist/ledger/index.js +2 -0
  161. package/dist/ledger/index.js.map +1 -0
  162. package/dist/ledger/ledger.d.ts +98 -0
  163. package/dist/ledger/ledger.d.ts.map +1 -0
  164. package/dist/ledger/ledger.js +145 -0
  165. package/dist/ledger/ledger.js.map +1 -0
  166. package/dist/logger/index.d.ts +3 -0
  167. package/dist/logger/index.d.ts.map +1 -0
  168. package/dist/logger/index.js +2 -0
  169. package/dist/logger/index.js.map +1 -0
  170. package/dist/logger/logger.d.ts +58 -0
  171. package/dist/logger/logger.d.ts.map +1 -0
  172. package/dist/logger/logger.js +201 -0
  173. package/dist/logger/logger.js.map +1 -0
  174. package/dist/mcp/index.d.ts +3 -0
  175. package/dist/mcp/index.d.ts.map +1 -0
  176. package/dist/mcp/index.js +2 -0
  177. package/dist/mcp/index.js.map +1 -0
  178. package/dist/mcp/mcp-server.d.ts +130 -0
  179. package/dist/mcp/mcp-server.d.ts.map +1 -0
  180. package/dist/mcp/mcp-server.js +775 -0
  181. package/dist/mcp/mcp-server.js.map +1 -0
  182. package/dist/metrics/index.d.ts +3 -0
  183. package/dist/metrics/index.d.ts.map +1 -0
  184. package/dist/metrics/index.js +2 -0
  185. package/dist/metrics/index.js.map +1 -0
  186. package/dist/metrics/metrics.d.ts +88 -0
  187. package/dist/metrics/metrics.d.ts.map +1 -0
  188. package/dist/metrics/metrics.js +179 -0
  189. package/dist/metrics/metrics.js.map +1 -0
  190. package/dist/policy/index.d.ts +3 -0
  191. package/dist/policy/index.d.ts.map +1 -0
  192. package/dist/policy/index.js +2 -0
  193. package/dist/policy/index.js.map +1 -0
  194. package/dist/policy/policy.d.ts +119 -0
  195. package/dist/policy/policy.d.ts.map +1 -0
  196. package/dist/policy/policy.js +426 -0
  197. package/dist/policy/policy.js.map +1 -0
  198. package/dist/user/index.d.ts +3 -0
  199. package/dist/user/index.d.ts.map +1 -0
  200. package/dist/user/index.js +2 -0
  201. package/dist/user/index.js.map +1 -0
  202. package/dist/user/user.d.ts +102 -0
  203. package/dist/user/user.d.ts.map +1 -0
  204. package/dist/user/user.js +216 -0
  205. package/dist/user/user.js.map +1 -0
  206. package/dist/vault/crypto.d.ts +28 -0
  207. package/dist/vault/crypto.d.ts.map +1 -0
  208. package/dist/vault/crypto.js +44 -0
  209. package/dist/vault/crypto.js.map +1 -0
  210. package/dist/vault/index.d.ts +10 -0
  211. package/dist/vault/index.d.ts.map +1 -0
  212. package/dist/vault/index.js +6 -0
  213. package/dist/vault/index.js.map +1 -0
  214. package/dist/vault/seal.d.ts +68 -0
  215. package/dist/vault/seal.d.ts.map +1 -0
  216. package/dist/vault/seal.js +110 -0
  217. package/dist/vault/seal.js.map +1 -0
  218. package/dist/vault/shamir.d.ts +33 -0
  219. package/dist/vault/shamir.d.ts.map +1 -0
  220. package/dist/vault/shamir.js +174 -0
  221. package/dist/vault/shamir.js.map +1 -0
  222. package/dist/vault/vault-manager.d.ts +62 -0
  223. package/dist/vault/vault-manager.d.ts.map +1 -0
  224. package/dist/vault/vault-manager.js +151 -0
  225. package/dist/vault/vault-manager.js.map +1 -0
  226. package/dist/vault/vault.d.ts +104 -0
  227. package/dist/vault/vault.d.ts.map +1 -0
  228. package/dist/vault/vault.js +259 -0
  229. package/dist/vault/vault.js.map +1 -0
  230. package/dist/version.d.ts +3 -0
  231. package/dist/version.d.ts.map +1 -0
  232. package/dist/version.js +18 -0
  233. package/dist/version.js.map +1 -0
  234. package/dist/webhook/index.d.ts +3 -0
  235. package/dist/webhook/index.d.ts.map +1 -0
  236. package/dist/webhook/index.js +2 -0
  237. package/dist/webhook/index.js.map +1 -0
  238. package/dist/webhook/webhook.d.ts +114 -0
  239. package/dist/webhook/webhook.d.ts.map +1 -0
  240. package/dist/webhook/webhook.js +269 -0
  241. package/dist/webhook/webhook.js.map +1 -0
  242. package/package.json +12 -6
@@ -0,0 +1,1243 @@
1
+ import * as fs from 'node:fs';
2
+ import * as http from 'node:http';
3
+ import * as https from 'node:https';
4
+ import { createLogger, generateRequestId } from '../logger/index.js';
5
+ import { buildPolicyMap, evaluatePolicy, loadPoliciesFromDirectory } from '../policy/index.js';
6
+ import { VERSION } from '../version.js';
7
+ import { BodyInspector } from './body-inspector.js';
8
+ import { parseRateLimit, RateLimiter } from './rate-limiter.js';
9
+ // ─── Scope → HTTP Method Mapping ─────────────────────────────────────────────
10
+ // Credential scopes restrict which HTTP methods are permitted.
11
+ // "read" → GET, HEAD, OPTIONS (safe/idempotent methods)
12
+ // "write" → POST, PUT, PATCH, DELETE (state-changing methods)
13
+ // "*" → all methods (default)
14
+ const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
15
+ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
16
+ /**
17
+ * Check whether an HTTP method is permitted by a credential's scopes.
18
+ * Returns true if the method is allowed, false if blocked.
19
+ */
20
+ export function methodMatchesScope(method, scopes) {
21
+ if (scopes.includes('*'))
22
+ return true;
23
+ const upper = method.toUpperCase();
24
+ if (scopes.includes('read') && READ_METHODS.has(upper))
25
+ return true;
26
+ if (scopes.includes('write') && WRITE_METHODS.has(upper))
27
+ return true;
28
+ return false;
29
+ }
30
+ /**
31
+ * Aegis Gate — HTTP proxy that sits between an AI agent and external APIs.
32
+ *
33
+ * The agent makes requests to: http://localhost:{port}/{service}/actual/api/path
34
+ * Gate resolves the service → looks up credential → injects auth → forwards to real API.
35
+ *
36
+ * The agent NEVER sees the credential.
37
+ */
38
+ export class Gate {
39
+ server = null;
40
+ vault;
41
+ ledger;
42
+ port;
43
+ logger;
44
+ tlsOptions;
45
+ testUpstream;
46
+ rateLimiter;
47
+ bodyInspector;
48
+ shuttingDown = false;
49
+ activeRequests = 0;
50
+ shutdownTimeoutMs;
51
+ agentRegistry;
52
+ requireAgentAuth;
53
+ policyMap;
54
+ policyMode;
55
+ policyDir;
56
+ policyWatcher;
57
+ metrics;
58
+ webhooks;
59
+ onAuditEntry;
60
+ maxBodySize;
61
+ requestTimeout;
62
+ maxConnectionsPerAgent;
63
+ /** Tracks in-flight request count per agent (keyed by agent ID). */
64
+ agentConnections = new Map();
65
+ /** Tracks upstream service failures for circuit breaker (keyed by service name). */
66
+ circuitBreaker = new Map();
67
+ /** Dedicated HTTP agent for outbound proxy requests (connection pooling with keep-alive). */
68
+ httpAgent;
69
+ /** Dedicated HTTPS agent for outbound proxy requests (connection pooling with keep-alive + TLS session reuse). */
70
+ httpsAgent;
71
+ constructor(options) {
72
+ this.vault = options.vault;
73
+ this.ledger = options.ledger;
74
+ this.port = options.port;
75
+ this.logger = createLogger({
76
+ module: 'gate',
77
+ level: options.logLevel ?? 'info',
78
+ });
79
+ this.tlsOptions = options.tls;
80
+ this.testUpstream = options._testUpstream;
81
+ this.rateLimiter = new RateLimiter();
82
+ this.bodyInspector = new BodyInspector();
83
+ this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? 10_000;
84
+ this.agentRegistry = options.agentRegistry;
85
+ this.requireAgentAuth = options.requireAgentAuth ?? true;
86
+ this.policyMode = options.policyMode ?? 'enforce';
87
+ this.policyDir = options.policyDir;
88
+ this.metrics = options.metrics;
89
+ this.webhooks = options.webhooks;
90
+ this.onAuditEntry = options.onAuditEntry;
91
+ this.maxBodySize = options.maxBodySize ?? 1_048_576; // 1 MB
92
+ this.requestTimeout = options.requestTimeout ?? 30_000; // 30 seconds
93
+ this.maxConnectionsPerAgent = options.maxConnectionsPerAgent ?? 50;
94
+ // Dedicated connection pool agents for outbound proxy requests.
95
+ // keepAlive: true — reuse TCP connections across requests (avoids TCP+TLS handshake per request)
96
+ // maxSockets: 256 — cap concurrent sockets per upstream host (prevents socket exhaustion)
97
+ // maxFreeSockets: 64 — keep up to 64 idle sockets per host in the pool
98
+ // scheduling: 'lifo' — reuse most-recent socket first (reduces stale connection risk)
99
+ // timeout: 60s — idle socket timeout (close sockets unused for 60s)
100
+ const poolOptions = {
101
+ keepAlive: true,
102
+ maxSockets: 256,
103
+ maxFreeSockets: 64,
104
+ scheduling: 'lifo',
105
+ timeout: 60_000,
106
+ };
107
+ this.httpAgent = new http.Agent(poolOptions);
108
+ this.httpsAgent = new https.Agent(poolOptions);
109
+ // Load policies from disk or test injection
110
+ if (options._testPolicies) {
111
+ this.policyMap = options._testPolicies;
112
+ }
113
+ else if (options.policyDir) {
114
+ this.policyMap = this.loadPolicies(options.policyDir);
115
+ }
116
+ else {
117
+ this.policyMap = new Map();
118
+ }
119
+ }
120
+ /**
121
+ * Start the Gate proxy server.
122
+ */
123
+ /**
124
+ * Whether the Gate is running with TLS.
125
+ */
126
+ get isTls() {
127
+ return this.tlsOptions !== undefined;
128
+ }
129
+ /**
130
+ * Whether policies are loaded and active.
131
+ */
132
+ get hasPolicies() {
133
+ return this.policyMap.size > 0;
134
+ }
135
+ /**
136
+ * The current policy enforcement mode.
137
+ */
138
+ get currentPolicyMode() {
139
+ return this.policyMode;
140
+ }
141
+ /**
142
+ * Load policies from a directory.
143
+ */
144
+ loadPolicies(dir) {
145
+ try {
146
+ const results = loadPoliciesFromDirectory(dir);
147
+ const map = buildPolicyMap(results);
148
+ const valid = results.filter((r) => r.valid).length;
149
+ const invalid = results.filter((r) => !r.valid).length;
150
+ this.logger.info({ dir, valid, invalid }, `Loaded ${valid} policy file(s)`);
151
+ return map;
152
+ }
153
+ catch (err) {
154
+ this.logger.warn({ dir, err }, 'Failed to load policies');
155
+ return new Map();
156
+ }
157
+ }
158
+ /**
159
+ * Reload policies from the configured directory.
160
+ * Called on file system changes for hot-reload.
161
+ */
162
+ reloadPolicies() {
163
+ if (!this.policyDir)
164
+ return;
165
+ this.policyMap = this.loadPolicies(this.policyDir);
166
+ this.logger.info({ count: this.policyMap.size }, 'Policies reloaded');
167
+ }
168
+ /**
169
+ * Start watching the policy directory for changes (hot-reload).
170
+ * Debounces changes to avoid rapid reloads.
171
+ */
172
+ startPolicyWatcher() {
173
+ if (!this.policyDir)
174
+ return;
175
+ let debounceTimer = null;
176
+ try {
177
+ this.policyWatcher = fs.watch(this.policyDir, { persistent: false }, () => {
178
+ if (debounceTimer)
179
+ clearTimeout(debounceTimer);
180
+ debounceTimer = setTimeout(() => {
181
+ this.logger.info('Policy files changed — reloading');
182
+ this.reloadPolicies();
183
+ }, 500);
184
+ });
185
+ }
186
+ catch (err) {
187
+ this.logger.warn({ err }, 'Could not watch policy directory');
188
+ }
189
+ }
190
+ start() {
191
+ return new Promise((resolve, reject) => {
192
+ const handler = (req, res) => {
193
+ this.handleRequest(req, res);
194
+ };
195
+ if (this.tlsOptions) {
196
+ // Validate TLS files before attempting to create the server
197
+ for (const [label, filePath] of [
198
+ ['certificate', this.tlsOptions.certPath],
199
+ ['private key', this.tlsOptions.keyPath],
200
+ ]) {
201
+ if (!fs.existsSync(filePath)) {
202
+ throw new Error(`TLS ${label} file not found: ${filePath}`);
203
+ }
204
+ const content = fs.readFileSync(filePath, 'utf-8');
205
+ const expectedMarker = label === 'certificate' ? '-----BEGIN CERTIFICATE-----' : '-----BEGIN';
206
+ if (!content.includes(expectedMarker)) {
207
+ throw new Error(`TLS ${label} file is not valid PEM format: ${filePath}\n` +
208
+ ` Expected a PEM file starting with "${expectedMarker}".\n` +
209
+ ` Generate a self-signed cert with:\n` +
210
+ ` openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'`);
211
+ }
212
+ }
213
+ const cert = fs.readFileSync(this.tlsOptions.certPath);
214
+ const key = fs.readFileSync(this.tlsOptions.keyPath);
215
+ this.server = https.createServer({ cert, key }, handler);
216
+ }
217
+ else {
218
+ this.server = http.createServer(handler);
219
+ this.logger.warn('Gate is running without TLS — credentials are transmitted in cleartext on localhost');
220
+ }
221
+ // Handle server errors (e.g. EADDRINUSE) before they become unhandled events
222
+ this.server.once('error', (err) => {
223
+ if (err.code === 'EADDRINUSE') {
224
+ reject(new Error(`Port ${this.port} is already in use.\n` +
225
+ ` Another process (or another Aegis instance) is using this port.\n` +
226
+ ` Either stop that process or use a different port:\n` +
227
+ ` aegis gate --port <port>`));
228
+ }
229
+ else {
230
+ reject(err);
231
+ }
232
+ });
233
+ this.server.listen(this.port, () => {
234
+ // Update port in case 0 was passed (OS-assigned)
235
+ const addr = this.server?.address();
236
+ if (addr && typeof addr === 'object') {
237
+ this.port = addr.port;
238
+ }
239
+ // Set server-level idle timeout to defend against slowloris attacks.
240
+ // Only covers idle socket time — outbound proxy has its own `timeout` option.
241
+ // Provide a custom callback so the default auto-destroy doesn't kill sockets
242
+ // that are legitimately waiting for upstream responses.
243
+ this.server?.setTimeout(this.requestTimeout, (socket) => {
244
+ socket.destroy();
245
+ });
246
+ const protocol = this.tlsOptions ? 'https' : 'http';
247
+ this.logger.info({ protocol, port: this.port }, `Aegis Gate listening on ${protocol}://localhost:${this.port}`);
248
+ this.logger.info({ port: this.port }, 'Agent requests → localhost:{port}/{service}/path → credential injected → forwarded');
249
+ if (this.policyMap.size > 0) {
250
+ this.logger.info({ count: this.policyMap.size, mode: this.policyMode }, 'Policy engine active');
251
+ this.startPolicyWatcher();
252
+ }
253
+ resolve();
254
+ });
255
+ });
256
+ }
257
+ /**
258
+ * The port the server is listening on (may differ from constructor if 0 was passed).
259
+ */
260
+ get listeningPort() {
261
+ return this.port;
262
+ }
263
+ /**
264
+ * Stop the Gate proxy server gracefully.
265
+ *
266
+ * 1. Sets `shuttingDown = true` — new requests receive 503 Service Unavailable.
267
+ * 2. Waits for in-flight requests to complete (up to `shutdownTimeoutMs`).
268
+ * 3. Closes the server socket and returns.
269
+ *
270
+ * During the drain phase the server still accepts connections so clients get
271
+ * a clean 503 rather than a connection-refused error.
272
+ */
273
+ stop() {
274
+ this.shuttingDown = true;
275
+ // Stop watching policy files
276
+ if (this.policyWatcher) {
277
+ this.policyWatcher.close();
278
+ this.policyWatcher = undefined;
279
+ }
280
+ return new Promise((resolve) => {
281
+ if (!this.server) {
282
+ this.httpAgent.destroy();
283
+ this.httpsAgent.destroy();
284
+ resolve({ drained: true, activeAtClose: 0 });
285
+ return;
286
+ }
287
+ const finish = (drained) => {
288
+ const activeAtClose = this.activeRequests;
289
+ // Destroy connection pool agents — close all keep-alive sockets
290
+ this.httpAgent.destroy();
291
+ this.httpsAgent.destroy();
292
+ if (!drained) {
293
+ // Force-destroy remaining connections so server.close() can complete
294
+ this.server?.closeAllConnections();
295
+ }
296
+ this.server?.close(() => {
297
+ // shuttingDown stays true — a stopped Gate is permanently shut down.
298
+ // Outbound proxy requests may still error asynchronously; keeping
299
+ // shuttingDown=true ensures error handlers skip Ledger writes.
300
+ this.server = null;
301
+ resolve({ drained, activeAtClose });
302
+ });
303
+ };
304
+ // If no active requests, shut down immediately
305
+ if (this.activeRequests === 0) {
306
+ this.logger.info('No in-flight requests — shutting down immediately');
307
+ finish(true);
308
+ return;
309
+ }
310
+ this.logger.info({ activeRequests: this.activeRequests }, 'Draining in-flight requests');
311
+ // Poll for active requests to reach 0
312
+ const drainInterval = setInterval(() => {
313
+ if (this.activeRequests === 0) {
314
+ clearInterval(drainInterval);
315
+ clearTimeout(forceTimeout);
316
+ this.logger.info('All in-flight requests drained — shutdown complete');
317
+ finish(true);
318
+ }
319
+ }, 50);
320
+ // Force-close after timeout
321
+ const forceTimeout = setTimeout(() => {
322
+ clearInterval(drainInterval);
323
+ this.logger.warn({ timeoutMs: this.shutdownTimeoutMs, activeRequests: this.activeRequests }, 'Shutdown timeout — forcing close');
324
+ finish(false);
325
+ }, this.shutdownTimeoutMs);
326
+ });
327
+ }
328
+ /**
329
+ * Whether the Gate is currently shutting down (draining in-flight requests).
330
+ */
331
+ get isShuttingDown() {
332
+ return this.shuttingDown;
333
+ }
334
+ /**
335
+ * The number of currently in-flight requests.
336
+ */
337
+ get inFlightRequests() {
338
+ return this.activeRequests;
339
+ }
340
+ async handleRequest(req, res) {
341
+ // Reject new requests during shutdown
342
+ if (this.shuttingDown) {
343
+ res.writeHead(503, { 'Content-Type': 'application/json' });
344
+ res.end(`${JSON.stringify({ error: 'Aegis Gate is shutting down' })}\n`);
345
+ return;
346
+ }
347
+ this.activeRequests++;
348
+ const decrementActive = () => {
349
+ this.activeRequests = Math.max(0, this.activeRequests - 1);
350
+ };
351
+ // Ensure we decrement when the response finishes (or the connection drops)
352
+ res.on('close', decrementActive);
353
+ // Create a per-request child logger with a correlation ID
354
+ const requestId = generateRequestId();
355
+ const reqLog = this.logger.child({ requestId });
356
+ try {
357
+ // Parse path segments from the RAW URL (not via new URL()) to prevent
358
+ // percent-encoded path traversal. new URL() normalises %2e%2e → .. and
359
+ // resolves /../, which would let an agent escape service routing and
360
+ // reach internal /_aegis/* endpoints via /service/%2e%2e/_aegis/health.
361
+ const rawUrl = req.url ?? '/';
362
+ const qIdx = rawUrl.indexOf('?');
363
+ const rawPath = qIdx >= 0 ? rawUrl.slice(0, qIdx) : rawUrl;
364
+ const rawQuery = qIdx >= 0 ? rawUrl.slice(qIdx) : '';
365
+ const pathParts = rawPath.split('/').filter(Boolean);
366
+ // Explicit path traversal guard: reject any segment that decodes to
367
+ // ".." or "." — belt-and-suspenders defense on top of raw parsing.
368
+ // The service lookup would naturally reject these, but blocking here
369
+ // produces a clear 400 and audit trail entry for traversal attempts.
370
+ const hasTraversal = pathParts.some((seg) => {
371
+ if (seg === '..' || seg === '.')
372
+ return true;
373
+ try {
374
+ const decoded = decodeURIComponent(seg);
375
+ return decoded === '..' || decoded === '.';
376
+ }
377
+ catch {
378
+ return false;
379
+ }
380
+ });
381
+ if (hasTraversal) {
382
+ reqLog.warn({ path: rawPath }, 'Blocked: path traversal attempt');
383
+ res.writeHead(400, { 'Content-Type': 'application/json' });
384
+ res.end(`${JSON.stringify({ error: 'Path traversal detected' })}\n`);
385
+ return;
386
+ }
387
+ // Health check
388
+ if (pathParts[0] === '_aegis' && pathParts[1] === 'health') {
389
+ res.writeHead(200, { 'Content-Type': 'application/json' });
390
+ res.end(`${JSON.stringify({ status: 'ok', version: VERSION })}\n`);
391
+ return;
392
+ }
393
+ // Stats endpoint
394
+ if (pathParts[0] === '_aegis' && pathParts[1] === 'stats') {
395
+ const stats = this.ledger.stats();
396
+ res.writeHead(200, { 'Content-Type': 'application/json' });
397
+ res.end(`${JSON.stringify(stats)}\n`);
398
+ return;
399
+ }
400
+ // Prometheus metrics endpoint
401
+ if (pathParts[0] === '_aegis' && pathParts[1] === 'metrics') {
402
+ if (this.metrics) {
403
+ const metricsOutput = await this.metrics.getMetricsOutput();
404
+ res.writeHead(200, { 'Content-Type': this.metrics.getContentType() });
405
+ res.end(metricsOutput);
406
+ }
407
+ else {
408
+ res.writeHead(404, { 'Content-Type': 'application/json' });
409
+ res.end(`${JSON.stringify({ error: 'Metrics not enabled' })}\n`);
410
+ }
411
+ return;
412
+ }
413
+ // ─── Agent Authentication ──────────────────────────────────────
414
+ // If agent auth is required, validate the X-Aegis-Agent token.
415
+ // The authenticated agent identity flows through to scoping,
416
+ // rate limiting, and audit trail entries.
417
+ let authenticatedAgent;
418
+ if (this.requireAgentAuth && this.agentRegistry) {
419
+ const agentToken = req.headers['x-aegis-agent'];
420
+ if (!agentToken) {
421
+ this.auditBlocked({
422
+ service: pathParts[0] ?? 'unknown',
423
+ targetDomain: 'unknown',
424
+ method: req.method ?? 'GET',
425
+ path: req.url ?? '/',
426
+ reason: 'Missing X-Aegis-Agent header — agent authentication required',
427
+ });
428
+ reqLog.warn({ service: pathParts[0] ?? 'unknown' }, 'Blocked: missing X-Aegis-Agent header');
429
+ this.metrics?.recordBlocked(pathParts[0] ?? 'unknown', 'agent_auth_missing');
430
+ this.webhooks?.emit('agent_auth_failure', {
431
+ service: pathParts[0] ?? 'unknown',
432
+ reason: 'Missing X-Aegis-Agent header',
433
+ method: req.method ?? 'GET',
434
+ path: req.url ?? '/',
435
+ });
436
+ res.writeHead(401, { 'Content-Type': 'application/json' });
437
+ res.end(`${JSON.stringify({
438
+ error: 'Agent authentication required',
439
+ hint: "Include X-Aegis-Agent header with your agent token. Run 'aegis agent add --name my-agent' to create one.",
440
+ })}\n`);
441
+ return;
442
+ }
443
+ const agent = this.agentRegistry.validateToken(agentToken);
444
+ if (!agent) {
445
+ this.auditBlocked({
446
+ service: pathParts[0] ?? 'unknown',
447
+ targetDomain: 'unknown',
448
+ method: req.method ?? 'GET',
449
+ path: req.url ?? '/',
450
+ reason: 'Invalid agent token in X-Aegis-Agent header',
451
+ });
452
+ reqLog.warn({ service: pathParts[0] ?? 'unknown' }, 'Blocked: invalid agent token');
453
+ this.metrics?.recordBlocked(pathParts[0] ?? 'unknown', 'agent_auth_invalid');
454
+ this.webhooks?.emit('agent_auth_failure', {
455
+ service: pathParts[0] ?? 'unknown',
456
+ reason: 'Invalid agent token',
457
+ method: req.method ?? 'GET',
458
+ path: req.url ?? '/',
459
+ });
460
+ res.writeHead(401, { 'Content-Type': 'application/json' });
461
+ res.end(`${JSON.stringify({
462
+ error: 'Invalid agent token',
463
+ hint: 'Check your X-Aegis-Agent token or register a new agent with: aegis agent add',
464
+ })}\n`);
465
+ return;
466
+ }
467
+ authenticatedAgent = agent;
468
+ reqLog.debug({ agent: agent.name, tokenPrefix: agent.tokenPrefix }, 'Authenticated agent');
469
+ }
470
+ else if (this.agentRegistry) {
471
+ // Agent auth not required, but optionally identify the agent if a token is provided
472
+ const agentToken = req.headers['x-aegis-agent'];
473
+ if (agentToken) {
474
+ const agent = this.agentRegistry.validateToken(agentToken);
475
+ if (agent) {
476
+ authenticatedAgent = agent;
477
+ reqLog.debug({ agent: agent.name, tokenPrefix: agent.tokenPrefix }, 'Identified agent');
478
+ }
479
+ }
480
+ }
481
+ // ─── Per-Agent Connection Limits ─────────────────────────────────
482
+ // Prevent a single agent from exhausting all available connections
483
+ // by tracking in-flight requests per agent.
484
+ if (authenticatedAgent) {
485
+ const agentId = authenticatedAgent.id;
486
+ const currentConnections = this.agentConnections.get(agentId) ?? 0;
487
+ if (currentConnections >= this.maxConnectionsPerAgent) {
488
+ reqLog.warn({
489
+ agent: authenticatedAgent.name,
490
+ current: currentConnections,
491
+ max: this.maxConnectionsPerAgent,
492
+ }, 'Blocked: per-agent connection limit exceeded');
493
+ this.metrics?.recordBlocked(pathParts[0] ?? 'unknown', 'agent_connection_limit', authenticatedAgent.name);
494
+ res.writeHead(429, { 'Content-Type': 'application/json' });
495
+ res.end(`${JSON.stringify({
496
+ error: 'Too many concurrent requests for this agent',
497
+ agent: authenticatedAgent.name,
498
+ limit: this.maxConnectionsPerAgent,
499
+ hint: 'Wait for in-flight requests to complete or increase gate.max_connections_per_agent',
500
+ })}\n`);
501
+ return;
502
+ }
503
+ this.agentConnections.set(agentId, currentConnections + 1);
504
+ // Decrement on response close
505
+ res.on('close', () => {
506
+ const count = this.agentConnections.get(agentId) ?? 1;
507
+ if (count <= 1) {
508
+ this.agentConnections.delete(agentId);
509
+ }
510
+ else {
511
+ this.agentConnections.set(agentId, count - 1);
512
+ }
513
+ });
514
+ }
515
+ // Route format: /{service}/rest/of/the/path
516
+ const serviceName = pathParts[0];
517
+ if (!serviceName) {
518
+ res.writeHead(400, { 'Content-Type': 'application/json' });
519
+ res.end(`${JSON.stringify({
520
+ error: 'Missing service name',
521
+ usage: 'GET http://localhost:{port}/{service}/api/path',
522
+ })}\n`);
523
+ return;
524
+ }
525
+ // Look up credential for this service
526
+ const credential = this.vault.getByService(serviceName);
527
+ if (!credential) {
528
+ this.auditBlocked({
529
+ service: serviceName,
530
+ targetDomain: 'unknown',
531
+ method: req.method ?? 'GET',
532
+ path: req.url ?? '/',
533
+ reason: `No credential found for service: ${serviceName}`,
534
+ agentName: authenticatedAgent?.name,
535
+ agentTokenPrefix: authenticatedAgent?.tokenPrefix,
536
+ });
537
+ reqLog.warn({ service: serviceName }, 'Blocked: no credential found');
538
+ this.metrics?.recordBlocked(serviceName, 'no_credential', authenticatedAgent?.name);
539
+ this.webhooks?.emit('blocked_request', {
540
+ service: serviceName,
541
+ reason: 'no_credential',
542
+ method: req.method ?? 'GET',
543
+ path: req.url ?? '/',
544
+ agent: authenticatedAgent?.name,
545
+ });
546
+ res.writeHead(404, { 'Content-Type': 'application/json' });
547
+ res.end(`${JSON.stringify({
548
+ error: `No credential registered for service: ${serviceName}`,
549
+ hint: `Run: aegis vault add --name ${serviceName} --service ${serviceName} --secret YOUR_KEY --domains api.example.com`,
550
+ })}\n`);
551
+ return;
552
+ }
553
+ // TTL enforcement: reject expired credentials
554
+ if (this.vault.isExpired(credential)) {
555
+ this.auditBlocked({
556
+ service: serviceName,
557
+ targetDomain: credential.domains[0] ?? 'unknown',
558
+ method: req.method ?? 'GET',
559
+ path: req.url ?? '/',
560
+ reason: `Credential "${credential.name}" expired at ${credential.expiresAt}`,
561
+ agentName: authenticatedAgent?.name,
562
+ agentTokenPrefix: authenticatedAgent?.tokenPrefix,
563
+ });
564
+ reqLog.warn({ credential: credential.name, expiredAt: credential.expiresAt }, 'Blocked: credential expired');
565
+ this.metrics?.recordBlocked(serviceName, 'credential_expired', authenticatedAgent?.name);
566
+ this.webhooks?.emit('credential_expiry', {
567
+ service: serviceName,
568
+ credential: credential.name,
569
+ expiredAt: credential.expiresAt,
570
+ agent: authenticatedAgent?.name,
571
+ });
572
+ res.writeHead(403, { 'Content-Type': 'application/json' });
573
+ res.end(`${JSON.stringify({
574
+ error: 'Credential has expired',
575
+ credential: credential.name,
576
+ expiredAt: credential.expiresAt,
577
+ hint: `Rotate with: aegis vault rotate --name ${credential.name} --secret NEW_SECRET`,
578
+ })}\n`);
579
+ return;
580
+ }
581
+ // ─── Credential Scope Enforcement ────────────────────────────────
582
+ // If the credential has scopes (read/write/*), verify the HTTP
583
+ // method is permitted. read → GET/HEAD/OPTIONS, write → POST/PUT/PATCH/DELETE.
584
+ const reqMethod = req.method ?? 'GET';
585
+ if (!methodMatchesScope(reqMethod, credential.scopes)) {
586
+ const scopeList = credential.scopes.join(', ');
587
+ this.auditBlocked({
588
+ service: serviceName,
589
+ targetDomain: credential.domains[0] ?? 'unknown',
590
+ method: reqMethod,
591
+ path: req.url ?? '/',
592
+ reason: `Method "${reqMethod}" not permitted by credential scopes [${scopeList}]`,
593
+ agentName: authenticatedAgent?.name,
594
+ agentTokenPrefix: authenticatedAgent?.tokenPrefix,
595
+ });
596
+ reqLog.warn({ credential: credential.name, method: reqMethod, scopes: credential.scopes }, 'Blocked: credential scope violation');
597
+ this.metrics?.recordBlocked(serviceName, 'credential_scope', authenticatedAgent?.name);
598
+ this.webhooks?.emit('blocked_request', {
599
+ service: serviceName,
600
+ reason: 'credential_scope',
601
+ credential: credential.name,
602
+ method: reqMethod,
603
+ scopes: credential.scopes,
604
+ agent: authenticatedAgent?.name,
605
+ path: req.url ?? '/',
606
+ });
607
+ res.writeHead(403, { 'Content-Type': 'application/json' });
608
+ res.end(`${JSON.stringify({
609
+ error: 'Method not permitted by credential scopes',
610
+ method: reqMethod,
611
+ scopes: credential.scopes,
612
+ hint: `Update scopes with: aegis vault update --name ${credential.name} --scopes ${scopeList},${reqMethod === 'GET' ? 'read' : 'write'}`,
613
+ })}\n`);
614
+ return;
615
+ }
616
+ // ─── Agent Credential Scoping ──────────────────────────────────
617
+ // If agent auth is required, verify this agent has been granted
618
+ // access to the requested credential.
619
+ if (authenticatedAgent && this.requireAgentAuth && this.agentRegistry) {
620
+ if (!this.agentRegistry.hasAccess(authenticatedAgent.id, credential.id)) {
621
+ this.auditBlocked({
622
+ service: serviceName,
623
+ targetDomain: credential.domains[0] ?? 'unknown',
624
+ method: req.method ?? 'GET',
625
+ path: req.url ?? '/',
626
+ reason: `Agent "${authenticatedAgent.name}" not granted access to credential "${credential.name}"`,
627
+ agentName: authenticatedAgent.name,
628
+ agentTokenPrefix: authenticatedAgent.tokenPrefix,
629
+ });
630
+ reqLog.warn({ agent: authenticatedAgent.name, credential: credential.name }, 'Blocked: agent not scoped to credential');
631
+ this.metrics?.recordBlocked(serviceName, 'agent_scope', authenticatedAgent.name);
632
+ this.webhooks?.emit('blocked_request', {
633
+ service: serviceName,
634
+ reason: 'agent_scope',
635
+ agent: authenticatedAgent.name,
636
+ credential: credential.name,
637
+ method: req.method ?? 'GET',
638
+ path: req.url ?? '/',
639
+ });
640
+ res.writeHead(403, { 'Content-Type': 'application/json' });
641
+ res.end(`${JSON.stringify({
642
+ error: 'Agent not granted access to this credential',
643
+ agent: authenticatedAgent.name,
644
+ credential: credential.name,
645
+ hint: `Grant access with: aegis agent grant --agent ${authenticatedAgent.name} --credential ${credential.name}`,
646
+ })}\n`);
647
+ return;
648
+ }
649
+ }
650
+ // ─── Policy Evaluation ─────────────────────────────────────────
651
+ // If the authenticated agent has a policy, evaluate the request
652
+ // against it. Policy checks: service access, method restrictions,
653
+ // path restrictions, time-of-day windows.
654
+ if (authenticatedAgent && this.policyMap.size > 0) {
655
+ const agentPolicy = this.policyMap.get(authenticatedAgent.name);
656
+ if (agentPolicy) {
657
+ const remainingPathForPolicy = `/${pathParts.slice(1).join('/')}`;
658
+ const evaluation = evaluatePolicy(agentPolicy, {
659
+ service: serviceName,
660
+ method: req.method ?? 'GET',
661
+ path: remainingPathForPolicy,
662
+ });
663
+ if (!evaluation.allowed) {
664
+ const reason = `Policy violation: ${evaluation.reason}`;
665
+ if (this.policyMode === 'enforce') {
666
+ this.auditBlocked({
667
+ service: serviceName,
668
+ targetDomain: credential.domains[0] ?? 'unknown',
669
+ method: req.method ?? 'GET',
670
+ path: req.url ?? '/',
671
+ reason,
672
+ agentName: authenticatedAgent.name,
673
+ agentTokenPrefix: authenticatedAgent.tokenPrefix,
674
+ });
675
+ reqLog.warn({
676
+ agent: authenticatedAgent.name,
677
+ violation: evaluation.violation,
678
+ reason: evaluation.reason,
679
+ }, 'Blocked: policy violation');
680
+ this.metrics?.recordBlocked(serviceName, 'policy_violation', authenticatedAgent.name);
681
+ this.webhooks?.emit('blocked_request', {
682
+ service: serviceName,
683
+ reason: 'policy_violation',
684
+ agent: authenticatedAgent.name,
685
+ violation: evaluation.violation,
686
+ detail: evaluation.reason,
687
+ method: req.method ?? 'GET',
688
+ path: req.url ?? '/',
689
+ });
690
+ res.writeHead(403, { 'Content-Type': 'application/json' });
691
+ res.end(`${JSON.stringify({
692
+ error: 'Policy violation',
693
+ agent: authenticatedAgent.name,
694
+ violation: evaluation.violation,
695
+ reason: evaluation.reason,
696
+ hint: "Update the agent's policy file to permit this request",
697
+ })}\n`);
698
+ return;
699
+ }
700
+ // Dry-run mode: log the would-be violation but allow the request through
701
+ this.auditBlocked({
702
+ service: serviceName,
703
+ targetDomain: credential.domains[0] ?? 'unknown',
704
+ method: req.method ?? 'GET',
705
+ path: req.url ?? '/',
706
+ reason: `POLICY_DRY_RUN: ${evaluation.reason}`,
707
+ agentName: authenticatedAgent.name,
708
+ agentTokenPrefix: authenticatedAgent.tokenPrefix,
709
+ });
710
+ reqLog.info({ agent: authenticatedAgent.name, reason: evaluation.reason }, 'Dry-run: would block');
711
+ }
712
+ // ─── Policy Rate Limiting ────────────────────────────────
713
+ // If the matched rule has a rateLimit, enforce it using the
714
+ // shared rate limiter. Keyed per agent+service for isolation.
715
+ if (evaluation.allowed && evaluation.matchedRule?.rateLimit) {
716
+ const policyRateLimitStr = evaluation.matchedRule.rateLimit;
717
+ let policyParsedLimit;
718
+ try {
719
+ policyParsedLimit = parseRateLimit(policyRateLimitStr);
720
+ }
721
+ catch {
722
+ reqLog.error({ agent: authenticatedAgent.name, rateLimit: policyRateLimitStr }, 'Invalid policy rate limit config');
723
+ policyParsedLimit = { maxRequests: Number.MAX_SAFE_INTEGER, windowMs: 60_000 };
724
+ }
725
+ const policyRateKey = `policy:${authenticatedAgent.name}:${serviceName}`;
726
+ const policyRateResult = this.rateLimiter.check(policyRateKey, policyParsedLimit);
727
+ if (!policyRateResult.allowed) {
728
+ const reason = `Policy rate limit exceeded: ${policyRateLimitStr} (retry after ${policyRateResult.retryAfterSeconds}s)`;
729
+ if (this.policyMode === 'enforce') {
730
+ this.auditBlocked({
731
+ service: serviceName,
732
+ targetDomain: credential.domains[0] ?? 'unknown',
733
+ method: req.method ?? 'GET',
734
+ path: req.url ?? '/',
735
+ reason,
736
+ agentName: authenticatedAgent.name,
737
+ agentTokenPrefix: authenticatedAgent.tokenPrefix,
738
+ });
739
+ reqLog.warn({
740
+ agent: authenticatedAgent.name,
741
+ limit: policyRateLimitStr,
742
+ retryAfter: policyRateResult.retryAfterSeconds,
743
+ }, 'Blocked: policy rate limit exceeded');
744
+ this.metrics?.recordBlocked(serviceName, 'policy_rate_limit', authenticatedAgent.name);
745
+ this.webhooks?.emit('rate_limit_exceeded', {
746
+ service: serviceName,
747
+ type: 'policy',
748
+ agent: authenticatedAgent.name,
749
+ limit: policyRateLimitStr,
750
+ retryAfter: policyRateResult.retryAfterSeconds,
751
+ });
752
+ res.writeHead(429, {
753
+ 'Content-Type': 'application/json',
754
+ 'Retry-After': String(policyRateResult.retryAfterSeconds),
755
+ });
756
+ res.end(`${JSON.stringify({
757
+ error: 'Policy rate limit exceeded',
758
+ agent: authenticatedAgent.name,
759
+ limit: policyRateLimitStr,
760
+ retryAfter: policyRateResult.retryAfterSeconds,
761
+ })}\n`);
762
+ return;
763
+ }
764
+ // Dry-run mode: log but allow through
765
+ this.auditBlocked({
766
+ service: serviceName,
767
+ targetDomain: credential.domains[0] ?? 'unknown',
768
+ method: req.method ?? 'GET',
769
+ path: req.url ?? '/',
770
+ reason: `POLICY_DRY_RUN: ${reason}`,
771
+ agentName: authenticatedAgent.name,
772
+ agentTokenPrefix: authenticatedAgent.tokenPrefix,
773
+ });
774
+ reqLog.info({ agent: authenticatedAgent.name, limit: policyRateLimitStr }, 'Dry-run: would block (policy rate limit)');
775
+ }
776
+ }
777
+ }
778
+ }
779
+ // ─── Per-Agent Rate Limiting ───────────────────────────────────
780
+ // If the authenticated agent has a rate limit, check it before
781
+ // the credential rate limit. More restrictive limit wins.
782
+ if (authenticatedAgent?.rateLimit) {
783
+ let agentParsedLimit;
784
+ try {
785
+ agentParsedLimit = parseRateLimit(authenticatedAgent.rateLimit);
786
+ }
787
+ catch {
788
+ reqLog.error({ agent: authenticatedAgent.name, rateLimit: authenticatedAgent.rateLimit }, 'Invalid agent rate limit config');
789
+ agentParsedLimit = { maxRequests: Number.MAX_SAFE_INTEGER, windowMs: 60_000 };
790
+ }
791
+ const agentResult = this.rateLimiter.check(`agent:${authenticatedAgent.id}`, agentParsedLimit);
792
+ if (!agentResult.allowed) {
793
+ this.auditBlocked({
794
+ service: serviceName,
795
+ targetDomain: credential.domains[0] ?? 'unknown',
796
+ method: req.method ?? 'GET',
797
+ path: req.url ?? '/',
798
+ reason: `Agent rate limit exceeded: ${authenticatedAgent.rateLimit} (retry after ${agentResult.retryAfterSeconds}s)`,
799
+ agentName: authenticatedAgent.name,
800
+ agentTokenPrefix: authenticatedAgent.tokenPrefix,
801
+ });
802
+ reqLog.warn({
803
+ agent: authenticatedAgent.name,
804
+ limit: authenticatedAgent.rateLimit,
805
+ retryAfter: agentResult.retryAfterSeconds,
806
+ }, 'Blocked: agent rate limit exceeded');
807
+ this.metrics?.recordBlocked(serviceName, 'agent_rate_limit', authenticatedAgent.name);
808
+ this.webhooks?.emit('rate_limit_exceeded', {
809
+ service: serviceName,
810
+ type: 'agent',
811
+ agent: authenticatedAgent.name,
812
+ limit: authenticatedAgent.rateLimit,
813
+ retryAfter: agentResult.retryAfterSeconds,
814
+ });
815
+ res.writeHead(429, {
816
+ 'Content-Type': 'application/json',
817
+ 'Retry-After': String(agentResult.retryAfterSeconds),
818
+ });
819
+ res.end(`${JSON.stringify({
820
+ error: 'Agent rate limit exceeded',
821
+ agent: authenticatedAgent.name,
822
+ limit: authenticatedAgent.rateLimit,
823
+ retryAfter: agentResult.retryAfterSeconds,
824
+ })}\n`);
825
+ return;
826
+ }
827
+ }
828
+ // Rate limit enforcement: check per-credential rate limit
829
+ if (credential.rateLimit) {
830
+ let parsedLimit;
831
+ try {
832
+ parsedLimit = parseRateLimit(credential.rateLimit);
833
+ }
834
+ catch {
835
+ reqLog.error({ credential: credential.name, rateLimit: credential.rateLimit }, 'Invalid credential rate limit config');
836
+ parsedLimit = { maxRequests: Number.MAX_SAFE_INTEGER, windowMs: 60_000 };
837
+ }
838
+ const result = this.rateLimiter.check(credential.id, parsedLimit);
839
+ if (!result.allowed) {
840
+ this.auditBlocked({
841
+ service: serviceName,
842
+ targetDomain: credential.domains[0] ?? 'unknown',
843
+ method: req.method ?? 'GET',
844
+ path: req.url ?? '/',
845
+ reason: `Rate limit exceeded: ${credential.rateLimit} (retry after ${result.retryAfterSeconds}s)`,
846
+ agentName: authenticatedAgent?.name,
847
+ agentTokenPrefix: authenticatedAgent?.tokenPrefix,
848
+ });
849
+ reqLog.warn({
850
+ credential: credential.name,
851
+ limit: credential.rateLimit,
852
+ retryAfter: result.retryAfterSeconds,
853
+ }, 'Blocked: credential rate limit exceeded');
854
+ this.metrics?.recordBlocked(serviceName, 'credential_rate_limit', authenticatedAgent?.name);
855
+ this.webhooks?.emit('rate_limit_exceeded', {
856
+ service: serviceName,
857
+ type: 'credential',
858
+ credential: credential.name,
859
+ limit: credential.rateLimit,
860
+ retryAfter: result.retryAfterSeconds,
861
+ agent: authenticatedAgent?.name,
862
+ });
863
+ res.writeHead(429, {
864
+ 'Content-Type': 'application/json',
865
+ 'Retry-After': String(result.retryAfterSeconds),
866
+ });
867
+ res.end(`${JSON.stringify({
868
+ error: 'Rate limit exceeded',
869
+ credential: credential.name,
870
+ limit: credential.rateLimit,
871
+ retryAfter: result.retryAfterSeconds,
872
+ })}\n`);
873
+ return;
874
+ }
875
+ }
876
+ // Determine target domain:
877
+ // 1. Agent can request a specific domain via X-Target-Host header
878
+ // 2. Otherwise, fall back to the credential's primary (first) domain
879
+ const agentRequestedHost = req.headers['x-target-host'] ?? undefined;
880
+ const targetDomain = agentRequestedHost ?? credential.domains[0];
881
+ const remainingPath = `/${pathParts.slice(1).join('/')}`;
882
+ const query = rawQuery;
883
+ // Domain guard: verify target domain is in the credential's allowlist
884
+ // This is the core security boundary — blocks agents from exfiltrating
885
+ // credentials to domains not explicitly approved.
886
+ if (!this.vault.domainMatches(targetDomain, credential.domains)) {
887
+ this.auditBlocked({
888
+ service: serviceName,
889
+ targetDomain,
890
+ method: req.method ?? 'GET',
891
+ path: remainingPath,
892
+ reason: `Domain "${targetDomain}" not in allowlist [${credential.domains.join(', ')}]`,
893
+ agentName: authenticatedAgent?.name,
894
+ agentTokenPrefix: authenticatedAgent?.tokenPrefix,
895
+ });
896
+ reqLog.warn({ targetDomain, allowed: credential.domains }, 'Blocked: domain guard rejected');
897
+ this.metrics?.recordBlocked(serviceName, 'domain_guard', authenticatedAgent?.name);
898
+ this.webhooks?.emit('blocked_request', {
899
+ service: serviceName,
900
+ reason: 'domain_guard',
901
+ targetDomain,
902
+ allowedDomains: credential.domains,
903
+ agent: authenticatedAgent?.name,
904
+ method: req.method ?? 'GET',
905
+ path: remainingPath,
906
+ });
907
+ res.writeHead(403, { 'Content-Type': 'application/json' });
908
+ res.end(`${JSON.stringify({
909
+ error: 'Domain not in credential allowlist',
910
+ requested: targetDomain,
911
+ allowed: credential.domains,
912
+ })}\n`);
913
+ return;
914
+ }
915
+ // Build outbound headers — strip any auth the agent tried to add
916
+ const outboundHeaders = {};
917
+ for (const [key, value] of Object.entries(req.headers)) {
918
+ const lower = key.toLowerCase();
919
+ // Strip auth headers the agent might have tried to include
920
+ if (lower === 'authorization' || lower === 'x-api-key')
921
+ continue;
922
+ // Don't forward host, target-host override, or agent token
923
+ if (lower === 'host' || lower === 'x-target-host' || lower === 'x-aegis-agent')
924
+ continue;
925
+ outboundHeaders[key] = value;
926
+ }
927
+ // Inject the real credential (query auth may modify the path)
928
+ const injectedPath = this.injectCredential(outboundHeaders, credential, `${remainingPath}${query}`);
929
+ outboundHeaders.host = targetDomain;
930
+ reqLog.debug({ service: serviceName, method: req.method, targetDomain, path: remainingPath }, 'Proxying request');
931
+ // Start request duration timer for Prometheus histogram
932
+ const stopTimer = this.metrics?.startRequestTimer(serviceName);
933
+ // Buffer the request body for inspection before forwarding.
934
+ // Enforce max body size to prevent memory exhaustion.
935
+ const bodyChunks = [];
936
+ let bodySize = 0;
937
+ let bodySizeExceeded = false;
938
+ req.on('data', (chunk) => {
939
+ bodySize += chunk.length;
940
+ if (bodySize > this.maxBodySize) {
941
+ bodySizeExceeded = true;
942
+ // Stop buffering — we'll reject in the 'end' handler.
943
+ // Don't destroy the stream (that kills the socket before we can respond).
944
+ }
945
+ else {
946
+ bodyChunks.push(chunk);
947
+ }
948
+ });
949
+ req.on('end', () => {
950
+ // Reject oversized bodies
951
+ if (bodySizeExceeded) {
952
+ reqLog.warn({ service: serviceName, bodySize, maxBodySize: this.maxBodySize }, 'Blocked: request body too large');
953
+ this.auditBlocked({
954
+ service: serviceName,
955
+ targetDomain,
956
+ method: req.method ?? 'GET',
957
+ path: remainingPath,
958
+ reason: 'Body too large',
959
+ });
960
+ this.metrics?.recordBlocked(serviceName, 'body_too_large', authenticatedAgent?.name);
961
+ res.writeHead(413, { 'Content-Type': 'application/json' });
962
+ res.end(`${JSON.stringify({
963
+ error: 'Request body too large',
964
+ received: bodySize,
965
+ limit: this.maxBodySize,
966
+ hint: 'Reduce body size or increase gate.max_body_size in aegis.config.yaml',
967
+ })}\n`);
968
+ return;
969
+ }
970
+ // Clear the server-level idle timeout — the body is fully received,
971
+ // so slowloris is no longer a risk. The outbound proxy timeout handles upstream delays.
972
+ req.socket?.setTimeout(0);
973
+ const bodyBuffer = Buffer.concat(bodyChunks);
974
+ const bodyString = bodyBuffer.toString('utf-8');
975
+ // Body inspection: scan for credential-like patterns in the request body
976
+ if (credential.bodyInspection !== 'off' && bodyString.length > 0) {
977
+ const inspection = this.bodyInspector.inspect(bodyString);
978
+ if (inspection.suspicious) {
979
+ const matchSummary = inspection.matches.join('; ');
980
+ if (credential.bodyInspection === 'block') {
981
+ this.auditBlocked({
982
+ service: serviceName,
983
+ targetDomain,
984
+ method: req.method ?? 'GET',
985
+ path: remainingPath,
986
+ reason: `Body inspection: potential credential exfiltration — ${matchSummary}`,
987
+ agentName: authenticatedAgent?.name,
988
+ agentTokenPrefix: authenticatedAgent?.tokenPrefix,
989
+ });
990
+ reqLog.warn({ credential: credential.name, matches: inspection.matches }, 'Blocked: body inspection detected exfiltration');
991
+ this.metrics?.recordBlocked(serviceName, 'body_inspection', authenticatedAgent?.name);
992
+ this.webhooks?.emit('body_inspection', {
993
+ service: serviceName,
994
+ credential: credential.name,
995
+ matches: inspection.matches,
996
+ agent: authenticatedAgent?.name,
997
+ method: req.method ?? 'GET',
998
+ path: remainingPath,
999
+ });
1000
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1001
+ res.end(`${JSON.stringify({
1002
+ error: 'Request body contains credential-like patterns',
1003
+ mode: 'block',
1004
+ matches: inspection.matches,
1005
+ hint: "If this is intentional, set body inspection to 'warn' or 'off' for this credential",
1006
+ })}\n`);
1007
+ return;
1008
+ }
1009
+ // warn mode — log but allow through
1010
+ reqLog.warn({ credential: credential.name, matches: inspection.matches }, 'Body inspection: credential-like patterns detected (warn mode)');
1011
+ }
1012
+ }
1013
+ // ─── Circuit Breaker Check ────────────────────────────────────
1014
+ // If the upstream service has been failing repeatedly, short-circuit
1015
+ // with 503 instead of adding more load to a struggling service.
1016
+ const circuitState = this.circuitBreaker.get(serviceName);
1017
+ if (circuitState && circuitState.openUntil > Date.now()) {
1018
+ reqLog.warn({ service: serviceName, failures: circuitState.failures }, 'Blocked: circuit breaker open');
1019
+ if (!res.headersSent) {
1020
+ const retryAfterSeconds = Math.ceil((circuitState.openUntil - Date.now()) / 1000);
1021
+ res.writeHead(503, {
1022
+ 'Content-Type': 'application/json',
1023
+ 'Retry-After': String(retryAfterSeconds),
1024
+ });
1025
+ res.end(`${JSON.stringify({
1026
+ error: 'Service temporarily unavailable (circuit breaker open)',
1027
+ service: serviceName,
1028
+ retryAfter: retryAfterSeconds,
1029
+ hint: 'The upstream service has been failing repeatedly. Requests will resume automatically.',
1030
+ })}\n`);
1031
+ }
1032
+ return;
1033
+ }
1034
+ // Forward the request with timeout and retry for transient failures
1035
+ const upstream = this.testUpstream;
1036
+ const transport = upstream?.protocol === 'http' ? http : https;
1037
+ const agent = upstream?.protocol === 'http' ? this.httpAgent : this.httpsAgent;
1038
+ const maxRetries = 2; // original + 2 retries = 3 total attempts
1039
+ const attemptProxy = (attempt) => {
1040
+ const proxyReq = transport.request({
1041
+ hostname: upstream?.hostname ?? targetDomain,
1042
+ port: upstream?.port ?? 443,
1043
+ path: injectedPath ?? `${remainingPath}${query}`,
1044
+ method: req.method,
1045
+ headers: outboundHeaders,
1046
+ timeout: this.requestTimeout,
1047
+ agent,
1048
+ }, (proxyRes) => {
1049
+ const statusCode = proxyRes.statusCode ?? 500;
1050
+ // Retry on transient upstream failures (502, 503, 504) if retries remain
1051
+ // Only retry idempotent methods (GET, HEAD, OPTIONS) — never retry state-changing methods
1052
+ const isRetryable = (statusCode === 502 || statusCode === 503 || statusCode === 504) &&
1053
+ attempt < maxRetries &&
1054
+ READ_METHODS.has(req.method?.toUpperCase() ?? 'GET');
1055
+ if (isRetryable) {
1056
+ reqLog.info({ service: serviceName, status: statusCode, attempt: attempt + 1 }, 'Retrying transient upstream failure');
1057
+ // Consume the response body before retrying
1058
+ proxyRes.resume();
1059
+ // Exponential backoff: 500ms, 1000ms
1060
+ const backoff = 500 * (attempt + 1);
1061
+ setTimeout(() => attemptProxy(attempt + 1), backoff);
1062
+ return;
1063
+ }
1064
+ // Record circuit breaker state on upstream failures
1065
+ if (statusCode >= 500) {
1066
+ this.recordCircuitFailure(serviceName);
1067
+ }
1068
+ else {
1069
+ // Success — reset circuit breaker for this service
1070
+ this.circuitBreaker.delete(serviceName);
1071
+ }
1072
+ // Strip any credential info from response headers
1073
+ const safeHeaders = { ...proxyRes.headers };
1074
+ delete safeHeaders['set-cookie']; // Prevent session hijack via agent
1075
+ this.auditAllowed({
1076
+ credentialId: credential.id,
1077
+ credentialName: credential.name,
1078
+ service: serviceName,
1079
+ targetDomain,
1080
+ method: req.method ?? 'GET',
1081
+ path: remainingPath,
1082
+ responseCode: statusCode,
1083
+ agentName: authenticatedAgent?.name,
1084
+ agentTokenPrefix: authenticatedAgent?.tokenPrefix,
1085
+ });
1086
+ reqLog.info({
1087
+ service: serviceName,
1088
+ method: req.method,
1089
+ path: remainingPath,
1090
+ status: statusCode,
1091
+ }, 'Request proxied');
1092
+ stopTimer?.();
1093
+ this.metrics?.recordRequest(serviceName, req.method ?? 'GET', statusCode, authenticatedAgent?.name);
1094
+ res.writeHead(statusCode, safeHeaders);
1095
+ proxyRes.pipe(res);
1096
+ });
1097
+ // Outbound request timeout — fires if upstream takes too long
1098
+ proxyReq.on('timeout', () => {
1099
+ proxyReq.destroy(new Error('upstream request timed out'));
1100
+ });
1101
+ proxyReq.on('error', (err) => {
1102
+ // Retry on ECONNRESET / socket hang up when using a reused (keep-alive) socket.
1103
+ // The server may have closed the idle connection before our request arrived.
1104
+ const isStaleSocket = (err.message.includes('ECONNRESET') || err.message.includes('socket hang up')) &&
1105
+ proxyReq.reusedSocket;
1106
+ if (isStaleSocket && attempt < maxRetries) {
1107
+ reqLog.info({ service: serviceName, attempt: attempt + 1 }, 'Retrying after stale socket error on reused connection');
1108
+ attemptProxy(attempt + 1);
1109
+ return;
1110
+ }
1111
+ reqLog.error({ service: serviceName, err: err.message }, 'Proxy error');
1112
+ // Record the failure for circuit breaker
1113
+ this.recordCircuitFailure(serviceName);
1114
+ if (!this.shuttingDown) {
1115
+ try {
1116
+ this.auditBlocked({
1117
+ service: serviceName,
1118
+ targetDomain,
1119
+ method: req.method ?? 'GET',
1120
+ path: remainingPath,
1121
+ reason: `Proxy error: ${err.message}`,
1122
+ });
1123
+ }
1124
+ catch {
1125
+ // Ledger may be unavailable during shutdown cleanup
1126
+ }
1127
+ }
1128
+ if (!res.headersSent) {
1129
+ const isTimeout = err.message.includes('timed out');
1130
+ const statusCode = isTimeout ? 504 : 502;
1131
+ const errorMessage = isTimeout
1132
+ ? 'Upstream request timed out'
1133
+ : 'Failed to reach upstream service';
1134
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
1135
+ res.end(`${JSON.stringify({
1136
+ error: errorMessage,
1137
+ service: serviceName,
1138
+ })}\n`);
1139
+ }
1140
+ });
1141
+ // Write the buffered body and end
1142
+ if (bodyBuffer.length > 0) {
1143
+ proxyReq.write(bodyBuffer);
1144
+ }
1145
+ proxyReq.end();
1146
+ };
1147
+ attemptProxy(0);
1148
+ });
1149
+ }
1150
+ catch (err) {
1151
+ reqLog.error({ err }, 'Unhandled error');
1152
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1153
+ res.end(`${JSON.stringify({ error: 'Internal Aegis Gate error' })}\n`);
1154
+ }
1155
+ }
1156
+ /**
1157
+ * Record an upstream failure for circuit breaker logic.
1158
+ * After 5 consecutive failures, the circuit opens for 30 seconds.
1159
+ */
1160
+ recordCircuitFailure(service) {
1161
+ const state = this.circuitBreaker.get(service) ?? { failures: 0, openUntil: 0 };
1162
+ state.failures++;
1163
+ // Open the circuit after 5 consecutive failures (30s cooldown)
1164
+ const FAILURE_THRESHOLD = 5;
1165
+ const COOLDOWN_MS = 30_000;
1166
+ if (state.failures >= FAILURE_THRESHOLD) {
1167
+ state.openUntil = Date.now() + COOLDOWN_MS;
1168
+ this.logger.warn({ service, failures: state.failures, cooldownMs: COOLDOWN_MS }, 'Circuit breaker opened — service failures exceeded threshold');
1169
+ }
1170
+ this.circuitBreaker.set(service, state);
1171
+ }
1172
+ /**
1173
+ * Inject the credential into outbound request headers based on auth type.
1174
+ * For `query` auth, the secret is appended as a URL query parameter instead.
1175
+ */
1176
+ injectCredential(headers, credential, path) {
1177
+ switch (credential.authType) {
1178
+ case 'bearer':
1179
+ headers.authorization = `Bearer ${credential.secret}`;
1180
+ break;
1181
+ case 'header':
1182
+ headers[credential.headerName ?? 'x-api-key'] = credential.secret;
1183
+ break;
1184
+ case 'basic':
1185
+ headers.authorization = `Basic ${Buffer.from(credential.secret).toString('base64')}`;
1186
+ break;
1187
+ case 'query': {
1188
+ if (path !== undefined) {
1189
+ const paramName = encodeURIComponent(credential.headerName ?? 'key');
1190
+ const paramValue = encodeURIComponent(credential.secret);
1191
+ const separator = path.includes('?') ? '&' : '?';
1192
+ return `${path}${separator}${paramName}=${paramValue}`;
1193
+ }
1194
+ break;
1195
+ }
1196
+ }
1197
+ return path;
1198
+ }
1199
+ // ─── Audit Wrappers (Ledger + Dashboard Broadcast) ─────────────
1200
+ /**
1201
+ * Log an allowed request and broadcast to dashboard live feed.
1202
+ */
1203
+ auditAllowed(params) {
1204
+ this.ledger.logAllowed(params);
1205
+ this.onAuditEntry?.({
1206
+ timestamp: new Date().toISOString(),
1207
+ credentialId: params.credentialId,
1208
+ credentialName: params.credentialName,
1209
+ service: params.service,
1210
+ targetDomain: params.targetDomain,
1211
+ method: params.method,
1212
+ path: params.path,
1213
+ status: 'allowed',
1214
+ blockedReason: null,
1215
+ responseCode: params.responseCode ?? null,
1216
+ agentName: params.agentName ?? null,
1217
+ agentTokenPrefix: params.agentTokenPrefix ?? null,
1218
+ channel: 'gate',
1219
+ });
1220
+ }
1221
+ /**
1222
+ * Log a blocked request and broadcast to dashboard live feed.
1223
+ */
1224
+ auditBlocked(params) {
1225
+ this.ledger.logBlocked(params);
1226
+ this.onAuditEntry?.({
1227
+ timestamp: new Date().toISOString(),
1228
+ credentialId: null,
1229
+ credentialName: null,
1230
+ service: params.service,
1231
+ targetDomain: params.targetDomain,
1232
+ method: params.method,
1233
+ path: params.path,
1234
+ status: 'blocked',
1235
+ blockedReason: params.reason,
1236
+ responseCode: null,
1237
+ agentName: params.agentName ?? null,
1238
+ agentTokenPrefix: params.agentTokenPrefix ?? null,
1239
+ channel: 'gate',
1240
+ });
1241
+ }
1242
+ }
1243
+ //# sourceMappingURL=gate.js.map