fullcourtdefense-cli 1.34.26 → 1.35.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.
@@ -0,0 +1,273 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.blockedResponseBody = blockedResponseBody;
37
+ exports.startHttpGateway = startHttpGateway;
38
+ /**
39
+ * `fullcourtdefense http-gateway` — a LOCAL HTTP/HTTPS forward proxy that gates
40
+ * an AI agent's outbound API calls.
41
+ *
42
+ * The agent process is pointed at it with the standard HTTP_PROXY /
43
+ * HTTPS_PROXY variables (workload-protect writes them into the identity file),
44
+ * so its HTTP libraries route through here without code changes. For every
45
+ * request the gateway:
46
+ *
47
+ * capture plain-HTTP request (method + absolute URL) or CONNECT tunnel (host only)
48
+ * canonicalize `httpEgress.ts` — host, method class, path template, agent name, flags
49
+ * decide `evaluateActionPolicies` over the delivered policy bundle (deterministic)
50
+ * enforce allow → forward / tunnel; block → 403 JSON (tunnel: 403 then close);
51
+ * require_approval → hold, ask the org queue, block on timeout or when
52
+ * nobody can answer; log → forward and record
53
+ * record minimized facts (agent, host, method, path template, verdict, rule)
54
+ *
55
+ * The gateway itself never sees inside an HTTPS tunnel: for CONNECT the only
56
+ * fact is the destination host (+ port), so host-level rules apply and method /
57
+ * endpoint rules cannot. The console says so ("HTTPS: host only").
58
+ *
59
+ * Nothing here branches on anything but the returned verdict.
60
+ */
61
+ const http = __importStar(require("http"));
62
+ const https = __importStar(require("https"));
63
+ const net = __importStar(require("net"));
64
+ const httpEgress_1 = require("../httpEgress");
65
+ const HOP_BY_HOP = new Set(['proxy-connection', 'proxy-authorization', 'proxy-authenticate', 'connection', 'keep-alive', 'te', 'trailer', 'upgrade', httpEgress_1.AGENT_NAME_HEADER]);
66
+ function blockedResponseBody(req, decision) {
67
+ return JSON.stringify({
68
+ blocked: true,
69
+ by: 'FullCourtDefense',
70
+ action: (0, httpEgress_1.describeHttpEgress)(req),
71
+ agent: req.agentName,
72
+ host: req.host,
73
+ method: req.method,
74
+ ...(req.pathTemplate ? { endpoint: req.pathTemplate } : {}),
75
+ reason: decision.reason || (decision.verdict === 'require_approval'
76
+ ? 'This call requires approval and nobody approved it in time.'
77
+ : 'Blocked by API destination policy.'),
78
+ ...(decision.policyName ? { policy: decision.policyName } : {}),
79
+ ...(decision.matchedRule ? { rule: decision.matchedRule } : {}),
80
+ ...(decision.approvalId ? { approvalId: decision.approvalId } : {}),
81
+ howToChange: 'Fleet console → this agent → API destinations.',
82
+ });
83
+ }
84
+ async function startHttpGateway(options) {
85
+ const bind = options.bind || '127.0.0.1';
86
+ const upstreamTimeoutMs = options.upstreamTimeoutMs ?? 15_000;
87
+ const log = options.log || (() => undefined);
88
+ const bypass = options.bypassHosts || new Set();
89
+ const stats = { requests: 0, blocked: 0, forwarded: 0, tunnelled: 0, errors: 0 };
90
+ const shouldBypass = (req) => bypass.has(req.host) || (0, httpEgress_1.isLoopbackHost)(req.host);
91
+ const upstreamFor = (req) => options.resolveUpstream?.(req.host, req.port) || { host: req.host, port: req.port };
92
+ const server = http.createServer(async (clientReq, clientRes) => {
93
+ const started = Date.now();
94
+ stats.requests += 1;
95
+ // Health / self endpoint: a request addressed to the proxy itself, not through it.
96
+ if (clientReq.url === '/__fcd/health' || clientReq.url === '/__fcd/health/') {
97
+ clientRes.writeHead(200, { 'content-type': 'application/json' });
98
+ clientRes.end(JSON.stringify({ ok: true, gateway: 'http', ...stats }));
99
+ return;
100
+ }
101
+ const canonical = (0, httpEgress_1.canonicalizeHttpEgress)({
102
+ method: clientReq.method || 'GET',
103
+ target: clientReq.url || '',
104
+ headers: clientReq.headers,
105
+ agent: options.agent,
106
+ });
107
+ if (!canonical) {
108
+ clientRes.writeHead(400, { 'content-type': 'application/json' });
109
+ clientRes.end(JSON.stringify({ error: 'FullCourtDefense http-gateway: cannot parse proxy target', target: clientReq.url }));
110
+ return;
111
+ }
112
+ if (shouldBypass(canonical)) {
113
+ forwardPlain(clientReq, clientRes, canonical, upstreamFor(canonical), upstreamTimeoutMs, () => { stats.forwarded += 1; }, () => { stats.errors += 1; });
114
+ return;
115
+ }
116
+ let decision;
117
+ try {
118
+ decision = await options.decide(canonical);
119
+ }
120
+ catch (error) {
121
+ // The decider is the only thing allowed to fail closed/open; an exception here is a bug — block and say so.
122
+ stats.errors += 1;
123
+ decision = { verdict: 'block', reason: `FullCourtDefense http-gateway could not evaluate policy: ${error.message}` };
124
+ }
125
+ if (decision.verdict === 'block' || decision.verdict === 'require_approval') {
126
+ stats.blocked += 1;
127
+ clientRes.writeHead(403, {
128
+ 'content-type': 'application/json',
129
+ 'x-fcd-verdict': decision.verdict,
130
+ ...(decision.policyName ? { 'x-fcd-policy': safeHeader(decision.policyName) } : {}),
131
+ });
132
+ clientRes.end(blockedResponseBody(canonical, decision));
133
+ options.record({ req: canonical, decision, enforced: 'blocked', durationMs: Date.now() - started, statusCode: 403 });
134
+ log(`blocked ${(0, httpEgress_1.describeHttpEgress)(canonical)} (${decision.policyName || decision.reason || decision.verdict})`);
135
+ return;
136
+ }
137
+ forwardPlain(clientReq, clientRes, canonical, upstreamFor(canonical), upstreamTimeoutMs, (statusCode) => {
138
+ stats.forwarded += 1;
139
+ options.record({ req: canonical, decision, enforced: 'forwarded', durationMs: Date.now() - started, statusCode });
140
+ }, (message) => {
141
+ stats.errors += 1;
142
+ options.record({ req: canonical, decision: { ...decision, reason: message }, enforced: 'error', durationMs: Date.now() - started, statusCode: 502 });
143
+ });
144
+ });
145
+ server.on('connect', async (clientReq, clientSocket, head) => {
146
+ const started = Date.now();
147
+ stats.requests += 1;
148
+ const canonical = (0, httpEgress_1.canonicalizeHttpEgress)({ method: 'CONNECT', target: clientReq.url || '', headers: clientReq.headers, agent: options.agent });
149
+ if (!canonical) {
150
+ clientSocket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
151
+ return;
152
+ }
153
+ let decision = { verdict: 'allow' };
154
+ if (!shouldBypass(canonical)) {
155
+ try {
156
+ decision = await options.decide(canonical);
157
+ }
158
+ catch (error) {
159
+ stats.errors += 1;
160
+ decision = { verdict: 'block', reason: `FullCourtDefense http-gateway could not evaluate policy: ${error.message}` };
161
+ }
162
+ }
163
+ if (decision.verdict === 'block' || decision.verdict === 'require_approval') {
164
+ stats.blocked += 1;
165
+ const body = blockedResponseBody(canonical, decision);
166
+ clientSocket.end(`HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nX-FCD-Verdict: ${decision.verdict}\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`);
167
+ options.record({ req: canonical, decision, enforced: 'blocked', durationMs: Date.now() - started, statusCode: 403 });
168
+ log(`blocked ${(0, httpEgress_1.describeHttpEgress)(canonical)} (${decision.policyName || decision.reason || decision.verdict})`);
169
+ return;
170
+ }
171
+ const target = upstreamFor(canonical);
172
+ const upstream = net.connect({ host: target.host, port: target.port });
173
+ let settled = false;
174
+ const fail = (message) => {
175
+ if (settled)
176
+ return;
177
+ settled = true;
178
+ stats.errors += 1;
179
+ if (!clientSocket.destroyed)
180
+ clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n`);
181
+ upstream.destroy();
182
+ if (!shouldBypass(canonical))
183
+ options.record({ req: canonical, decision: { ...decision, reason: message }, enforced: 'error', durationMs: Date.now() - started, statusCode: 502 });
184
+ };
185
+ upstream.setTimeout(upstreamTimeoutMs, () => fail('upstream connect timeout'));
186
+ upstream.once('error', (error) => fail(error.message));
187
+ upstream.once('connect', () => {
188
+ settled = true;
189
+ upstream.setTimeout(0);
190
+ stats.tunnelled += 1;
191
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\nProxy-Agent: FullCourtDefense\r\n\r\n');
192
+ if (head && head.length)
193
+ upstream.write(head);
194
+ upstream.pipe(clientSocket);
195
+ clientSocket.pipe(upstream);
196
+ if (!shouldBypass(canonical))
197
+ options.record({ req: canonical, decision, enforced: 'tunnelled', durationMs: Date.now() - started });
198
+ });
199
+ clientSocket.once('error', () => upstream.destroy());
200
+ clientSocket.once('close', () => upstream.destroy());
201
+ upstream.once('close', () => { if (!clientSocket.destroyed)
202
+ clientSocket.destroy(); });
203
+ });
204
+ server.on('clientError', (_error, socket) => {
205
+ if (!socket.destroyed)
206
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
207
+ });
208
+ await new Promise((resolve, reject) => {
209
+ server.once('error', reject);
210
+ server.listen(options.port, bind, () => { server.off('error', reject); resolve(); });
211
+ });
212
+ const address = server.address();
213
+ const port = typeof address === 'object' && address ? address.port : options.port;
214
+ log(`listening on http://${bind}:${port} (agent: ${options.agent.configured || options.agent.workloadName || 'unknown-agent'})`);
215
+ return {
216
+ port,
217
+ address: bind,
218
+ stats,
219
+ close: () => new Promise((resolve) => {
220
+ server.closeAllConnections?.();
221
+ server.close(() => resolve());
222
+ }),
223
+ };
224
+ }
225
+ function safeHeader(value) {
226
+ return value.replace(/[^\x20-\x7e]/g, '').slice(0, 200);
227
+ }
228
+ function forwardPlain(clientReq, clientRes, canonical, target, timeoutMs, onDone, onError) {
229
+ const headers = {};
230
+ for (const [key, value] of Object.entries(clientReq.headers)) {
231
+ if (value === undefined || HOP_BY_HOP.has(key.toLowerCase()) || key.toLowerCase() === 'host')
232
+ continue;
233
+ headers[key] = value;
234
+ }
235
+ headers.connection = 'close';
236
+ // The verdict was decided on the absolute-URI host; the upstream must see
237
+ // THAT host. A client-supplied `Host:` that names a different virtual host
238
+ // on the same front (CDN, cloud LB, ingress) would otherwise reach a vhost
239
+ // the policy never evaluated.
240
+ const defaultPort = canonical.scheme === 'https' ? 443 : 80;
241
+ headers.host = canonical.port === defaultPort ? canonical.host : `${canonical.host}:${canonical.port}`;
242
+ // An absolute `https://` URI on the plain proxy path (rare: a client that does
243
+ // not use CONNECT) must be forwarded over TLS, never in cleartext to :443.
244
+ const request = canonical.scheme === 'https' ? https.request : http.request;
245
+ const upstreamReq = request({
246
+ host: target.host,
247
+ port: target.port,
248
+ method: canonical.method,
249
+ path: canonical.path || '/',
250
+ headers,
251
+ timeout: timeoutMs,
252
+ ...(canonical.scheme === 'https' ? { servername: canonical.host } : {}),
253
+ }, (upstreamRes) => {
254
+ const responseHeaders = {};
255
+ for (const [key, value] of Object.entries(upstreamRes.headers)) {
256
+ if (value === undefined || key.toLowerCase() === 'connection')
257
+ continue;
258
+ responseHeaders[key] = value;
259
+ }
260
+ clientRes.writeHead(upstreamRes.statusCode || 502, responseHeaders);
261
+ upstreamRes.pipe(clientRes);
262
+ upstreamRes.once('end', () => onDone(upstreamRes.statusCode || 0));
263
+ });
264
+ upstreamReq.once('timeout', () => { upstreamReq.destroy(new Error('upstream timeout')); });
265
+ upstreamReq.once('error', (error) => {
266
+ onError(error.message);
267
+ if (!clientRes.headersSent) {
268
+ clientRes.writeHead(502, { 'content-type': 'application/json' });
269
+ }
270
+ clientRes.end(JSON.stringify({ error: 'FullCourtDefense http-gateway: upstream unreachable', detail: error.message, host: canonical.host }));
271
+ });
272
+ clientReq.pipe(upstreamReq);
273
+ }
@@ -0,0 +1,87 @@
1
+ import type { BotGuardConfig } from '../config';
2
+ import { type EngineActionPolicy, type EngineCheckResult } from '../actionPolicyEngine';
3
+ import { type HttpEgressRequest } from '../httpEgress';
4
+ import { type GatewayConfig } from './mcpGateway';
5
+ import { type HttpEgressDecision, type HttpEgressRecord, type RunningHttpGateway } from './httpGateway';
6
+ /** Fact the compiled API-destination rules constrain on (`gateway equals http`). Mirrors backend API_EGRESS_GATEWAY_FACT. */
7
+ export declare const HTTP_GATEWAY_FACT = "http";
8
+ export declare const DEFAULT_HTTP_GATEWAY_PORT = 18790;
9
+ export interface HttpGatewayArgs {
10
+ port?: string;
11
+ bind?: string;
12
+ agentName?: string;
13
+ shieldId?: string;
14
+ shieldKey?: string;
15
+ apiUrl?: string;
16
+ developerName?: string;
17
+ approvalTimeoutMs?: string;
18
+ approvalPollMs?: string;
19
+ failClosed?: string;
20
+ /** `host=addr:port[,host=addr:port]` — connect elsewhere for these hosts (demos / tests). */
21
+ resolve?: string;
22
+ /** Write a JSON line per decision here (in addition to the spool) — demos / tests. */
23
+ decisionLog?: string;
24
+ /** Write `{ port }` here once listening — the spawner (workload-protect) waits for it. */
25
+ readyFile?: string;
26
+ /** 'true' => print one line per request to stderr. */
27
+ verbose?: string;
28
+ /**
29
+ * 'true' => honour a per-request `x-fcd-agent` header (several agents behind ONE
30
+ * gateway). OFF by default: the agent process would otherwise be able to rename
31
+ * itself out of its own table. Also FCD_HTTP_GATEWAY_AGENT_HEADER=1.
32
+ */
33
+ agentHeader?: string;
34
+ }
35
+ export declare function parseResolveArg(value: string | undefined): Map<string, {
36
+ host: string;
37
+ port: number;
38
+ }>;
39
+ /**
40
+ * The engine-facing evaluation for one egress request. Pure given its inputs —
41
+ * exported so tests can drive it without sockets.
42
+ */
43
+ export declare function evaluateHttpEgress(policies: EngineActionPolicy[], req: HttpEgressRequest, identity: {
44
+ developerName: string;
45
+ agentName: string;
46
+ agentClient: string;
47
+ }): {
48
+ operation: string;
49
+ context: Record<string, string>;
50
+ result: EngineCheckResult;
51
+ };
52
+ export declare class HttpGatewayRuntime {
53
+ private readonly gatewayConfig;
54
+ private readonly options;
55
+ private apis;
56
+ private bundleMemo;
57
+ private folds;
58
+ private decisionLogPath?;
59
+ constructor(gatewayConfig: GatewayConfig, options: {
60
+ failClosed: boolean;
61
+ decisionLog?: string;
62
+ verbose?: boolean;
63
+ });
64
+ private apiFor;
65
+ bundle(force?: boolean): Promise<import("../runtimeConfig").EffectiveBundle>;
66
+ private identity;
67
+ /** The decider handed to the proxy: resolves every request to an enforceable verdict. */
68
+ decide: (req: HttpEgressRequest) => Promise<HttpEgressDecision>;
69
+ /** Local Safety snapshot for this machine (bundle-hash keyed, stale-cache-wins on Monitor). Never throws. */
70
+ private localSafety;
71
+ private spoolFinding;
72
+ /** Recorder handed to the proxy: folds allowed calls, spools blocks immediately. */
73
+ record: (record: HttpEgressRecord) => void;
74
+ private fold;
75
+ /** Flush pending folds now (shutdown / tests). */
76
+ flushFolds(): void;
77
+ private spool;
78
+ }
79
+ export interface StartedHttpGateway {
80
+ gateway: RunningHttpGateway;
81
+ runtime: HttpGatewayRuntime;
82
+ agentName: string;
83
+ stop: () => Promise<void>;
84
+ }
85
+ /** Start the gateway in-process (used by the command and by tests). */
86
+ export declare function startHttpGatewayFromArgs(args: HttpGatewayArgs, config: BotGuardConfig): Promise<StartedHttpGateway>;
87
+ export declare function httpGatewayCommand(args: HttpGatewayArgs, config: BotGuardConfig): Promise<void>;