@oculisecurity/cli 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 (85) hide show
  1. package/LICENSE.txt +201 -0
  2. package/README.md +67 -0
  3. package/dist/cli.d.ts +18 -0
  4. package/dist/cli.js +565 -0
  5. package/dist/commands/init.d.ts +14 -0
  6. package/dist/commands/init.js +135 -0
  7. package/dist/commands/report.d.ts +33 -0
  8. package/dist/commands/report.js +145 -0
  9. package/dist/commands/serve.d.ts +27 -0
  10. package/dist/commands/serve.js +163 -0
  11. package/dist/commands/tail.d.ts +7 -0
  12. package/dist/commands/tail.js +211 -0
  13. package/dist/commands/uninstall.d.ts +13 -0
  14. package/dist/commands/uninstall.js +111 -0
  15. package/dist/config.d.ts +17 -0
  16. package/dist/config.js +90 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +35 -0
  19. package/dist/init.d.ts +9 -0
  20. package/dist/init.js +50 -0
  21. package/dist/install/claude-code.d.ts +13 -0
  22. package/dist/install/claude-code.js +118 -0
  23. package/dist/install/cursor.d.ts +13 -0
  24. package/dist/install/cursor.js +119 -0
  25. package/dist/install/detect.d.ts +5 -0
  26. package/dist/install/detect.js +64 -0
  27. package/dist/middleware/auth.d.ts +15 -0
  28. package/dist/middleware/auth.js +116 -0
  29. package/dist/routes/adapters/claude-code.d.ts +38 -0
  30. package/dist/routes/adapters/claude-code.js +125 -0
  31. package/dist/routes/adapters/cursor.d.ts +21 -0
  32. package/dist/routes/adapters/cursor.js +139 -0
  33. package/dist/routes/adapters/index.d.ts +16 -0
  34. package/dist/routes/adapters/index.js +56 -0
  35. package/dist/routes/adapters/router.d.ts +31 -0
  36. package/dist/routes/adapters/router.js +97 -0
  37. package/dist/routes/adapters/schema.d.ts +141 -0
  38. package/dist/routes/adapters/schema.js +83 -0
  39. package/dist/routes/adapters/windsurf.d.ts +6 -0
  40. package/dist/routes/adapters/windsurf.js +48 -0
  41. package/dist/routes/admin.d.ts +15 -0
  42. package/dist/routes/admin.js +399 -0
  43. package/dist/routes/call.d.ts +13 -0
  44. package/dist/routes/call.js +68 -0
  45. package/dist/routes/events.d.ts +7 -0
  46. package/dist/routes/events.js +125 -0
  47. package/dist/routes/health.d.ts +2 -0
  48. package/dist/routes/health.js +12 -0
  49. package/dist/routes/hooks.d.ts +11 -0
  50. package/dist/routes/hooks.js +166 -0
  51. package/dist/routes/mcp.d.ts +10 -0
  52. package/dist/routes/mcp.js +170 -0
  53. package/dist/routes/openai-tools.d.ts +9 -0
  54. package/dist/routes/openai-tools.js +121 -0
  55. package/dist/server.d.ts +11 -0
  56. package/dist/server.js +118 -0
  57. package/dist/services/audit.d.ts +92 -0
  58. package/dist/services/audit.js +388 -0
  59. package/dist/services/data-dir.d.ts +7 -0
  60. package/dist/services/data-dir.js +61 -0
  61. package/dist/services/local-policy-templates.d.ts +9 -0
  62. package/dist/services/local-policy-templates.js +47 -0
  63. package/dist/services/local-policy.d.ts +39 -0
  64. package/dist/services/local-policy.js +172 -0
  65. package/dist/services/policy-store.d.ts +82 -0
  66. package/dist/services/policy-store.js +331 -0
  67. package/dist/services/policy.d.ts +8 -0
  68. package/dist/services/policy.js +126 -0
  69. package/dist/services/ratelimit.d.ts +26 -0
  70. package/dist/services/ratelimit.js +60 -0
  71. package/dist/services/sanitizer.d.ts +9 -0
  72. package/dist/services/sanitizer.js +73 -0
  73. package/dist/services/sqlite-loader.d.ts +4 -0
  74. package/dist/services/sqlite-loader.js +16 -0
  75. package/dist/services/telemetry-log.d.ts +76 -0
  76. package/dist/services/telemetry-log.js +260 -0
  77. package/dist/services/tool-executor.d.ts +46 -0
  78. package/dist/services/tool-executor.js +167 -0
  79. package/dist/services/upstream.d.ts +18 -0
  80. package/dist/services/upstream.js +72 -0
  81. package/dist/types.d.ts +112 -0
  82. package/dist/types.js +3 -0
  83. package/package.json +72 -0
  84. package/public/favicon.svg +4 -0
  85. package/public/index.html +3893 -0
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerHooksRoute = registerHooksRoute;
4
+ const uuid_1 = require("uuid");
5
+ const router_1 = require("./adapters/router");
6
+ function registerHooksRoute(app, ctx) {
7
+ app.post('/v1/hooks', async (request, reply) => {
8
+ const body = request.body;
9
+ if (!body || typeof body !== 'object') {
10
+ reply.code(400).send({ error: 'Invalid request body' });
11
+ return;
12
+ }
13
+ // ── Adapter selection ───────────────────────────────────────────────────
14
+ // X-Oculi-Adapter header lets the CLI bypass auto-detection entirely.
15
+ const explicitAdapter = request.headers['x-oculi-adapter'];
16
+ const routed = (0, router_1.route)(body, explicitAdapter);
17
+ if (!routed) {
18
+ reply.code(400).send({
19
+ error: 'Unrecognised hook payload format. Supported: claude-code, cursor, windsurf.',
20
+ });
21
+ return;
22
+ }
23
+ const { adapter, event } = routed;
24
+ // ── From here the pipeline is completely adapter-agnostic ───────────────
25
+ const requestId = (0, uuid_1.v4)();
26
+ const sessionId = event.session_id ?? requestId;
27
+ const ip = request.ip;
28
+ const args = event.tool_args ?? {};
29
+ const argsJson = JSON.stringify(args);
30
+ const storedArgsJson = ctx.audit.storeFullArgs ? argsJson : null;
31
+ const startMs = Date.now();
32
+ const tool = event.tool ?? '__stop__';
33
+ request.log.info({ adapter: routed.adapterName, tool: event.tool, phase: event.phase }, 'hook received');
34
+ // ── Pre-decided fast path ───────────────────────────────────────────────
35
+ // When the CLI sends X-Oculi-Local-Decision (deny | warn), the local policy
36
+ // engine has already executed. Record the event verbatim and skip rate
37
+ // limit + central policy. Warn is persisted as decision='allow' with a
38
+ // distinguishing reason prefix because the audit_logs CHECK constraint only
39
+ // allows 'allow' | 'deny'; surfacing warn as a first-class decision is a
40
+ // follow-up that requires a table-rebuild migration.
41
+ const localDec = request.headers['x-oculi-local-decision'];
42
+ if (localDec === 'deny' || localDec === 'warn') {
43
+ const localReasonHeader = request.headers['x-oculi-local-reason'];
44
+ const localReason = (Array.isArray(localReasonHeader) ? localReasonHeader[0] : localReasonHeader) ??
45
+ 'oculi: pre-decided';
46
+ ctx.audit.log({
47
+ requestId,
48
+ timestamp: event.timestamp ?? new Date().toISOString(),
49
+ actor: event.actor,
50
+ orgId: 'default',
51
+ upstreamId: `ext:${event.ide_source}`,
52
+ tool,
53
+ argsHash: ctx.audit.hash(args),
54
+ argsJson: storedArgsJson,
55
+ decision: localDec === 'deny' ? 'deny' : 'allow',
56
+ reason: localReason,
57
+ latencyMs: Date.now() - startMs,
58
+ outcome: localDec === 'deny' ? 'denied' : 'success',
59
+ ip,
60
+ sessionId,
61
+ });
62
+ reply.code(200).send({});
63
+ return;
64
+ }
65
+ // ── Post / complete phase: telemetry only ───────────────────────────────
66
+ if (event.phase === 'post' || event.phase === 'complete') {
67
+ const resultStr = event.tool_result != null ? JSON.stringify(event.tool_result) : null;
68
+ const responseJson = resultStr
69
+ ? resultStr.length <= 8192 ? resultStr : resultStr.slice(0, 8192) + '…'
70
+ : null;
71
+ ctx.audit.log({
72
+ requestId,
73
+ timestamp: event.timestamp ?? new Date().toISOString(),
74
+ actor: event.actor,
75
+ orgId: 'default',
76
+ upstreamId: `ext:${event.ide_source}`,
77
+ tool,
78
+ argsHash: ctx.audit.hash(args),
79
+ argsJson: storedArgsJson,
80
+ decision: 'allow',
81
+ reason: 'telemetry',
82
+ latencyMs: event.duration_ms ?? 0,
83
+ outcome: event.error ? 'error' : 'success',
84
+ responseHash: event.tool_result != null ? ctx.audit.hash(event.tool_result) : undefined,
85
+ responseJson,
86
+ ip,
87
+ sessionId,
88
+ });
89
+ reply.code(202).send({ received: 1 });
90
+ return;
91
+ }
92
+ // ── Pre-phase: rate limit → policy → audit → respond ───────────────────
93
+ // 1. Rate limiting
94
+ const rlKey = `${event.actor}:${tool}`;
95
+ const rlResult = ctx.rateLimiter.check(rlKey);
96
+ if (!rlResult.allowed) {
97
+ ctx.audit.log({
98
+ requestId,
99
+ timestamp: new Date().toISOString(),
100
+ actor: event.actor,
101
+ orgId: 'default',
102
+ upstreamId: `ext:${event.ide_source}`,
103
+ tool,
104
+ argsHash: ctx.audit.hash(args),
105
+ argsJson: storedArgsJson,
106
+ decision: 'deny',
107
+ reason: 'rate limit exceeded',
108
+ latencyMs: Date.now() - startMs,
109
+ outcome: 'denied',
110
+ ip,
111
+ sessionId,
112
+ });
113
+ reply
114
+ .code(adapter.denyStatusCode())
115
+ .send(JSON.parse(adapter.formatDeny('rate limit exceeded', event)));
116
+ return;
117
+ }
118
+ // 2. Policy evaluation
119
+ const policyInput = {
120
+ actor: event.actor,
121
+ orgId: 'default',
122
+ roles: ['developer'],
123
+ upstreamId: `ext:${event.ide_source}`,
124
+ tool,
125
+ args,
126
+ time: new Date().toISOString(),
127
+ ip,
128
+ sessionId,
129
+ };
130
+ let decision;
131
+ try {
132
+ decision = await ctx.policy.evaluate(policyInput);
133
+ }
134
+ catch (err) {
135
+ // Policy eval failure → fail open so IDEs aren't blocked
136
+ request.log.error({ err }, 'policy eval error in hook — failing open');
137
+ reply.code(200).send(JSON.parse(adapter.formatAllow(event)));
138
+ return;
139
+ }
140
+ // 3. Audit
141
+ ctx.audit.log({
142
+ requestId,
143
+ timestamp: new Date().toISOString(),
144
+ actor: event.actor,
145
+ orgId: 'default',
146
+ upstreamId: `ext:${event.ide_source}`,
147
+ tool,
148
+ argsHash: ctx.audit.hash(args),
149
+ argsJson: storedArgsJson,
150
+ decision: decision.allow ? 'allow' : 'deny',
151
+ reason: decision.reason,
152
+ latencyMs: Date.now() - startMs,
153
+ outcome: decision.allow ? 'success' : 'denied',
154
+ ip,
155
+ sessionId,
156
+ });
157
+ // 4. Respond in the adapter's native wire format
158
+ if (!decision.allow) {
159
+ reply
160
+ .code(adapter.denyStatusCode())
161
+ .send(JSON.parse(adapter.formatDeny(decision.reason, event)));
162
+ return;
163
+ }
164
+ reply.code(200).send(JSON.parse(adapter.formatAllow(event)));
165
+ });
166
+ }
@@ -0,0 +1,10 @@
1
+ import { FastifyInstance } from 'fastify';
2
+ import { UpstreamConnector } from '../services/upstream';
3
+ import { ToolExecutor } from '../services/tool-executor';
4
+ interface McpRouteContext {
5
+ executor: ToolExecutor;
6
+ upstream: UpstreamConnector;
7
+ jwtSecret: string;
8
+ }
9
+ export declare function registerMcpRoutes(app: FastifyInstance, ctx: McpRouteContext): void;
10
+ export {};
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerMcpRoutes = registerMcpRoutes;
4
+ const uuid_1 = require("uuid");
5
+ const auth_1 = require("../middleware/auth");
6
+ const MCP_PROTOCOL_VERSION = '2024-11-05';
7
+ const GATEWAY_VERSION = '0.1.0';
8
+ // ---------------------------------------------------------------------------
9
+ // In-process SSE session store
10
+ // TODO: multi-replica — replace with Redis pub/sub so sessions survive restarts
11
+ // and work across gateway replicas.
12
+ // ---------------------------------------------------------------------------
13
+ const sseSessions = new Map();
14
+ /** Split "{upstreamId}__{toolName}" — returns null if format is invalid. */
15
+ function parseToolName(namespaced) {
16
+ const idx = namespaced.indexOf('__');
17
+ if (idx === -1)
18
+ return null;
19
+ return { upstreamId: namespaced.slice(0, idx), tool: namespaced.slice(idx + 2) };
20
+ }
21
+ function buildToolList(upstream) {
22
+ return upstream.listUpstreams().flatMap((u) => (u.tools ?? []).map((toolName) => ({
23
+ name: `${u.id}__${toolName}`,
24
+ description: `${toolName} on ${u.name}`,
25
+ inputSchema: { type: 'object', additionalProperties: true },
26
+ })));
27
+ }
28
+ function jsonRpcError(id, code, message) {
29
+ return { jsonrpc: '2.0', id, error: { code, message } };
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // Route registration
33
+ // ---------------------------------------------------------------------------
34
+ function registerMcpRoutes(app, ctx) {
35
+ // -------------------------------------------------------------------------
36
+ // GET /mcp — establish SSE push channel (used by Claude Desktop / Code and
37
+ // any other MCP client that prefers the legacy SSE transport).
38
+ // -------------------------------------------------------------------------
39
+ app.get('/mcp', async (request, reply) => {
40
+ const actor = (0, auth_1.resolveActor)(request, ctx.jwtSecret);
41
+ if (!actor) {
42
+ reply.code(401).send({ error: 'Unauthorized. Provide Authorization: Bearer <token> header or ?token= query parameter.' });
43
+ return;
44
+ }
45
+ const sessionId = (0, uuid_1.v4)();
46
+ // Take over the raw HTTP response — Fastify must not touch it after this
47
+ reply.raw.setHeader('Content-Type', 'text/event-stream');
48
+ reply.raw.setHeader('Cache-Control', 'no-cache');
49
+ reply.raw.setHeader('Connection', 'keep-alive');
50
+ reply.raw.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering
51
+ reply.raw.flushHeaders();
52
+ sseSessions.set(sessionId, reply);
53
+ // Tell the client which URL to POST messages to
54
+ const endpointUrl = `/mcp?session=${sessionId}`;
55
+ reply.raw.write(`data: ${JSON.stringify({ type: 'endpoint', uri: endpointUrl })}\n\n`);
56
+ // Heartbeat every 30 s to keep the connection alive through proxies
57
+ const heartbeat = setInterval(() => {
58
+ if (!reply.raw.writableEnded) {
59
+ reply.raw.write(': ping\n\n');
60
+ }
61
+ }, 30_000);
62
+ request.raw.on('close', () => {
63
+ clearInterval(heartbeat);
64
+ sseSessions.delete(sessionId);
65
+ });
66
+ // Fastify's reply lifecycle is now bypassed — prevent it from finalising
67
+ await new Promise((resolve) => request.raw.on('close', resolve));
68
+ });
69
+ // -------------------------------------------------------------------------
70
+ // POST /mcp — JSON-RPC 2.0 message handler (MCP 2024-11-05 streamable HTTP
71
+ // transport). Supports both:
72
+ // - Streamable HTTP: respond directly in the HTTP body (no ?session=)
73
+ // - SSE transport: forward response over the SSE channel (?session=<id>)
74
+ // -------------------------------------------------------------------------
75
+ app.post('/mcp', async (request, reply) => {
76
+ const actor = (0, auth_1.resolveActor)(request, ctx.jwtSecret);
77
+ if (!actor) {
78
+ reply.code(401).send({ error: 'Unauthorized' });
79
+ return;
80
+ }
81
+ const msg = request.body;
82
+ const sessionId = request.query.session;
83
+ const sseReply = sessionId ? sseSessions.get(sessionId) : undefined;
84
+ /** Send a JSON-RPC response — either over SSE or directly as HTTP body. */
85
+ const respond = (payload) => {
86
+ if (sseReply && !sseReply.raw.writableEnded) {
87
+ sseReply.raw.write(`data: ${JSON.stringify(payload)}\n\n`);
88
+ reply.code(202).send();
89
+ }
90
+ else {
91
+ reply.code(200).send(payload);
92
+ }
93
+ };
94
+ // MCP protocol: stateless — clients may call tools/call without initialize.
95
+ // Note: initialize is optional for clients that skip handshake.
96
+ switch (msg.method) {
97
+ // -------------------------------------------------------------------
98
+ case 'initialize':
99
+ respond({
100
+ jsonrpc: '2.0',
101
+ id: msg.id,
102
+ result: {
103
+ protocolVersion: MCP_PROTOCOL_VERSION,
104
+ capabilities: { tools: {} },
105
+ serverInfo: { name: 'oculi-security-gateway', version: GATEWAY_VERSION },
106
+ },
107
+ });
108
+ return;
109
+ // -------------------------------------------------------------------
110
+ case 'tools/list':
111
+ respond({
112
+ jsonrpc: '2.0',
113
+ id: msg.id,
114
+ result: { tools: buildToolList(ctx.upstream) },
115
+ });
116
+ return;
117
+ // -------------------------------------------------------------------
118
+ case 'tools/call': {
119
+ const params = msg.params;
120
+ if (!params?.name) {
121
+ respond(jsonRpcError(msg.id, -32602, 'params.name is required'));
122
+ return;
123
+ }
124
+ const parsed = parseToolName(params.name);
125
+ if (!parsed) {
126
+ respond(jsonRpcError(msg.id, -32602, `Invalid tool name '${params.name}'. Expected format: '{upstreamId}__{toolName}'`));
127
+ return;
128
+ }
129
+ const execResult = await ctx.executor.execute({
130
+ actor,
131
+ upstreamId: parsed.upstreamId,
132
+ tool: parsed.tool,
133
+ args: params.arguments ?? {},
134
+ ip: request.ip,
135
+ });
136
+ // MCP spec: tool errors use isError:true in result, not JSON-RPC error
137
+ switch (execResult.kind) {
138
+ case 'allow':
139
+ respond({
140
+ jsonrpc: '2.0',
141
+ id: msg.id,
142
+ result: {
143
+ content: [{ type: 'text', text: JSON.stringify(execResult.result) }],
144
+ isError: false,
145
+ },
146
+ });
147
+ break;
148
+ case 'deny':
149
+ case 'error':
150
+ respond({
151
+ jsonrpc: '2.0',
152
+ id: msg.id,
153
+ result: {
154
+ content: [{
155
+ type: 'text',
156
+ text: execResult.kind === 'deny' ? execResult.reason : execResult.message,
157
+ }],
158
+ isError: true,
159
+ },
160
+ });
161
+ break;
162
+ }
163
+ return;
164
+ }
165
+ // -------------------------------------------------------------------
166
+ default:
167
+ respond(jsonRpcError(msg.id, -32601, `Method not found: ${String(msg.method)}`));
168
+ }
169
+ });
170
+ }
@@ -0,0 +1,9 @@
1
+ import { FastifyInstance } from 'fastify';
2
+ import { UpstreamConnector } from '../services/upstream';
3
+ import { ToolExecutor } from '../services/tool-executor';
4
+ interface OpenAIToolsRouteContext {
5
+ executor: ToolExecutor;
6
+ upstream: UpstreamConnector;
7
+ }
8
+ export declare function registerOpenAIToolsRoutes(app: FastifyInstance, ctx: OpenAIToolsRouteContext): void;
9
+ export {};
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerOpenAIToolsRoutes = registerOpenAIToolsRoutes;
4
+ /** Split "{upstreamId}__{toolName}" into parts. Returns null if format invalid. */
5
+ function parseToolName(namespaced) {
6
+ const idx = namespaced.indexOf('__');
7
+ if (idx === -1)
8
+ return null;
9
+ return { upstreamId: namespaced.slice(0, idx), tool: namespaced.slice(idx + 2) };
10
+ }
11
+ function registerOpenAIToolsRoutes(app, ctx) {
12
+ // -----------------------------------------------------------------------
13
+ // GET /v1/openai/tools — list all tools in OpenAI function schema format
14
+ // -----------------------------------------------------------------------
15
+ app.get('/v1/openai/tools', async (request, reply) => {
16
+ if (!request.actor?.actor) {
17
+ reply.code(401).send({ error: 'Unauthorized' });
18
+ return;
19
+ }
20
+ const data = ctx.upstream.listUpstreams().flatMap((u) => (u.tools ?? []).map((toolName) => ({
21
+ type: 'function',
22
+ function: {
23
+ name: `${u.id}__${toolName}`,
24
+ description: `${toolName} on ${u.name}`,
25
+ parameters: {
26
+ type: 'object',
27
+ properties: {},
28
+ additionalProperties: true,
29
+ },
30
+ },
31
+ })));
32
+ reply.send({ object: 'list', data });
33
+ });
34
+ // -----------------------------------------------------------------------
35
+ // POST /v1/openai/tools — execute an array of OpenAI tool_calls
36
+ // -----------------------------------------------------------------------
37
+ app.post('/v1/openai/tools', {
38
+ schema: {
39
+ body: {
40
+ type: 'object',
41
+ required: ['tool_calls'],
42
+ properties: {
43
+ tool_calls: { type: 'array' },
44
+ session_id: { type: 'string' },
45
+ },
46
+ },
47
+ },
48
+ }, async (request, reply) => {
49
+ if (!request.actor?.actor) {
50
+ reply.code(401).send({ error: 'Unauthorized' });
51
+ return;
52
+ }
53
+ const { tool_calls, session_id } = request.body;
54
+ if (!Array.isArray(tool_calls) || tool_calls.length === 0) {
55
+ reply.code(400).send({ error: 'tool_calls must be a non-empty array' });
56
+ return;
57
+ }
58
+ const results = [];
59
+ // Execute sequentially — avoids burst tripping rate limiter and matches
60
+ // how OpenAI clients consume tool results (ordered array).
61
+ for (const call of tool_calls) {
62
+ if (call.type !== 'function') {
63
+ results.push({
64
+ tool_call_id: call.id,
65
+ role: 'tool',
66
+ content: JSON.stringify({ error: `Unsupported tool call type: ${String(call.type)}` }),
67
+ });
68
+ continue;
69
+ }
70
+ const parsed = parseToolName(call.function.name);
71
+ if (!parsed) {
72
+ results.push({
73
+ tool_call_id: call.id,
74
+ role: 'tool',
75
+ content: JSON.stringify({
76
+ error: `Invalid tool name '${call.function.name}'. Expected format: '{upstreamId}__{toolName}'`,
77
+ }),
78
+ });
79
+ continue;
80
+ }
81
+ let args;
82
+ try {
83
+ args = JSON.parse(call.function.arguments);
84
+ }
85
+ catch {
86
+ results.push({
87
+ tool_call_id: call.id,
88
+ role: 'tool',
89
+ content: JSON.stringify({ error: 'Failed to parse tool arguments as JSON' }),
90
+ });
91
+ continue;
92
+ }
93
+ const execResult = await ctx.executor.execute({
94
+ actor: request.actor,
95
+ upstreamId: parsed.upstreamId,
96
+ tool: parsed.tool,
97
+ args,
98
+ sessionId: session_id,
99
+ ip: request.ip,
100
+ });
101
+ let content;
102
+ switch (execResult.kind) {
103
+ case 'allow':
104
+ content =
105
+ typeof execResult.result === 'string'
106
+ ? execResult.result
107
+ : JSON.stringify(execResult.result);
108
+ break;
109
+ case 'deny':
110
+ content = JSON.stringify({ error: execResult.reason });
111
+ break;
112
+ case 'error':
113
+ content = JSON.stringify({ error: execResult.message });
114
+ break;
115
+ }
116
+ results.push({ tool_call_id: call.id, role: 'tool', content });
117
+ }
118
+ // Always HTTP 200 — per-tool errors are encoded in content, per OpenAI conventions
119
+ reply.code(200).send(results);
120
+ });
121
+ }
@@ -0,0 +1,11 @@
1
+ import { FastifyInstance } from 'fastify';
2
+ import { AppConfig } from './config';
3
+ import { AuditService } from './services/audit';
4
+ import { RateLimiter } from './services/ratelimit';
5
+ export interface GatewayServer {
6
+ app: FastifyInstance;
7
+ audit: AuditService;
8
+ rateLimiter: RateLimiter;
9
+ close: () => Promise<void>;
10
+ }
11
+ export declare function buildServer(config: AppConfig): Promise<GatewayServer>;
package/dist/server.js ADDED
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildServer = buildServer;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fastify_1 = __importDefault(require("fastify"));
9
+ const cors_1 = __importDefault(require("@fastify/cors"));
10
+ const static_1 = __importDefault(require("@fastify/static"));
11
+ const auth_1 = require("./middleware/auth");
12
+ const health_1 = require("./routes/health");
13
+ const call_1 = require("./routes/call");
14
+ const admin_1 = require("./routes/admin");
15
+ const events_1 = require("./routes/events");
16
+ const hooks_1 = require("./routes/hooks");
17
+ const openai_tools_1 = require("./routes/openai-tools");
18
+ const mcp_1 = require("./routes/mcp");
19
+ const policy_1 = require("./services/policy");
20
+ const audit_1 = require("./services/audit");
21
+ const upstream_1 = require("./services/upstream");
22
+ const ratelimit_1 = require("./services/ratelimit");
23
+ const policy_store_1 = require("./services/policy-store");
24
+ const tool_executor_1 = require("./services/tool-executor");
25
+ async function buildServer(config) {
26
+ const app = (0, fastify_1.default)({
27
+ logger: config.logLevel !== 'silent'
28
+ ? // Route pino to stderr so it can't pollute the `oculi serve` banner
29
+ // on stdout. Logs still print, just to the appropriate stream.
30
+ { level: config.logLevel, stream: process.stderr }
31
+ : false,
32
+ trustProxy: true, // needed for accurate request.ip behind Docker/nginx
33
+ });
34
+ // -----------------------------------------------------------------------
35
+ // Plugins
36
+ // -----------------------------------------------------------------------
37
+ await app.register(cors_1.default, { origin: true });
38
+ // Serve admin UI from public/
39
+ const publicDir = path_1.default.join(__dirname, '..', 'public');
40
+ await app.register(static_1.default, {
41
+ root: publicDir,
42
+ prefix: '/admin',
43
+ maxAge: 0,
44
+ immutable: false,
45
+ setHeaders(res, filePath) {
46
+ if (filePath.endsWith('.html')) {
47
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
48
+ res.setHeader('Pragma', 'no-cache');
49
+ res.setHeader('Expires', '0');
50
+ }
51
+ },
52
+ });
53
+ // -----------------------------------------------------------------------
54
+ // Services
55
+ // -----------------------------------------------------------------------
56
+ const audit = new audit_1.AuditService(config);
57
+ const policy = new policy_1.PolicyService(config);
58
+ const policyStore = new policy_store_1.PolicyStore(config);
59
+ const upstream = new upstream_1.UpstreamConnector(config);
60
+ const rateLimiter = new ratelimit_1.RateLimiter(config.rateLimitCapacity, config.rateLimitRefillPerSecond);
61
+ // Periodically clean up stale rate-limit buckets
62
+ const cleanupInterval = setInterval(() => rateLimiter.cleanup(), 60_000);
63
+ cleanupInterval.unref(); // don't prevent process exit
64
+ // Serve favicon at root for browsers
65
+ app.get('/favicon.svg', (_req, reply) => {
66
+ reply.type('image/svg+xml').sendFile('favicon.svg');
67
+ });
68
+ app.get('/favicon.ico', (_req, reply) => {
69
+ reply.type('image/svg+xml').sendFile('favicon.svg');
70
+ });
71
+ // -----------------------------------------------------------------------
72
+ // Auth middleware (all routes except /health and /admin static)
73
+ // -----------------------------------------------------------------------
74
+ (0, auth_1.registerAuthMiddleware)(app, config.jwtSecret);
75
+ // -----------------------------------------------------------------------
76
+ // Routes
77
+ // -----------------------------------------------------------------------
78
+ (0, health_1.registerHealthRoutes)(app);
79
+ const executor = new tool_executor_1.ToolExecutor({ policy, audit, upstream, rateLimiter });
80
+ (0, call_1.registerCallRoutes)(app, { policy, audit, upstream, rateLimiter });
81
+ (0, openai_tools_1.registerOpenAIToolsRoutes)(app, { executor, upstream });
82
+ (0, mcp_1.registerMcpRoutes)(app, { executor, upstream, jwtSecret: config.jwtSecret });
83
+ (0, admin_1.registerAdminRoutes)(app, { audit, upstream, config, policyStore, policy });
84
+ (0, events_1.registerEventsRoute)(app, { audit });
85
+ (0, hooks_1.registerHooksRoute)(app, { policy, audit, rateLimiter });
86
+ // -----------------------------------------------------------------------
87
+ // 404 handler — SPA catch-all for /admin/* paths
88
+ // -----------------------------------------------------------------------
89
+ app.setNotFoundHandler((req, reply) => {
90
+ if (req.method === 'GET' && req.url.startsWith('/admin/')) {
91
+ reply.type('text/html').sendFile('index.html');
92
+ return;
93
+ }
94
+ reply.code(404).send({ error: 'Not found' });
95
+ });
96
+ // -----------------------------------------------------------------------
97
+ // Error handler
98
+ // -----------------------------------------------------------------------
99
+ app.setErrorHandler((error, _req, reply) => {
100
+ app.log.error(error);
101
+ if (error.statusCode === 400) {
102
+ reply.code(400).send({ error: error.message, code: 'VALIDATION_ERROR' });
103
+ return;
104
+ }
105
+ reply.code(500).send({ error: 'Internal server error' });
106
+ });
107
+ return {
108
+ app,
109
+ audit,
110
+ rateLimiter,
111
+ async close() {
112
+ clearInterval(cleanupInterval);
113
+ await app.close();
114
+ audit.close();
115
+ policyStore.close();
116
+ },
117
+ };
118
+ }