@gatebolt/agent-redroom 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.
Files changed (61) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +387 -0
  4. package/dist/agents/agent.d.ts +37 -0
  5. package/dist/agents/agent.js +1 -0
  6. package/dist/agents/claude-code.d.ts +39 -0
  7. package/dist/agents/claude-code.js +91 -0
  8. package/dist/agents/claude-stream.d.ts +2 -0
  9. package/dist/agents/claude-stream.js +64 -0
  10. package/dist/deadline.d.ts +1 -0
  11. package/dist/deadline.js +17 -0
  12. package/dist/docker/cli.d.ts +28 -0
  13. package/dist/docker/cli.js +102 -0
  14. package/dist/docker/plan.d.ts +42 -0
  15. package/dist/docker/plan.js +80 -0
  16. package/dist/docker/session.d.ts +14 -0
  17. package/dist/docker/session.js +118 -0
  18. package/dist/hooks.d.ts +5 -0
  19. package/dist/hooks.js +13 -0
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.js +9 -0
  22. package/dist/paths.d.ts +2 -0
  23. package/dist/paths.js +3 -0
  24. package/dist/planting.d.ts +6 -0
  25. package/dist/planting.js +57 -0
  26. package/dist/redact.d.ts +3 -0
  27. package/dist/redact.js +24 -0
  28. package/dist/redroom.d.ts +37 -0
  29. package/dist/redroom.js +438 -0
  30. package/dist/run.d.ts +39 -0
  31. package/dist/run.js +251 -0
  32. package/dist/secrets.d.ts +2 -0
  33. package/dist/secrets.js +44 -0
  34. package/dist/sidecar/config.d.ts +25 -0
  35. package/dist/sidecar/config.js +15 -0
  36. package/dist/sidecar/control.d.ts +83 -0
  37. package/dist/sidecar/control.js +92 -0
  38. package/dist/sidecar/dns.d.ts +10 -0
  39. package/dist/sidecar/dns.js +71 -0
  40. package/dist/sidecar/fake-web.d.ts +15 -0
  41. package/dist/sidecar/fake-web.js +87 -0
  42. package/dist/sidecar/http.d.ts +9 -0
  43. package/dist/sidecar/http.js +37 -0
  44. package/dist/sidecar/main.d.ts +1 -0
  45. package/dist/sidecar/main.js +22 -0
  46. package/dist/sidecar/relay.d.ts +9 -0
  47. package/dist/sidecar/relay.js +174 -0
  48. package/dist/sidecar/sidecar.d.ts +24 -0
  49. package/dist/sidecar/sidecar.js +106 -0
  50. package/dist/sidecar/state.d.ts +45 -0
  51. package/dist/sidecar/state.js +93 -0
  52. package/dist/sidecar/tool-hook.d.ts +13 -0
  53. package/dist/sidecar/tool-hook.js +147 -0
  54. package/dist/sidecar-bundle/sidecar.mjs +4929 -0
  55. package/dist/sightings.d.ts +10 -0
  56. package/dist/sightings.js +13 -0
  57. package/dist/types.d.ts +139 -0
  58. package/dist/types.js +32 -0
  59. package/images/agent/Dockerfile +18 -0
  60. package/images/sidecar/Dockerfile +10 -0
  61. package/package.json +73 -0
@@ -0,0 +1,87 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { createServer as createHttpServer, } from "node:http";
4
+ import { createServer as createHttpsServer } from "node:https";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import {} from "../types.js";
9
+ import { isFakeHost } from "./dns.js";
10
+ import { headersOf, SCAN_LIMIT, sourceIp } from "./state.js";
11
+ const run = promisify(execFile);
12
+ const HIT_BODY_LIMIT = 64 * 1024;
13
+ export async function generateTls(domain) {
14
+ const dir = await mkdtemp(join(tmpdir(), "redroom-tls-"));
15
+ const f = (name) => join(dir, name);
16
+ const openssl = (...args) => run("openssl", args);
17
+ try {
18
+ await writeFile(f("ext.cnf"), `subjectAltName=DNS:*.${domain},DNS:${domain}\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\n`);
19
+ await openssl(...["req", "-x509", "-newkey", "rsa:2048", "-nodes"], ...["-keyout", f("ca.key"), "-out", f("ca.pem"), "-days", "2"], ...["-subj", "/CN=agent-redroom fake web CA"]);
20
+ await openssl(...["req", "-newkey", "rsa:2048", "-nodes"], ...["-keyout", f("srv.key"), "-out", f("srv.csr")], ...["-subj", `/CN=*.${domain}`]);
21
+ await openssl(...["x509", "-req", "-in", f("srv.csr")], ...["-CA", f("ca.pem"), "-CAkey", f("ca.key"), "-CAcreateserial"], ...["-out", f("srv.pem"), "-days", "2", "-extfile", f("ext.cnf")]);
22
+ const [caPem, certPem, keyPem] = await Promise.all([
23
+ readFile(f("ca.pem"), "utf8"),
24
+ readFile(f("srv.pem"), "utf8"),
25
+ readFile(f("srv.key"), "utf8"),
26
+ ]);
27
+ return { caPem, certPem, keyPem };
28
+ }
29
+ finally {
30
+ await rm(dir, { recursive: true, force: true });
31
+ }
32
+ }
33
+ function fakeWeb(context, protocol) {
34
+ return (req, res) => {
35
+ const chunks = [];
36
+ let size = 0;
37
+ req.on("data", (chunk) => {
38
+ if (size < SCAN_LIMIT)
39
+ chunks.push(chunk);
40
+ size += chunk.length;
41
+ });
42
+ req.on("end", () => {
43
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
44
+ if (!isFakeHost(host, context.domain)) {
45
+ res.writeHead(404).end();
46
+ return;
47
+ }
48
+ const target = req.url ?? "/";
49
+ const url = URL.parse(target, "http://fake-web");
50
+ const source = sourceIp(req, context.trustTestSourceHeader);
51
+ const method = req.method ?? "GET";
52
+ const path = url ? url.pathname + url.search : target;
53
+ const body = Buffer.concat(chunks);
54
+ context.state.recordHit(source, {
55
+ protocol,
56
+ host,
57
+ method,
58
+ path,
59
+ headers: headersOf(req),
60
+ body: body.subarray(0, HIT_BODY_LIMIT).toString("utf8"),
61
+ truncated: size > HIT_BODY_LIMIT,
62
+ }, body.toString("utf8"));
63
+ if (!url) {
64
+ res.writeHead(400, { "Content-Type": "text/plain" }).end("bad request");
65
+ return;
66
+ }
67
+ const pages = context.state.runForIp(source)?.pages ?? {};
68
+ const page = pages[`${host}${path}`] ?? pages[`${host}${url.pathname}`];
69
+ if (page) {
70
+ res.writeHead(page.status, { "Content-Type": page.contentType });
71
+ res.end(page.body);
72
+ return;
73
+ }
74
+ if (method === "GET" || method === "HEAD") {
75
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("not found");
76
+ return;
77
+ }
78
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
79
+ });
80
+ };
81
+ }
82
+ export function createFakeHttps(context, tls) {
83
+ return createHttpsServer({ cert: tls.certPem, key: tls.keyPem }, fakeWeb(context, "https"));
84
+ }
85
+ export function createFakeHttp(context) {
86
+ return createHttpServer(fakeWeb(context, "http"));
87
+ }
@@ -0,0 +1,9 @@
1
+ import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ export declare const BODY_LIMIT_BYTES: number;
3
+ export declare class BodyTooLargeError extends Error {
4
+ }
5
+ export declare function readBody(body: AsyncIterable<Uint8Array>, limit?: number): Promise<Buffer>;
6
+ export declare function readJson(req: IncomingMessage): Promise<unknown>;
7
+ export declare function sendJson(res: ServerResponse, status: number, body: unknown, headers?: Record<string, string>): void;
8
+ export declare function bearerMatches(header: string | undefined, secret: string): boolean;
9
+ export declare function jsonServer(handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>): Server;
@@ -0,0 +1,37 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { createServer, } from "node:http";
3
+ export const BODY_LIMIT_BYTES = 1024 * 1024;
4
+ export class BodyTooLargeError extends Error {
5
+ }
6
+ export async function readBody(body, limit = BODY_LIMIT_BYTES) {
7
+ const chunks = [];
8
+ let size = 0;
9
+ for await (const chunk of body) {
10
+ size += chunk.length;
11
+ if (size > limit)
12
+ throw new BodyTooLargeError("body too large");
13
+ chunks.push(chunk);
14
+ }
15
+ return Buffer.concat(chunks);
16
+ }
17
+ export async function readJson(req) {
18
+ return JSON.parse((await readBody(req)).toString("utf8"));
19
+ }
20
+ export function sendJson(res, status, body, headers = {}) {
21
+ res.writeHead(status, { "Content-Type": "application/json", ...headers });
22
+ res.end(JSON.stringify(body));
23
+ }
24
+ export function bearerMatches(header, secret) {
25
+ const given = Buffer.from(header ?? "");
26
+ const wanted = Buffer.from(`Bearer ${secret}`);
27
+ return given.length === wanted.length && timingSafeEqual(given, wanted);
28
+ }
29
+ export function jsonServer(handle) {
30
+ return createServer((req, res) => {
31
+ handle(req, res).catch(() => {
32
+ if (!res.headersSent)
33
+ res.writeHead(500);
34
+ res.end();
35
+ });
36
+ });
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { CONFIG_ENV, configSchema, PORTS } from "./config.js";
2
+ import { startSidecar } from "./sidecar.js";
3
+ const raw = process.env[CONFIG_ENV];
4
+ if (!raw)
5
+ throw new Error(`${CONFIG_ENV} is required`);
6
+ const config = configSchema.parse(JSON.parse(raw));
7
+ const sidecar = await startSidecar({
8
+ controlSecret: config.controlSecret,
9
+ domain: config.domain,
10
+ ports: PORTS,
11
+ lease: { ms: config.leaseMs, onExpired: () => shutdown() },
12
+ });
13
+ function shutdown() {
14
+ void sidecar.stop().then(() => process.exit(0));
15
+ }
16
+ process.on("SIGTERM", shutdown);
17
+ process.on("SIGINT", shutdown);
18
+ function keepRunning(error) {
19
+ console.error("sidecar: unexpected error, still running:", error);
20
+ }
21
+ process.on("uncaughtException", keepRunning);
22
+ process.on("unhandledRejection", keepRunning);
@@ -0,0 +1,9 @@
1
+ import { type Server } from "node:http";
2
+ import { type SidecarState } from "./state.js";
3
+ export declare const UPSTREAM_IDLE_TIMEOUT_MS = 120000;
4
+ export type RelayContext = {
5
+ state: SidecarState;
6
+ trustTestSourceHeader: boolean;
7
+ idleTimeoutMs: number;
8
+ };
9
+ export declare function createRelay(context: RelayContext): Server;
@@ -0,0 +1,174 @@
1
+ import { createServer, request as httpRequest, } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
3
+ import { findSecrets } from "../secrets.js";
4
+ import { DETAIL_LIMIT } from "../types.js";
5
+ import { headerLines, headersOf, push, SCAN_LIMIT, sourceIp, } from "./state.js";
6
+ // Well under the turn timeout so a silent proxy is cut and logged first; streams send pings.
7
+ export const UPSTREAM_IDLE_TIMEOUT_MS = 120_000;
8
+ const DROPPED = new Set([
9
+ "host",
10
+ "connection",
11
+ "authorization",
12
+ "x-api-key",
13
+ "x-redroom-run",
14
+ "accept-encoding",
15
+ ]);
16
+ const MODEL_ENDPOINTS = new Set([
17
+ "POST /v1/messages",
18
+ "POST /v1/messages/count_tokens",
19
+ "GET /v1/models",
20
+ ]);
21
+ function forwardedHeaders(headers, upstream, runId) {
22
+ const kept = Object.fromEntries(Object.entries(headers).filter(([name]) => !DROPPED.has(name)));
23
+ return { ...kept, ...upstream.headers, "x-redroom-run": runId };
24
+ }
25
+ function isLocalPath(path) {
26
+ return path.startsWith("/") && !path.startsWith("//");
27
+ }
28
+ function isModelEndpoint(method, path) {
29
+ const { pathname } = new URL(path, "http://relay");
30
+ return MODEL_ENDPOINTS.has(`${method} ${pathname}`);
31
+ }
32
+ function upstreamUrl(baseUrl, path) {
33
+ const base = new URL(baseUrl);
34
+ const request = new URL(path, base.origin);
35
+ const target = new URL(base.origin);
36
+ target.pathname = base.pathname.replace(/\/$/, "") + request.pathname;
37
+ target.search = request.search;
38
+ return target;
39
+ }
40
+ function refuseEndpoint(run, method, path, res) {
41
+ push(run.modelCalls, {
42
+ method,
43
+ path,
44
+ status: 403,
45
+ durationMs: 0,
46
+ requestBytes: 0,
47
+ at: new Date().toISOString(),
48
+ secrets: [],
49
+ error: {
50
+ by: "sidecar",
51
+ code: "path_not_allowed",
52
+ detail: "the relay forwards only the model endpoints",
53
+ },
54
+ });
55
+ res.writeHead(403, { "Content-Type": "application/json" });
56
+ res.end(JSON.stringify({ error: "path not allowed" }));
57
+ }
58
+ export function createRelay(context) {
59
+ return createServer((req, res) => {
60
+ try {
61
+ relay(context, req, res);
62
+ }
63
+ catch {
64
+ if (!res.headersSent)
65
+ res.writeHead(500);
66
+ res.end();
67
+ }
68
+ });
69
+ }
70
+ function relay(context, req, res) {
71
+ const path = req.url ?? "/";
72
+ if (!isLocalPath(path)) {
73
+ res.writeHead(400, { "Content-Type": "application/json" });
74
+ res.end(JSON.stringify({ error: "request target must be a path" }));
75
+ return;
76
+ }
77
+ const entry = context.state.runEntryForIp(sourceIp(req, context.trustTestSourceHeader));
78
+ if (!entry) {
79
+ res.writeHead(403, { "Content-Type": "application/json" });
80
+ res.end(JSON.stringify({ error: "unknown source" }));
81
+ return;
82
+ }
83
+ const [runId, run] = entry;
84
+ const method = req.method ?? "GET";
85
+ if (!isModelEndpoint(method, path)) {
86
+ refuseEndpoint(run, method, path, res);
87
+ return;
88
+ }
89
+ const target = upstreamUrl(run.modelProxy.baseUrl, path);
90
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
91
+ const started = Date.now();
92
+ const scanned = [];
93
+ let requestBytes = 0;
94
+ let status = 0;
95
+ let errorBody = "";
96
+ let error;
97
+ let recorded = false;
98
+ const record = () => {
99
+ if (recorded)
100
+ return;
101
+ recorded = true;
102
+ const body = Buffer.concat(scanned).toString("utf8");
103
+ const text = [path, headerLines(headersOf(req)), body].join("\n");
104
+ if (!error && status >= 400) {
105
+ error = {
106
+ by: "modelProxy",
107
+ code: `http_${status}`,
108
+ detail: errorBody.slice(0, DETAIL_LIMIT),
109
+ call: { url: target.href, status, durationMs: Date.now() - started },
110
+ };
111
+ }
112
+ push(run.modelCalls, {
113
+ method,
114
+ path,
115
+ status,
116
+ durationMs: Date.now() - started,
117
+ requestBytes,
118
+ at: new Date(started).toISOString(),
119
+ secrets: findSecrets(text, run.secrets),
120
+ ...(error ? { error } : {}),
121
+ });
122
+ };
123
+ const fail = (code, detail) => {
124
+ if (recorded)
125
+ return;
126
+ error = {
127
+ by: "modelProxy",
128
+ code,
129
+ detail,
130
+ call: { url: target.href, durationMs: Date.now() - started },
131
+ };
132
+ if (res.headersSent) {
133
+ res.destroy();
134
+ }
135
+ else {
136
+ status = code === "timeout" ? 504 : 502;
137
+ res.writeHead(status, { "Content-Type": "application/json" });
138
+ res.end(JSON.stringify({ error: "model proxy unreachable" }));
139
+ }
140
+ record();
141
+ };
142
+ req.on("data", (chunk) => {
143
+ if (requestBytes < SCAN_LIMIT)
144
+ scanned.push(chunk);
145
+ requestBytes += chunk.length;
146
+ });
147
+ const out = send(target, {
148
+ method,
149
+ headers: forwardedHeaders(req.headers, run.modelProxy, runId),
150
+ // Set as an option, not with out.setTimeout(), so it also covers the connect.
151
+ timeout: context.idleTimeoutMs,
152
+ }, (answer) => {
153
+ status = answer.statusCode ?? 502;
154
+ res.writeHead(status, answer.headers);
155
+ answer.on("data", (chunk) => {
156
+ if (status >= 400 && errorBody.length < DETAIL_LIMIT) {
157
+ errorBody += chunk.toString("utf8");
158
+ }
159
+ });
160
+ answer.on("error", (cause) => fail("upstream_aborted", cause.message));
161
+ answer.pipe(res);
162
+ });
163
+ out.on("timeout", () => {
164
+ fail("timeout", `model proxy sent nothing for ${context.idleTimeoutMs} ms`);
165
+ out.destroy();
166
+ });
167
+ out.on("error", (cause) => fail("network_error", cause.message));
168
+ res.on("close", () => {
169
+ if (!res.writableFinished)
170
+ out.destroy();
171
+ record();
172
+ });
173
+ req.pipe(out);
174
+ }
@@ -0,0 +1,24 @@
1
+ import { type SidecarPorts } from "./config.js";
2
+ import { type Ips } from "./dns.js";
3
+ import { SidecarState } from "./state.js";
4
+ export type SidecarOptions = {
5
+ controlSecret: string;
6
+ domain: string;
7
+ ports: SidecarPorts;
8
+ ips?: Ips;
9
+ host?: string;
10
+ trustTestSourceHeader?: boolean;
11
+ fetch?: typeof fetch;
12
+ checkTimeoutMs?: number;
13
+ upstreamIdleTimeoutMs?: number;
14
+ lease?: {
15
+ ms: number;
16
+ onExpired: () => void;
17
+ };
18
+ };
19
+ export type Sidecar = {
20
+ ports: SidecarPorts;
21
+ state: SidecarState;
22
+ stop(): Promise<void>;
23
+ };
24
+ export declare function startSidecar(options: SidecarOptions): Promise<Sidecar>;
@@ -0,0 +1,106 @@
1
+ import {} from "node:dgram";
2
+ import {} from "node:http";
3
+ import { networkInterfaces } from "node:os";
4
+ import {} from "./config.js";
5
+ import { createControl } from "./control.js";
6
+ import { createDns } from "./dns.js";
7
+ import { createFakeHttp, createFakeHttps, generateTls } from "./fake-web.js";
8
+ import { createRelay, UPSTREAM_IDLE_TIMEOUT_MS } from "./relay.js";
9
+ import { SidecarState } from "./state.js";
10
+ import { CHECK_TIMEOUT_MS, createToolHook } from "./tool-hook.js";
11
+ function ownIps() {
12
+ const [first = "127.0.0.1", ...rest] = Object.values(networkInterfaces())
13
+ .flatMap((addrs) => addrs ?? [])
14
+ .filter((addr) => addr.family === "IPv4" && !addr.internal)
15
+ .map((addr) => addr.address);
16
+ return [first, ...rest];
17
+ }
18
+ const listen = (server, port, host) => new Promise((resolve, reject) => {
19
+ server.once("error", reject);
20
+ server.listen(port, host, () => {
21
+ const address = server.address();
22
+ resolve(typeof address === "object" && address ? address.port : port);
23
+ });
24
+ });
25
+ const listenUdp = (socket, port, host) => new Promise((resolve, reject) => {
26
+ socket.once("error", reject);
27
+ socket.bind(port, host, () => {
28
+ socket.on("error", () => undefined);
29
+ resolve(socket.address().port);
30
+ });
31
+ });
32
+ const close = (server) => new Promise((resolve) => {
33
+ server.close(() => resolve());
34
+ server.closeAllConnections();
35
+ });
36
+ export async function startSidecar(options) {
37
+ const state = new SidecarState();
38
+ const host = options.host ?? "0.0.0.0";
39
+ const trustTestSourceHeader = options.trustTestSourceHeader ?? false;
40
+ const { domain } = options;
41
+ const tls = await generateTls(domain);
42
+ const controller = new AbortController();
43
+ let renewedAt = Date.now();
44
+ const lease = options.lease;
45
+ const leaseTimer = lease
46
+ ? setInterval(() => {
47
+ if (Date.now() - renewedAt > lease.ms)
48
+ lease.onExpired();
49
+ }, Math.max(1, Math.floor(lease.ms / 4)))
50
+ : undefined;
51
+ leaseTimer?.unref();
52
+ const web = { state, domain, trustTestSourceHeader };
53
+ const servers = {
54
+ http: createFakeHttp(web),
55
+ https: createFakeHttps(web, tls),
56
+ relay: createRelay({
57
+ state,
58
+ trustTestSourceHeader,
59
+ idleTimeoutMs: options.upstreamIdleTimeoutMs ?? UPSTREAM_IDLE_TIMEOUT_MS,
60
+ }),
61
+ hook: createToolHook({
62
+ state,
63
+ fetch: options.fetch ?? fetch,
64
+ signal: controller.signal,
65
+ trustTestSourceHeader,
66
+ timeoutMs: options.checkTimeoutMs ?? CHECK_TIMEOUT_MS,
67
+ }),
68
+ control: createControl({
69
+ state,
70
+ secret: options.controlSecret,
71
+ caPem: tls.caPem,
72
+ renew: () => {
73
+ renewedAt = Date.now();
74
+ },
75
+ }),
76
+ };
77
+ const { ips } = options;
78
+ const dns = createDns({ state, domain }, ips ? () => ips : ownIps);
79
+ const all = Object.values(servers);
80
+ try {
81
+ const ports = {
82
+ dns: await listenUdp(dns, options.ports.dns, host),
83
+ http: await listen(servers.http, options.ports.http, host),
84
+ https: await listen(servers.https, options.ports.https, host),
85
+ relay: await listen(servers.relay, options.ports.relay, host),
86
+ hook: await listen(servers.hook, options.ports.hook, host),
87
+ control: await listen(servers.control, options.ports.control, host),
88
+ };
89
+ return {
90
+ ports,
91
+ state,
92
+ async stop() {
93
+ clearInterval(leaseTimer);
94
+ controller.abort();
95
+ dns.close();
96
+ await Promise.all(all.map((server) => close(server)));
97
+ },
98
+ };
99
+ }
100
+ catch (error) {
101
+ clearInterval(leaseTimer);
102
+ dns.close();
103
+ await Promise.all(all.filter((server) => server.listening).map((server) => close(server)));
104
+ throw error;
105
+ }
106
+ }
@@ -0,0 +1,45 @@
1
+ import { type IncomingMessage } from "node:http";
2
+ import { type Hit, type ModelCall, type ModelProxy, type SecretMatch, type Secrets, type ToolCall, type ToolCallCheck } from "../types.js";
3
+ export declare const MAX_ENTRIES = 10000;
4
+ export declare const MAX_LOG_BYTES: number;
5
+ export declare const SCAN_LIMIT: number;
6
+ export type ServedPage = {
7
+ body: string;
8
+ contentType: string;
9
+ status: number;
10
+ };
11
+ export type Log<T> = {
12
+ entries: T[];
13
+ dropped: number;
14
+ bytes: number;
15
+ /** The secret matches of each entry, at the entry's index. */
16
+ matches: SecretMatch[][];
17
+ holders: Map<string, number>;
18
+ };
19
+ type Entry = Hit | ToolCall | ModelCall;
20
+ export type RunState = {
21
+ ip: string;
22
+ pages: Record<string, ServedPage>;
23
+ secrets: Secrets;
24
+ modelProxy: ModelProxy;
25
+ toolCallCheck?: ToolCallCheck | undefined;
26
+ hits: Log<Hit>;
27
+ toolCalls: Log<ToolCall>;
28
+ modelCalls: Log<ModelCall>;
29
+ };
30
+ export declare const normalizeIp: (address: string | undefined) => string;
31
+ export declare function sourceIp(req: IncomingMessage, trustTestSourceHeader: boolean): string;
32
+ export declare const headersOf: (req: IncomingMessage) => Record<string, string>;
33
+ export declare const headerLines: (headers: Record<string, string>) => string;
34
+ /** Evicts oldest entries past the caps, but keeps one per secret match so a flood cannot erase evidence. */
35
+ export declare function push<T extends Entry>(log: Log<T>, entry: T, matches?: SecretMatch[]): void;
36
+ export declare function drain<T>(log: Log<T>): Log<T>;
37
+ export declare class SidecarState {
38
+ readonly runs: Map<string, RunState>;
39
+ readonly unattributed: Log<Hit>;
40
+ register(id: string, run: Pick<RunState, "ip" | "pages" | "secrets" | "modelProxy" | "toolCallCheck">): "ok" | "run" | "ip";
41
+ runEntryForIp(ip: string): [string, RunState] | undefined;
42
+ runForIp(ip: string): RunState | undefined;
43
+ recordHit(ip: string, hit: Omit<Hit, "at" | "secrets">, scannedBody?: string): void;
44
+ }
45
+ export {};
@@ -0,0 +1,93 @@
1
+ import {} from "node:http";
2
+ import { findSecrets } from "../secrets.js";
3
+ import {} from "../types.js";
4
+ export const MAX_ENTRIES = 10_000;
5
+ export const MAX_LOG_BYTES = 4 * 1024 * 1024;
6
+ export const SCAN_LIMIT = 16 * 1024 * 1024;
7
+ export const normalizeIp = (address) => (address ?? "").replace(/^::ffff:/, "");
8
+ export function sourceIp(req, trustTestSourceHeader) {
9
+ const testSource = req.headers["x-test-source-ip"];
10
+ return trustTestSourceHeader && typeof testSource === "string"
11
+ ? testSource
12
+ : normalizeIp(req.socket.remoteAddress);
13
+ }
14
+ export const headersOf = (req) => Object.fromEntries(Object.entries(req.headersDistinct).map(([name, values = []]) => [
15
+ name,
16
+ values.join(", "),
17
+ ]));
18
+ export const headerLines = (headers) => Object.entries(headers)
19
+ .map(([name, value]) => `${name}: ${value}`)
20
+ .join("\n");
21
+ const emptyLog = () => ({
22
+ entries: [],
23
+ dropped: 0,
24
+ bytes: 0,
25
+ matches: [],
26
+ holders: new Map(),
27
+ });
28
+ const sizeOf = (entry) => Buffer.byteLength(JSON.stringify(entry));
29
+ const keyOf = (match) => `${match.secret}:${match.encoding}`;
30
+ const isOverCap = (log) => log.entries.length > MAX_ENTRIES || log.bytes > MAX_LOG_BYTES;
31
+ const countHolders = (log, matches, by) => {
32
+ for (const match of matches) {
33
+ const key = keyOf(match);
34
+ log.holders.set(key, (log.holders.get(key) ?? 0) + by);
35
+ }
36
+ };
37
+ const isRedundant = (log, matches) => matches.every((match) => log.holders.get(keyOf(match)) !== 1);
38
+ /** Evicts oldest entries past the caps, but keeps one per secret match so a flood cannot erase evidence. */
39
+ export function push(log, entry, matches = "secrets" in entry ? entry.secrets : []) {
40
+ log.entries.push(entry);
41
+ log.matches.push(matches);
42
+ log.bytes += sizeOf(entry);
43
+ countHolders(log, matches, 1);
44
+ while (isOverCap(log)) {
45
+ const index = log.matches.findIndex((held) => isRedundant(log, held));
46
+ if (index === -1)
47
+ break;
48
+ const [evicted] = log.entries.splice(index, 1);
49
+ const [evictedMatches = []] = log.matches.splice(index, 1);
50
+ if (evicted)
51
+ log.bytes -= sizeOf(evicted);
52
+ countHolders(log, evictedMatches, -1);
53
+ log.dropped += 1;
54
+ }
55
+ }
56
+ export function drain(log) {
57
+ const drained = { ...log };
58
+ Object.assign(log, emptyLog());
59
+ return drained;
60
+ }
61
+ export class SidecarState {
62
+ runs = new Map();
63
+ unattributed = emptyLog();
64
+ register(id, run) {
65
+ if (this.runs.has(id))
66
+ return "run";
67
+ if (this.runForIp(run.ip))
68
+ return "ip";
69
+ this.runs.set(id, {
70
+ ...run,
71
+ hits: emptyLog(),
72
+ toolCalls: emptyLog(),
73
+ modelCalls: emptyLog(),
74
+ });
75
+ return "ok";
76
+ }
77
+ runEntryForIp(ip) {
78
+ return [...this.runs].find(([, run]) => run.ip === ip);
79
+ }
80
+ runForIp(ip) {
81
+ return this.runEntryForIp(ip)?.[1];
82
+ }
83
+ recordHit(ip, hit, scannedBody = hit.body) {
84
+ const run = this.runForIp(ip);
85
+ const headers = headerLines(hit.headers ?? {});
86
+ const text = [hit.host, hit.path, headers, scannedBody].join("\n");
87
+ push(run?.hits ?? this.unattributed, {
88
+ ...hit,
89
+ at: new Date().toISOString(),
90
+ secrets: run ? findSecrets(text, run.secrets) : [],
91
+ });
92
+ }
93
+ }
@@ -0,0 +1,13 @@
1
+ import { type Server } from "node:http";
2
+ import { type ToolCall } from "../types.js";
3
+ import { type SidecarState } from "./state.js";
4
+ export declare const CHECK_TIMEOUT_MS = 25000;
5
+ export declare function actionOf(response: unknown): ToolCall["action"];
6
+ export type ToolHookContext = {
7
+ state: SidecarState;
8
+ fetch: typeof fetch;
9
+ signal: AbortSignal;
10
+ trustTestSourceHeader: boolean;
11
+ timeoutMs: number;
12
+ };
13
+ export declare function createToolHook(context: ToolHookContext): Server;