@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,399 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerAdminRoutes = registerAdminRoutes;
|
|
4
|
+
const local_policy_1 = require("../services/local-policy");
|
|
5
|
+
function registerAdminRoutes(app, ctx) {
|
|
6
|
+
// GET /admin/search — unified search over audit records (unauthenticated — demo only)
|
|
7
|
+
app.get('/api/admin/search', async (request, reply) => {
|
|
8
|
+
const limit = Math.min(parseInt(request.query.limit ?? '50', 10) || 50, 500);
|
|
9
|
+
const offset = parseInt(request.query.offset ?? '0', 10) || 0;
|
|
10
|
+
const result = ctx.audit.search({
|
|
11
|
+
q: request.query.q,
|
|
12
|
+
decision: request.query.decision,
|
|
13
|
+
from: request.query.from,
|
|
14
|
+
to: request.query.to,
|
|
15
|
+
limit,
|
|
16
|
+
offset,
|
|
17
|
+
});
|
|
18
|
+
reply.send(result);
|
|
19
|
+
});
|
|
20
|
+
// GET /admin/aggregate — server-side GROUP BY aggregation (unauthenticated — demo only)
|
|
21
|
+
app.get('/api/admin/aggregate', async (request, reply) => {
|
|
22
|
+
const groupByRaw = request.query.groupBy;
|
|
23
|
+
if (!groupByRaw || !groupByRaw.trim()) {
|
|
24
|
+
reply.code(400).send({ error: 'groupBy is required' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const groupBy = groupByRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
if (groupBy.length === 0) {
|
|
29
|
+
reply.code(400).send({ error: 'groupBy is required' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const limit = Math.min(parseInt(request.query.limit ?? '100', 10) || 100, 500);
|
|
33
|
+
const result = ctx.audit.aggregate({
|
|
34
|
+
groupBy,
|
|
35
|
+
q: request.query.q,
|
|
36
|
+
decision: request.query.decision,
|
|
37
|
+
from: request.query.from,
|
|
38
|
+
to: request.query.to,
|
|
39
|
+
limit,
|
|
40
|
+
});
|
|
41
|
+
reply.send(result);
|
|
42
|
+
});
|
|
43
|
+
// GET /admin/logs — recent audit log entries (requires auth)
|
|
44
|
+
app.get('/api/admin/logs', async (request, reply) => {
|
|
45
|
+
// Auth check — require at least some valid actor (set by auth middleware)
|
|
46
|
+
if (!request.actor?.actor) {
|
|
47
|
+
reply.code(401).send({ error: 'Unauthorized' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const limit = Math.min(parseInt(request.query.limit ?? '50', 10), 500);
|
|
51
|
+
const offset = parseInt(request.query.offset ?? '0', 10);
|
|
52
|
+
const logs = ctx.audit.query(limit, offset);
|
|
53
|
+
const total = ctx.audit.count();
|
|
54
|
+
reply.send({ total, limit, offset, logs });
|
|
55
|
+
});
|
|
56
|
+
// GET /admin/upstreams — list registered upstreams (requires auth)
|
|
57
|
+
app.get('/api/admin/upstreams', async (request, reply) => {
|
|
58
|
+
if (!request.actor?.actor) {
|
|
59
|
+
reply.code(401).send({ error: 'Unauthorized' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const upstreams = ctx.upstream.listUpstreams().map((u) => ({
|
|
63
|
+
id: u.id,
|
|
64
|
+
name: u.name,
|
|
65
|
+
baseUrl: u.baseUrl,
|
|
66
|
+
timeout: u.timeout,
|
|
67
|
+
// never expose authHeader
|
|
68
|
+
}));
|
|
69
|
+
reply.send({ upstreams });
|
|
70
|
+
});
|
|
71
|
+
// -----------------------------------------------------------------------
|
|
72
|
+
// Dashboard stats endpoints (unauthenticated — demo only)
|
|
73
|
+
// -----------------------------------------------------------------------
|
|
74
|
+
app.get('/api/admin/stats/summary', async (request, reply) => {
|
|
75
|
+
const hours = Math.min(parseInt(request.query.hours ?? '24', 10) || 24, 720);
|
|
76
|
+
reply.send(ctx.audit.summary(hours));
|
|
77
|
+
});
|
|
78
|
+
app.get('/api/admin/stats/top-tools', async (request, reply) => {
|
|
79
|
+
const limit = Math.min(parseInt(request.query.limit ?? '5', 10) || 5, 50);
|
|
80
|
+
reply.send(ctx.audit.topTools(limit));
|
|
81
|
+
});
|
|
82
|
+
app.get('/api/admin/stats/top-denied', async (request, reply) => {
|
|
83
|
+
const limit = Math.min(parseInt(request.query.limit ?? '5', 10) || 5, 50);
|
|
84
|
+
reply.send(ctx.audit.topDeniedTools(limit));
|
|
85
|
+
});
|
|
86
|
+
app.get('/api/admin/stats/top-actors', async (request, reply) => {
|
|
87
|
+
const limit = Math.min(parseInt(request.query.limit ?? '5', 10) || 5, 50);
|
|
88
|
+
reply.send(ctx.audit.topActors(limit));
|
|
89
|
+
});
|
|
90
|
+
app.get('/api/admin/stats/decisions', async (_request, reply) => {
|
|
91
|
+
reply.send(ctx.audit.decisionBreakdown());
|
|
92
|
+
});
|
|
93
|
+
app.get('/api/admin/stats/timeseries', async (request, reply) => {
|
|
94
|
+
const hours = Math.min(parseInt(request.query.hours ?? '1', 10) || 1, 168);
|
|
95
|
+
const bucket = Math.min(parseInt(request.query.bucket ?? '5', 10) || 5, 60);
|
|
96
|
+
reply.send(ctx.audit.timeSeries(hours, bucket));
|
|
97
|
+
});
|
|
98
|
+
// -----------------------------------------------------------------------
|
|
99
|
+
// Settings (read-only view of runtime config)
|
|
100
|
+
// -----------------------------------------------------------------------
|
|
101
|
+
app.get('/api/admin/settings', async (_request, reply) => {
|
|
102
|
+
const c = ctx.config;
|
|
103
|
+
reply.send({
|
|
104
|
+
gateway: {
|
|
105
|
+
port: c.port,
|
|
106
|
+
logLevel: c.logLevel,
|
|
107
|
+
opaEnabled: c.opaEnabled,
|
|
108
|
+
opaUrl: c.opaEnabled ? c.opaUrl : null,
|
|
109
|
+
storeFullArgs: c.storeFullArgs,
|
|
110
|
+
dbPath: c.dbPath,
|
|
111
|
+
},
|
|
112
|
+
rateLimit: {
|
|
113
|
+
capacity: c.rateLimitCapacity,
|
|
114
|
+
refillPerSecond: c.rateLimitRefillPerSecond,
|
|
115
|
+
},
|
|
116
|
+
upstreams: c.upstreams.map((u) => ({
|
|
117
|
+
id: u.id,
|
|
118
|
+
name: u.name,
|
|
119
|
+
baseUrl: u.baseUrl,
|
|
120
|
+
timeout: u.timeout,
|
|
121
|
+
hasAuth: !!u.authHeader,
|
|
122
|
+
tools: u.tools ?? [],
|
|
123
|
+
})),
|
|
124
|
+
telemetry: {
|
|
125
|
+
endpointUrl: '/v1/events',
|
|
126
|
+
claudeCodeHook: {
|
|
127
|
+
type: 'http',
|
|
128
|
+
url: `http://<gateway-host>:${c.port}/v1/events`,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
// Policy management (unauthenticated — demo only)
|
|
135
|
+
// -----------------------------------------------------------------------
|
|
136
|
+
// POST /admin/policies/test — must be registered BEFORE :id param route
|
|
137
|
+
app.post('/api/admin/policies/test', async (request, reply) => {
|
|
138
|
+
const body = request.body;
|
|
139
|
+
if (!body.input || typeof body.input !== 'object') {
|
|
140
|
+
reply.code(400).send({ error: 'input object is required' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const decision = await ctx.policy.evaluate(body.input);
|
|
145
|
+
reply.send({ decision });
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
149
|
+
reply.code(500).send({ error: 'Policy evaluation failed', details: msg });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
// GET /admin/policies — list all policies
|
|
153
|
+
app.get('/api/admin/policies', async (_request, reply) => {
|
|
154
|
+
const policies = ctx.policyStore.list();
|
|
155
|
+
reply.send({ policies });
|
|
156
|
+
});
|
|
157
|
+
// GET /admin/policies/:id — get single policy
|
|
158
|
+
app.get('/api/admin/policies/:id', async (request, reply) => {
|
|
159
|
+
const id = parseInt(request.params.id, 10);
|
|
160
|
+
if (isNaN(id)) {
|
|
161
|
+
reply.code(400).send({ error: 'Invalid policy id' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const policy = ctx.policyStore.getById(id);
|
|
165
|
+
if (!policy) {
|
|
166
|
+
reply.code(404).send({ error: 'Policy not found' });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
reply.send(policy);
|
|
170
|
+
});
|
|
171
|
+
// POST /admin/policies — create new policy
|
|
172
|
+
app.post('/api/admin/policies', async (request, reply) => {
|
|
173
|
+
const body = request.body;
|
|
174
|
+
const name = body.name;
|
|
175
|
+
const rego = body.rego;
|
|
176
|
+
if (!name || !rego) {
|
|
177
|
+
reply.code(400).send({ error: 'name and rego are required' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const created = ctx.policyStore.create({
|
|
181
|
+
name,
|
|
182
|
+
rego,
|
|
183
|
+
description: body.description ?? undefined,
|
|
184
|
+
version: body.version ?? undefined,
|
|
185
|
+
updatedBy: body.updatedBy ?? 'admin@oculisecurity.com',
|
|
186
|
+
});
|
|
187
|
+
reply.code(201).send(created);
|
|
188
|
+
});
|
|
189
|
+
// PUT /admin/policies/:id — update policy
|
|
190
|
+
app.put('/api/admin/policies/:id', async (request, reply) => {
|
|
191
|
+
const id = parseInt(request.params.id, 10);
|
|
192
|
+
if (isNaN(id)) {
|
|
193
|
+
reply.code(400).send({ error: 'Invalid policy id' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const body = request.body;
|
|
197
|
+
const updated = ctx.policyStore.update(id, {
|
|
198
|
+
name: body.name,
|
|
199
|
+
description: body.description,
|
|
200
|
+
version: body.version,
|
|
201
|
+
rego: body.rego,
|
|
202
|
+
updatedBy: body.updatedBy ?? 'admin@oculisecurity.com',
|
|
203
|
+
isActive: body.isActive,
|
|
204
|
+
});
|
|
205
|
+
if (!updated) {
|
|
206
|
+
reply.code(404).send({ error: 'Policy not found' });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
reply.send(updated);
|
|
210
|
+
});
|
|
211
|
+
// POST /admin/policies/:id/duplicate — duplicate a policy
|
|
212
|
+
app.post('/api/admin/policies/:id/duplicate', async (request, reply) => {
|
|
213
|
+
const id = parseInt(request.params.id, 10);
|
|
214
|
+
if (isNaN(id)) {
|
|
215
|
+
reply.code(400).send({ error: 'Invalid policy id' });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const duplicated = ctx.policyStore.duplicate(id);
|
|
219
|
+
if (!duplicated) {
|
|
220
|
+
reply.code(404).send({ error: 'Policy not found' });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
reply.code(201).send(duplicated);
|
|
224
|
+
});
|
|
225
|
+
// DELETE /admin/policies/:id — delete a policy
|
|
226
|
+
app.delete('/api/admin/policies/:id', async (request, reply) => {
|
|
227
|
+
const id = parseInt(request.params.id, 10);
|
|
228
|
+
if (isNaN(id)) {
|
|
229
|
+
reply.code(400).send({ error: 'Invalid policy id' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const deleted = ctx.policyStore.delete(id);
|
|
233
|
+
if (!deleted) {
|
|
234
|
+
reply.code(404).send({ error: 'Policy not found' });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
reply.send({ success: true });
|
|
238
|
+
});
|
|
239
|
+
// -----------------------------------------------------------------------
|
|
240
|
+
// Visual Rules (unauthenticated — demo only)
|
|
241
|
+
// -----------------------------------------------------------------------
|
|
242
|
+
// POST /admin/rules/test — must be registered BEFORE :id param route
|
|
243
|
+
app.post('/api/admin/rules/test', async (request, reply) => {
|
|
244
|
+
const body = request.body;
|
|
245
|
+
const ruleInput = body.rule;
|
|
246
|
+
const eventInput = body.event;
|
|
247
|
+
if (!ruleInput || !eventInput) {
|
|
248
|
+
reply.code(400).send({ error: 'rule and event objects are required' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Validate regex patterns
|
|
252
|
+
for (const field of ['command_pattern', 'file_pattern']) {
|
|
253
|
+
const pattern = ruleInput[field];
|
|
254
|
+
if (pattern) {
|
|
255
|
+
try {
|
|
256
|
+
new RegExp(pattern);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
reply.code(400).send({ error: `Invalid regex in ${field}: ${pattern}` });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const policyRule = {
|
|
265
|
+
id: ruleInput.rule_id ?? 'test',
|
|
266
|
+
match: {
|
|
267
|
+
tool: ruleInput.tool,
|
|
268
|
+
command_pattern: ruleInput.command_pattern,
|
|
269
|
+
file_pattern: ruleInput.file_pattern,
|
|
270
|
+
mcp_server: ruleInput.mcp_server,
|
|
271
|
+
},
|
|
272
|
+
action: (['deny', 'warn', 'allow'].includes(ruleInput.action)
|
|
273
|
+
? ruleInput.action
|
|
274
|
+
: 'deny'),
|
|
275
|
+
};
|
|
276
|
+
const event = eventInput;
|
|
277
|
+
const matches = (0, local_policy_1.ruleMatches)(policyRule, event);
|
|
278
|
+
reply.send({ matches, action: matches ? policyRule.action : 'no match' });
|
|
279
|
+
});
|
|
280
|
+
// GET /admin/rules — list all visual rules
|
|
281
|
+
app.get('/api/admin/rules', async (_request, reply) => {
|
|
282
|
+
const rules = ctx.policyStore.listVisualRules();
|
|
283
|
+
reply.send({ rules });
|
|
284
|
+
});
|
|
285
|
+
// GET /admin/rules/:id — get single visual rule
|
|
286
|
+
app.get('/api/admin/rules/:id', async (request, reply) => {
|
|
287
|
+
const id = parseInt(request.params.id, 10);
|
|
288
|
+
if (isNaN(id)) {
|
|
289
|
+
reply.code(400).send({ error: 'Invalid rule id' });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const rule = ctx.policyStore.getVisualRuleById(id);
|
|
293
|
+
if (!rule) {
|
|
294
|
+
reply.code(404).send({ error: 'Rule not found' });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
reply.send(rule);
|
|
298
|
+
});
|
|
299
|
+
// POST /admin/rules — create visual rule
|
|
300
|
+
app.post('/api/admin/rules', async (request, reply) => {
|
|
301
|
+
const body = request.body;
|
|
302
|
+
const rule_id = body.rule_id;
|
|
303
|
+
const action = body.action;
|
|
304
|
+
if (!rule_id || !action) {
|
|
305
|
+
reply.code(400).send({ error: 'rule_id and action are required' });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (!['deny', 'warn', 'allow'].includes(action)) {
|
|
309
|
+
reply.code(400).send({ error: 'action must be deny, warn, or allow' });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Validate regex patterns
|
|
313
|
+
for (const field of ['command_pattern', 'file_pattern']) {
|
|
314
|
+
const pattern = body[field];
|
|
315
|
+
if (pattern) {
|
|
316
|
+
try {
|
|
317
|
+
new RegExp(pattern);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
reply.code(400).send({ error: `Invalid regex in ${field}: ${pattern}` });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Check at least one match field
|
|
326
|
+
if (!body.tool && !body.command_pattern && !body.file_pattern && !body.mcp_server) {
|
|
327
|
+
reply.code(400).send({ error: 'At least one match field (tool, command_pattern, file_pattern, mcp_server) is required' });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const created = ctx.policyStore.createVisualRule(body);
|
|
332
|
+
reply.code(201).send(created);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
336
|
+
if (msg.includes('UNIQUE constraint')) {
|
|
337
|
+
reply.code(409).send({ error: `Rule ID "${rule_id}" already exists` });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
// PUT /admin/rules/:id — update visual rule
|
|
344
|
+
app.put('/api/admin/rules/:id', async (request, reply) => {
|
|
345
|
+
const id = parseInt(request.params.id, 10);
|
|
346
|
+
if (isNaN(id)) {
|
|
347
|
+
reply.code(400).send({ error: 'Invalid rule id' });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const body = request.body;
|
|
351
|
+
// Validate regex patterns if provided
|
|
352
|
+
for (const field of ['command_pattern', 'file_pattern']) {
|
|
353
|
+
const pattern = body[field];
|
|
354
|
+
if (pattern) {
|
|
355
|
+
try {
|
|
356
|
+
new RegExp(pattern);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
reply.code(400).send({ error: `Invalid regex in ${field}: ${pattern}` });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (body.action && !['deny', 'warn', 'allow'].includes(body.action)) {
|
|
365
|
+
reply.code(400).send({ error: 'action must be deny, warn, or allow' });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const updated = ctx.policyStore.updateVisualRule(id, body);
|
|
370
|
+
if (!updated) {
|
|
371
|
+
reply.code(404).send({ error: 'Rule not found' });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
reply.send(updated);
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
378
|
+
if (msg.includes('UNIQUE constraint')) {
|
|
379
|
+
reply.code(409).send({ error: `Rule ID "${body.rule_id}" already exists` });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
// DELETE /admin/rules/:id — delete visual rule
|
|
386
|
+
app.delete('/api/admin/rules/:id', async (request, reply) => {
|
|
387
|
+
const id = parseInt(request.params.id, 10);
|
|
388
|
+
if (isNaN(id)) {
|
|
389
|
+
reply.code(400).send({ error: 'Invalid rule id' });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const deleted = ctx.policyStore.deleteVisualRule(id);
|
|
393
|
+
if (!deleted) {
|
|
394
|
+
reply.code(404).send({ error: 'Rule not found' });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
reply.send({ success: true });
|
|
398
|
+
});
|
|
399
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FastifyInstance } from 'fastify';
|
|
2
|
+
import { PolicyService } from '../services/policy';
|
|
3
|
+
import { AuditService } from '../services/audit';
|
|
4
|
+
import { UpstreamConnector } from '../services/upstream';
|
|
5
|
+
import { RateLimiter } from '../services/ratelimit';
|
|
6
|
+
interface CallRouteContext {
|
|
7
|
+
policy: PolicyService;
|
|
8
|
+
audit: AuditService;
|
|
9
|
+
upstream: UpstreamConnector;
|
|
10
|
+
rateLimiter: RateLimiter;
|
|
11
|
+
}
|
|
12
|
+
export declare function registerCallRoutes(app: FastifyInstance, ctx: CallRouteContext): void;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerCallRoutes = registerCallRoutes;
|
|
4
|
+
const tool_executor_1 = require("../services/tool-executor");
|
|
5
|
+
function registerCallRoutes(app, ctx) {
|
|
6
|
+
const executor = new tool_executor_1.ToolExecutor(ctx);
|
|
7
|
+
app.post('/v1/call', {
|
|
8
|
+
schema: {
|
|
9
|
+
body: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
required: ['upstreamId', 'tool', 'args'],
|
|
12
|
+
properties: {
|
|
13
|
+
upstreamId: { type: 'string' },
|
|
14
|
+
tool: { type: 'string' },
|
|
15
|
+
args: { type: 'object' },
|
|
16
|
+
sessionId: { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}, async (request, reply) => {
|
|
21
|
+
const { upstreamId, tool, args, sessionId } = request.body;
|
|
22
|
+
const execResult = await executor.execute({
|
|
23
|
+
actor: request.actor,
|
|
24
|
+
upstreamId,
|
|
25
|
+
tool,
|
|
26
|
+
args,
|
|
27
|
+
sessionId,
|
|
28
|
+
ip: request.ip,
|
|
29
|
+
});
|
|
30
|
+
switch (execResult.kind) {
|
|
31
|
+
case 'allow':
|
|
32
|
+
reply.code(200).send({
|
|
33
|
+
requestId: execResult.requestId,
|
|
34
|
+
decision: { allow: true, reason: execResult.reason },
|
|
35
|
+
upstream: { id: execResult.upstreamId, latencyMs: execResult.latencyMs },
|
|
36
|
+
result: execResult.result,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
case 'deny':
|
|
40
|
+
if (execResult.httpStatus === 429) {
|
|
41
|
+
reply
|
|
42
|
+
.code(429)
|
|
43
|
+
.header('Retry-After', String(Math.ceil((execResult.retryAfterMs ?? 0) / 1000)))
|
|
44
|
+
.send({
|
|
45
|
+
requestId: execResult.requestId,
|
|
46
|
+
decision: { allow: false, reason: execResult.reason },
|
|
47
|
+
error: `Rate limit exceeded. Retry after ~${execResult.retryAfterMs}ms`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
reply.code(execResult.httpStatus).send({
|
|
52
|
+
requestId: execResult.requestId,
|
|
53
|
+
decision: { allow: false, reason: execResult.reason },
|
|
54
|
+
error: execResult.reason,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
case 'error':
|
|
59
|
+
reply.code(execResult.httpStatus).send({
|
|
60
|
+
requestId: execResult.requestId,
|
|
61
|
+
decision: { allow: true, reason: execResult.reason },
|
|
62
|
+
upstream: { id: execResult.upstreamId, latencyMs: execResult.latencyMs },
|
|
63
|
+
error: execResult.message,
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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.registerEventsRoute = registerEventsRoute;
|
|
37
|
+
const uuid_1 = require("uuid");
|
|
38
|
+
const ClaudeCodeAdapter = __importStar(require("./adapters/claude-code"));
|
|
39
|
+
function normalizePayload(body) {
|
|
40
|
+
if (ClaudeCodeAdapter.detect(body)) {
|
|
41
|
+
const event = ClaudeCodeAdapter.normalize(body);
|
|
42
|
+
return {
|
|
43
|
+
source: 'claude-code',
|
|
44
|
+
events: [
|
|
45
|
+
{
|
|
46
|
+
tool: event.tool ?? '__stop__',
|
|
47
|
+
args: event.tool_args,
|
|
48
|
+
result: event.tool_result,
|
|
49
|
+
sessionId: event.session_id,
|
|
50
|
+
actor: event.actor,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Standard TelemetryPayload — validate manually
|
|
56
|
+
const payload = body;
|
|
57
|
+
if (typeof payload.source !== 'string' ||
|
|
58
|
+
!payload.source ||
|
|
59
|
+
!Array.isArray(payload.events) ||
|
|
60
|
+
payload.events.length < 1 ||
|
|
61
|
+
payload.events.length > 100) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
for (const event of payload.events) {
|
|
65
|
+
if (typeof event.tool !== 'string' || !event.tool) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return payload;
|
|
70
|
+
}
|
|
71
|
+
function registerEventsRoute(app, ctx) {
|
|
72
|
+
app.post('/v1/events', async (request, reply) => {
|
|
73
|
+
const body = request.body;
|
|
74
|
+
if (!body || typeof body !== 'object') {
|
|
75
|
+
reply.code(400).send({ error: 'Invalid request body' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const payload = normalizePayload(body);
|
|
79
|
+
if (!payload) {
|
|
80
|
+
reply.code(400).send({
|
|
81
|
+
error: 'Invalid payload. Expected { source, events: [{ tool, ... }] } or Claude Code hook format { hook_event_name, tool_name, ... }',
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const { source, orgId, events } = payload;
|
|
86
|
+
const ip = request.ip;
|
|
87
|
+
const isClaudeCode = source === 'claude-code';
|
|
88
|
+
// For Claude Code hooks, stash the full raw body so nothing is lost
|
|
89
|
+
const rawBodyJson = isClaudeCode ? JSON.stringify(body) : null;
|
|
90
|
+
request.log.info({ source, toolCount: events.length }, 'telemetry received');
|
|
91
|
+
if (isClaudeCode) {
|
|
92
|
+
request.log.debug({ rawBody: body }, 'claude-code hook raw payload');
|
|
93
|
+
}
|
|
94
|
+
for (const event of events) {
|
|
95
|
+
const requestId = (0, uuid_1.v4)();
|
|
96
|
+
// For Claude Code hooks use raw body; for standard events use parsed args
|
|
97
|
+
const argsJson = isClaudeCode
|
|
98
|
+
? rawBodyJson
|
|
99
|
+
: (event.args ? JSON.stringify(event.args) : null);
|
|
100
|
+
const resultStr = event.result != null ? JSON.stringify(event.result) : null;
|
|
101
|
+
const responseJson = resultStr
|
|
102
|
+
? resultStr.length <= 8192 ? resultStr : resultStr.slice(0, 8192) + '…'
|
|
103
|
+
: null;
|
|
104
|
+
ctx.audit.log({
|
|
105
|
+
requestId,
|
|
106
|
+
timestamp: event.timestamp ?? new Date().toISOString(),
|
|
107
|
+
actor: event.actor ?? source,
|
|
108
|
+
orgId: orgId ?? 'default',
|
|
109
|
+
upstreamId: `ext:${source}`,
|
|
110
|
+
tool: event.tool,
|
|
111
|
+
argsHash: event.args ? ctx.audit.hash(event.args) : 'none',
|
|
112
|
+
argsJson,
|
|
113
|
+
decision: 'allow',
|
|
114
|
+
reason: 'telemetry',
|
|
115
|
+
latencyMs: event.durationMs ?? 0,
|
|
116
|
+
outcome: event.error ? 'error' : 'success',
|
|
117
|
+
responseHash: event.result != null ? ctx.audit.hash(event.result) : undefined,
|
|
118
|
+
responseJson,
|
|
119
|
+
ip,
|
|
120
|
+
sessionId: event.sessionId ?? requestId,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
reply.code(202).send({ received: events.length });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerHealthRoutes = registerHealthRoutes;
|
|
4
|
+
function registerHealthRoutes(app) {
|
|
5
|
+
app.get('/health', async (_req, reply) => {
|
|
6
|
+
reply.send({
|
|
7
|
+
status: 'ok',
|
|
8
|
+
timestamp: new Date().toISOString(),
|
|
9
|
+
service: 'oculi-security-gateway',
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FastifyInstance } from 'fastify';
|
|
2
|
+
import { PolicyService } from '../services/policy';
|
|
3
|
+
import { AuditService } from '../services/audit';
|
|
4
|
+
import { RateLimiter } from '../services/ratelimit';
|
|
5
|
+
interface HooksRouteContext {
|
|
6
|
+
policy: PolicyService;
|
|
7
|
+
audit: AuditService;
|
|
8
|
+
rateLimiter: RateLimiter;
|
|
9
|
+
}
|
|
10
|
+
export declare function registerHooksRoute(app: FastifyInstance, ctx: HooksRouteContext): void;
|
|
11
|
+
export {};
|