@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.
- package/LICENSE.txt +201 -0
- package/README.md +67 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +565 -0
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +135 -0
- package/dist/commands/report.d.ts +33 -0
- package/dist/commands/report.js +145 -0
- package/dist/commands/serve.d.ts +27 -0
- package/dist/commands/serve.js +163 -0
- package/dist/commands/tail.d.ts +7 -0
- package/dist/commands/tail.js +211 -0
- package/dist/commands/uninstall.d.ts +13 -0
- package/dist/commands/uninstall.js +111 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +90 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +35 -0
- package/dist/init.d.ts +9 -0
- package/dist/init.js +50 -0
- package/dist/install/claude-code.d.ts +13 -0
- package/dist/install/claude-code.js +118 -0
- package/dist/install/cursor.d.ts +13 -0
- package/dist/install/cursor.js +119 -0
- package/dist/install/detect.d.ts +5 -0
- package/dist/install/detect.js +64 -0
- package/dist/middleware/auth.d.ts +15 -0
- package/dist/middleware/auth.js +116 -0
- package/dist/routes/adapters/claude-code.d.ts +38 -0
- package/dist/routes/adapters/claude-code.js +125 -0
- package/dist/routes/adapters/cursor.d.ts +21 -0
- package/dist/routes/adapters/cursor.js +139 -0
- package/dist/routes/adapters/index.d.ts +16 -0
- package/dist/routes/adapters/index.js +56 -0
- package/dist/routes/adapters/router.d.ts +31 -0
- package/dist/routes/adapters/router.js +97 -0
- package/dist/routes/adapters/schema.d.ts +141 -0
- package/dist/routes/adapters/schema.js +83 -0
- package/dist/routes/adapters/windsurf.d.ts +6 -0
- package/dist/routes/adapters/windsurf.js +48 -0
- package/dist/routes/admin.d.ts +15 -0
- package/dist/routes/admin.js +399 -0
- package/dist/routes/call.d.ts +13 -0
- package/dist/routes/call.js +68 -0
- package/dist/routes/events.d.ts +7 -0
- package/dist/routes/events.js +125 -0
- package/dist/routes/health.d.ts +2 -0
- package/dist/routes/health.js +12 -0
- package/dist/routes/hooks.d.ts +11 -0
- package/dist/routes/hooks.js +166 -0
- package/dist/routes/mcp.d.ts +10 -0
- package/dist/routes/mcp.js +170 -0
- package/dist/routes/openai-tools.d.ts +9 -0
- package/dist/routes/openai-tools.js +121 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +118 -0
- package/dist/services/audit.d.ts +92 -0
- package/dist/services/audit.js +388 -0
- package/dist/services/data-dir.d.ts +7 -0
- package/dist/services/data-dir.js +61 -0
- package/dist/services/local-policy-templates.d.ts +9 -0
- package/dist/services/local-policy-templates.js +47 -0
- package/dist/services/local-policy.d.ts +39 -0
- package/dist/services/local-policy.js +172 -0
- package/dist/services/policy-store.d.ts +82 -0
- package/dist/services/policy-store.js +331 -0
- package/dist/services/policy.d.ts +8 -0
- package/dist/services/policy.js +126 -0
- package/dist/services/ratelimit.d.ts +26 -0
- package/dist/services/ratelimit.js +60 -0
- package/dist/services/sanitizer.d.ts +9 -0
- package/dist/services/sanitizer.js +73 -0
- package/dist/services/sqlite-loader.d.ts +4 -0
- package/dist/services/sqlite-loader.js +16 -0
- package/dist/services/telemetry-log.d.ts +76 -0
- package/dist/services/telemetry-log.js +260 -0
- package/dist/services/tool-executor.d.ts +46 -0
- package/dist/services/tool-executor.js +167 -0
- package/dist/services/upstream.d.ts +18 -0
- package/dist/services/upstream.js +72 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.js +3 -0
- package/package.json +72 -0
- package/public/favicon.svg +4 -0
- 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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|