airlock-bot 0.2.35 → 0.2.36
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/README.md +21 -0
- package/dist/audit/api.d.ts +2 -0
- package/dist/audit/api.d.ts.map +1 -1
- package/dist/audit/api.js +3 -13
- package/dist/audit/api.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +40 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +91 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +7 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/configure-web/cli.d.ts +29 -2
- package/dist/configure-web/cli.d.ts.map +1 -1
- package/dist/configure-web/cli.js +957 -28
- package/dist/configure-web/cli.js.map +1 -1
- package/dist/gateway.d.ts +7 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +81 -26
- package/dist/gateway.js.map +1 -1
- package/dist/hitl/api.d.ts +2 -0
- package/dist/hitl/api.d.ts.map +1 -1
- package/dist/hitl/api.js +3 -13
- package/dist/hitl/api.js.map +1 -1
- package/dist/hitl/approval-dashboard.d.ts +19 -0
- package/dist/hitl/approval-dashboard.d.ts.map +1 -0
- package/dist/hitl/approval-dashboard.js +148 -0
- package/dist/hitl/approval-dashboard.js.map +1 -0
- package/dist/hitl/provider-factory.js +1 -1
- package/dist/hitl/provider-factory.js.map +1 -1
- package/dist/hitl/providers/dashboard.d.ts +4 -5
- package/dist/hitl/providers/dashboard.d.ts.map +1 -1
- package/dist/hitl/providers/dashboard.js +29 -469
- package/dist/hitl/providers/dashboard.js.map +1 -1
- package/dist/hook/api.d.ts +2 -0
- package/dist/hook/api.d.ts.map +1 -1
- package/dist/hook/api.js +3 -13
- package/dist/hook/api.js.map +1 -1
- package/dist/index.js +29 -4
- package/dist/index.js.map +1 -1
- package/dist/security/request.d.ts +14 -0
- package/dist/security/request.d.ts.map +1 -0
- package/dist/security/request.js +47 -0
- package/dist/security/request.js.map +1 -0
- package/dist/tools/api.d.ts +2 -0
- package/dist/tools/api.d.ts.map +1 -1
- package/dist/tools/api.js +3 -13
- package/dist/tools/api.js.map +1 -1
- package/dist/transport/http-server.d.ts +2 -0
- package/dist/transport/http-server.d.ts.map +1 -1
- package/dist/transport/http-server.js +9 -22
- package/dist/transport/http-server.js.map +1 -1
- package/dist/transport/sse-server.d.ts +2 -0
- package/dist/transport/sse-server.d.ts.map +1 -1
- package/dist/transport/sse-server.js +9 -26
- package/dist/transport/sse-server.js.map +1 -1
- package/examples/docker-compose.yaml +63 -0
- package/examples/docker-gateway.yaml +61 -0
- package/examples/gateway.yaml +7 -0
- package/package.json +1 -1
- package/schema.json +35 -0
|
@@ -2,11 +2,16 @@ import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
|
|
|
2
2
|
import { parseArgs } from 'util';
|
|
3
3
|
import Fastify from 'fastify';
|
|
4
4
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
5
|
+
import { AuditDb } from '../audit/db.js';
|
|
5
6
|
import { buildAdapters } from '../backend/factory.js';
|
|
6
|
-
import { GatewayConfig, getMcpConfigs, } from '../config/schema.js';
|
|
7
|
+
import { AuditConfig, GatewayConfig, McpServerConfig, ProviderConfig, getMcpConfigs, } from '../config/schema.js';
|
|
8
|
+
import { rememberAllow } from '../config/mutator.js';
|
|
7
9
|
import { validateConfig } from '../config/loader.js';
|
|
8
10
|
import { ClientPool } from '../pool/pool.js';
|
|
9
11
|
import { checkSuspiciousPatterns } from '../registry/sanitizer.js';
|
|
12
|
+
import { VERSION } from '../version.js';
|
|
13
|
+
const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
14
|
+
let latestVersionCache = null;
|
|
10
15
|
const HELP = `
|
|
11
16
|
airlock configure-web - browser UI for profiles, agents, and allow/ask/deny lists
|
|
12
17
|
|
|
@@ -31,9 +36,56 @@ Options:
|
|
|
31
36
|
--host <host> Bind host (default: 127.0.0.1)
|
|
32
37
|
-h, --help Show this help
|
|
33
38
|
`;
|
|
39
|
+
const DASHBOARD_HELP = `
|
|
40
|
+
airlock dashboard - standalone admin dashboard for a remote Airlock gateway
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
airlock dashboard [options]
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
-c, --config <path> Airlock config file (default: ./airlock.yaml)
|
|
47
|
+
-p, --port <port> Web UI port (default: 4177)
|
|
48
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
49
|
+
--gateway-url <url> Gateway URL (default: http://127.0.0.1:4111)
|
|
50
|
+
--gateway-secret <secret> Gateway admin bearer token (default: AIRLOCK_GATEWAY_SECRET or AIRLOCK_API_SECRET)
|
|
51
|
+
-h, --help Show this help
|
|
52
|
+
`;
|
|
34
53
|
export async function runCommandCenter(argv) {
|
|
35
54
|
await runConfigureWeb(argv, 'run');
|
|
36
55
|
}
|
|
56
|
+
export async function runDashboard(argv) {
|
|
57
|
+
const { values } = parseArgs({
|
|
58
|
+
args: argv,
|
|
59
|
+
options: {
|
|
60
|
+
config: { type: 'string', short: 'c', default: './airlock.yaml' },
|
|
61
|
+
port: { type: 'string', short: 'p', default: '4177' },
|
|
62
|
+
host: { type: 'string', default: '127.0.0.1' },
|
|
63
|
+
'gateway-url': { type: 'string', default: 'http://127.0.0.1:4111' },
|
|
64
|
+
'gateway-secret': { type: 'string' },
|
|
65
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
66
|
+
},
|
|
67
|
+
allowPositionals: false,
|
|
68
|
+
});
|
|
69
|
+
if (values.help) {
|
|
70
|
+
console.log(DASHBOARD_HELP);
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
const configPath = values.config ?? './airlock.yaml';
|
|
74
|
+
const port = Number(values.port ?? '4177');
|
|
75
|
+
const host = values.host ?? '127.0.0.1';
|
|
76
|
+
const gatewayUrl = values['gateway-url'] ?? 'http://127.0.0.1:4111';
|
|
77
|
+
const gatewaySecret = values['gateway-secret'] ?? process.env['AIRLOCK_GATEWAY_SECRET'] ?? process.env['AIRLOCK_API_SECRET'];
|
|
78
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
79
|
+
throw new Error(`Invalid --port: ${values.port}`);
|
|
80
|
+
}
|
|
81
|
+
const app = createConfigureWebApp(configPath, {
|
|
82
|
+
remoteGateway: { url: gatewayUrl, secret: gatewaySecret },
|
|
83
|
+
});
|
|
84
|
+
await app.listen({ host, port });
|
|
85
|
+
console.log(`Airlock dashboard running at http://${host}:${port}`);
|
|
86
|
+
console.log(`Editing ${configPath}`);
|
|
87
|
+
console.log(`Gateway ${gatewayUrl}`);
|
|
88
|
+
}
|
|
37
89
|
export async function runConfigureWeb(argv, entrypoint = 'configure-web') {
|
|
38
90
|
const { values } = parseArgs({
|
|
39
91
|
args: argv,
|
|
@@ -60,7 +112,7 @@ export async function runConfigureWeb(argv, entrypoint = 'configure-web') {
|
|
|
60
112
|
console.log(`Airlock command center running at http://${host}:${port}`);
|
|
61
113
|
console.log(`Editing ${configPath}`);
|
|
62
114
|
}
|
|
63
|
-
export function createConfigureWebApp(configPath) {
|
|
115
|
+
export function createConfigureWebApp(configPath, options = {}) {
|
|
64
116
|
const app = Fastify({ logger: false });
|
|
65
117
|
app.get('/', async (_request, reply) => {
|
|
66
118
|
reply.type('text/html; charset=utf-8').send(INDEX_HTML);
|
|
@@ -68,6 +120,9 @@ export function createConfigureWebApp(configPath) {
|
|
|
68
120
|
app.get('/api/state', () => readState(configPath));
|
|
69
121
|
app.get('/api/status', async (_request, reply) => {
|
|
70
122
|
try {
|
|
123
|
+
if (options.remoteGateway) {
|
|
124
|
+
return await readRemoteCommandCenterStatus(configPath, options.remoteGateway);
|
|
125
|
+
}
|
|
71
126
|
return await readCommandCenterStatus(configPath);
|
|
72
127
|
}
|
|
73
128
|
catch (err) {
|
|
@@ -75,8 +130,28 @@ export function createConfigureWebApp(configPath) {
|
|
|
75
130
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
76
131
|
}
|
|
77
132
|
});
|
|
133
|
+
app.get('/api/logs', async (request, reply) => {
|
|
134
|
+
try {
|
|
135
|
+
if (options.remoteGateway) {
|
|
136
|
+
return await readRemoteAuditLogs(options.remoteGateway, parseAuditLogQuery(request.query));
|
|
137
|
+
}
|
|
138
|
+
return readAuditLogs(configPath, parseAuditLogQuery(request.query));
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
reply.code(500);
|
|
142
|
+
return {
|
|
143
|
+
dbPath: '',
|
|
144
|
+
entries: [],
|
|
145
|
+
pending: [],
|
|
146
|
+
error: err instanceof Error ? err.message : String(err),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
});
|
|
78
150
|
app.get('/api/tools', async (_request, reply) => {
|
|
79
151
|
try {
|
|
152
|
+
if (options.remoteGateway) {
|
|
153
|
+
return await remoteGatewayJson(options.remoteGateway, '/admin/tools');
|
|
154
|
+
}
|
|
80
155
|
return await discoverTools(configPath);
|
|
81
156
|
}
|
|
82
157
|
catch (err) {
|
|
@@ -84,6 +159,12 @@ export function createConfigureWebApp(configPath) {
|
|
|
84
159
|
return { error: err instanceof Error ? err.message : String(err), tools: [] };
|
|
85
160
|
}
|
|
86
161
|
});
|
|
162
|
+
if (options.remoteGateway) {
|
|
163
|
+
registerRemoteGatewayRoutes(app, configPath, options.remoteGateway);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
options.approvals?.registerRoutes(app, configPath);
|
|
167
|
+
}
|
|
87
168
|
app.post('/api/rules', async (request, reply) => {
|
|
88
169
|
try {
|
|
89
170
|
const body = request.body;
|
|
@@ -151,9 +232,8 @@ export function readState(configPath) {
|
|
|
151
232
|
export async function readCommandCenterStatus(configPath) {
|
|
152
233
|
const raw = readConfigObject(configPath);
|
|
153
234
|
const editableProviders = toEditableProviders(asRecord(raw.providers));
|
|
154
|
-
const parsed = GatewayConfig.parse(withoutDisabledProviders(raw));
|
|
155
|
-
const mcpConfigs = getMcpConfigs(parsed.providers);
|
|
156
235
|
const statuses = new Map();
|
|
236
|
+
const enabledProviders = {};
|
|
157
237
|
for (const [id, provider] of Object.entries(editableProviders)) {
|
|
158
238
|
statuses.set(id, {
|
|
159
239
|
id,
|
|
@@ -164,7 +244,26 @@ export async function readCommandCenterStatus(configPath) {
|
|
|
164
244
|
toolFingerprint: '',
|
|
165
245
|
error: provider.enabled ? 'Not checked yet' : undefined,
|
|
166
246
|
});
|
|
247
|
+
if (!provider.enabled)
|
|
248
|
+
continue;
|
|
249
|
+
const rawProvider = asRecord(raw.providers)[id];
|
|
250
|
+
if (provider.type === 'builtin') {
|
|
251
|
+
enabledProviders[id] = rawProvider;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const parsedProvider = parseMcpProviderStatus(rawProvider);
|
|
255
|
+
if (!parsedProvider.success) {
|
|
256
|
+
const status = statuses.get(id);
|
|
257
|
+
if (status) {
|
|
258
|
+
status.status = 'down';
|
|
259
|
+
status.error = parsedProvider.error;
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
enabledProviders[id] = parsedProvider.data;
|
|
167
264
|
}
|
|
265
|
+
const parsed = GatewayConfig.parse({ ...raw, providers: enabledProviders });
|
|
266
|
+
const mcpConfigs = getMcpConfigs(parsed.providers);
|
|
168
267
|
const pool = new ClientPool(mcpConfigs, {
|
|
169
268
|
stdioStderr: 'ignore',
|
|
170
269
|
healthCheck: false,
|
|
@@ -216,17 +315,241 @@ export async function readCommandCenterStatus(configPath) {
|
|
|
216
315
|
await pool.stop().catch(() => { });
|
|
217
316
|
}
|
|
218
317
|
const providers = Array.from(statuses.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
318
|
+
const auditLogs = readAuditLogs(configPath, { limit: 1 });
|
|
219
319
|
return {
|
|
220
320
|
generatedAt: new Date().toISOString(),
|
|
321
|
+
auditDbPath: parsed.audit.db_path,
|
|
221
322
|
providers,
|
|
222
323
|
summary: {
|
|
223
324
|
ok: providers.filter((provider) => provider.status === 'ok').length,
|
|
224
325
|
down: providers.filter((provider) => provider.status === 'down').length,
|
|
225
326
|
disabled: providers.filter((provider) => provider.status === 'disabled').length,
|
|
226
327
|
tools: providers.reduce((sum, provider) => sum + provider.toolCount, 0),
|
|
328
|
+
pendingApprovals: auditLogs.pending.length,
|
|
227
329
|
},
|
|
228
330
|
};
|
|
229
331
|
}
|
|
332
|
+
export function readAuditLogs(configPath, query = {}) {
|
|
333
|
+
const parsed = readConfigObject(configPath);
|
|
334
|
+
const auditConfig = AuditConfig.parse(parsed.audit ?? {});
|
|
335
|
+
const dbPath = auditConfig.db_path;
|
|
336
|
+
if (dbPath === ':memory:' || !existsSync(dbPath)) {
|
|
337
|
+
return { dbPath, entries: [], pending: [] };
|
|
338
|
+
}
|
|
339
|
+
const db = new AuditDb(dbPath);
|
|
340
|
+
try {
|
|
341
|
+
return {
|
|
342
|
+
dbPath,
|
|
343
|
+
entries: db.queryAudit(query),
|
|
344
|
+
pending: db.getPendingHitl(),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
db.close();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async function readRemoteCommandCenterStatus(configPath, remoteGateway) {
|
|
352
|
+
const raw = readConfigObject(configPath);
|
|
353
|
+
const config = GatewayConfig.parse(raw);
|
|
354
|
+
const health = await remoteGatewayJson(remoteGateway, '/health');
|
|
355
|
+
const mcpHealth = health.mcpHealth ?? {};
|
|
356
|
+
const providers = Object.entries(config.providers).map(([id, provider]) => {
|
|
357
|
+
const providerConfig = ProviderConfig.parse(provider);
|
|
358
|
+
const type = providerConfig === 'builtin' ? 'builtin' : providerConfig.type;
|
|
359
|
+
const enabled = providerConfig === 'builtin' ? true : providerConfig.enabled;
|
|
360
|
+
const runtime = mcpHealth[id];
|
|
361
|
+
return {
|
|
362
|
+
id,
|
|
363
|
+
type,
|
|
364
|
+
enabled,
|
|
365
|
+
status: enabled ? runtime ?? 'ok' : 'disabled',
|
|
366
|
+
toolCount: 0,
|
|
367
|
+
toolFingerprint: '',
|
|
368
|
+
...(runtime === 'down' ? { error: 'Provider is down on the gateway' } : {}),
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
const ok = providers.filter((provider) => provider.status === 'ok').length;
|
|
372
|
+
const down = providers.filter((provider) => provider.status === 'down').length;
|
|
373
|
+
const disabled = providers.filter((provider) => provider.status === 'disabled').length;
|
|
374
|
+
return {
|
|
375
|
+
generatedAt: new Date().toISOString(),
|
|
376
|
+
auditDbPath: `gateway:${remoteGateway.url}`,
|
|
377
|
+
providers,
|
|
378
|
+
summary: {
|
|
379
|
+
ok,
|
|
380
|
+
down,
|
|
381
|
+
disabled,
|
|
382
|
+
tools: 0,
|
|
383
|
+
pendingApprovals: health.pendingApprovals ?? 0,
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
async function readRemoteAuditLogs(remoteGateway, query = {}) {
|
|
388
|
+
const params = new URLSearchParams();
|
|
389
|
+
for (const [key, value] of Object.entries(query)) {
|
|
390
|
+
if (value !== undefined && value !== '')
|
|
391
|
+
params.set(key, String(value));
|
|
392
|
+
}
|
|
393
|
+
const suffix = params.toString() ? `?${params.toString()}` : '';
|
|
394
|
+
const [entries, pending] = await Promise.all([
|
|
395
|
+
remoteGatewayJson(remoteGateway, `/audit${suffix}`),
|
|
396
|
+
remoteGatewayJson(remoteGateway, '/hitl/pending'),
|
|
397
|
+
]);
|
|
398
|
+
return {
|
|
399
|
+
dbPath: `gateway:${remoteGateway.url}`,
|
|
400
|
+
entries,
|
|
401
|
+
pending: pending.map((entry) => ({
|
|
402
|
+
id: entry.id,
|
|
403
|
+
code: entry.code,
|
|
404
|
+
agent_id: entry.agent_id ?? entry.agentId ?? '',
|
|
405
|
+
tool: entry.tool,
|
|
406
|
+
args: JSON.stringify(entry.args ?? {}),
|
|
407
|
+
status: 'pending',
|
|
408
|
+
created_at: entry.created_at ?? entry.createdAt ?? new Date().toISOString(),
|
|
409
|
+
})),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function registerRemoteGatewayRoutes(app, configPath, remoteGateway) {
|
|
413
|
+
app.get('/events', (request, reply) => {
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
request.raw.on('close', () => controller.abort());
|
|
416
|
+
void (async () => {
|
|
417
|
+
const response = await remoteGatewayFetch(remoteGateway, '/events', {
|
|
418
|
+
signal: controller.signal,
|
|
419
|
+
});
|
|
420
|
+
if (!response.ok || !response.body) {
|
|
421
|
+
reply.code(response.status || 502).send({ error: await remoteGatewayError(response) });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
reply.hijack();
|
|
425
|
+
reply.raw.writeHead(200, {
|
|
426
|
+
'Content-Type': 'text/event-stream',
|
|
427
|
+
'Cache-Control': 'no-cache',
|
|
428
|
+
Connection: 'keep-alive',
|
|
429
|
+
});
|
|
430
|
+
const reader = response.body.getReader();
|
|
431
|
+
try {
|
|
432
|
+
while (true) {
|
|
433
|
+
const { done, value } = await reader.read();
|
|
434
|
+
if (done)
|
|
435
|
+
break;
|
|
436
|
+
reply.raw.write(Buffer.from(value));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
/* client closed */
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
reply.raw.end();
|
|
444
|
+
}
|
|
445
|
+
})().catch((err) => {
|
|
446
|
+
if (!reply.sent) {
|
|
447
|
+
reply.code(502).send({ error: err instanceof Error ? err.message : String(err) });
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
app.post('/approve', async (request, reply) => {
|
|
452
|
+
const query = request.query;
|
|
453
|
+
const code = typeof query.code === 'string' ? query.code : '';
|
|
454
|
+
if (!code)
|
|
455
|
+
return reply.send({ ok: true });
|
|
456
|
+
const remember = typeof query.remember === 'string' ? query.remember : undefined;
|
|
457
|
+
if (remember) {
|
|
458
|
+
const result = await rememberRemoteDecision(configPath, remoteGateway, code, remember, query.duration_ms);
|
|
459
|
+
if (result.error)
|
|
460
|
+
return reply.code(result.status).send({ error: result.error });
|
|
461
|
+
}
|
|
462
|
+
await forwardRemoteApproval(remoteGateway, 'approve', code);
|
|
463
|
+
return reply.send({ ok: true });
|
|
464
|
+
});
|
|
465
|
+
app.post('/deny', async (request, reply) => {
|
|
466
|
+
const query = request.query;
|
|
467
|
+
const code = typeof query.code === 'string' ? query.code : '';
|
|
468
|
+
if (code)
|
|
469
|
+
await forwardRemoteApproval(remoteGateway, 'deny', code);
|
|
470
|
+
return reply.send({ ok: true });
|
|
471
|
+
});
|
|
472
|
+
app.get('/version', () => ({ version: VERSION }));
|
|
473
|
+
app.get('/version/latest', async (_request, reply) => handleLatestVersion(reply));
|
|
474
|
+
}
|
|
475
|
+
async function rememberRemoteDecision(configPath, remoteGateway, code, remember, durationMsValue) {
|
|
476
|
+
if (remember !== 'always' && remember !== 'temporary') {
|
|
477
|
+
return { status: 400, error: 'Invalid remember mode' };
|
|
478
|
+
}
|
|
479
|
+
const pending = await remoteGatewayJson(remoteGateway, '/hitl/pending');
|
|
480
|
+
const request = pending.find((entry) => entry.code === code || entry.id === code);
|
|
481
|
+
if (!request)
|
|
482
|
+
return { status: 404, error: 'No pending request found for code' };
|
|
483
|
+
const durationMs = typeof durationMsValue === 'string' && durationMsValue.trim()
|
|
484
|
+
? Number(durationMsValue)
|
|
485
|
+
: undefined;
|
|
486
|
+
if (durationMs !== undefined && (!Number.isFinite(durationMs) || durationMs <= 0)) {
|
|
487
|
+
return { status: 400, error: 'Invalid duration_ms' };
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
rememberAllow({
|
|
491
|
+
configPath,
|
|
492
|
+
agentId: request.agent_id ?? request.agentId ?? '',
|
|
493
|
+
tool: request.tool,
|
|
494
|
+
mode: remember,
|
|
495
|
+
...(durationMs ? { durationMs } : {}),
|
|
496
|
+
});
|
|
497
|
+
return { status: 200 };
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
return {
|
|
501
|
+
status: 500,
|
|
502
|
+
error: err instanceof Error ? err.message : 'Failed to update config',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function forwardRemoteApproval(remoteGateway, action, code) {
|
|
507
|
+
const response = await remoteGatewayFetch(remoteGateway, `/${action}?${new URLSearchParams({ code })}`, {
|
|
508
|
+
method: 'POST',
|
|
509
|
+
});
|
|
510
|
+
if (!response.ok)
|
|
511
|
+
throw new Error(await remoteGatewayError(response));
|
|
512
|
+
}
|
|
513
|
+
async function remoteGatewayJson(remoteGateway, path, init = {}) {
|
|
514
|
+
const response = await remoteGatewayFetch(remoteGateway, path, init);
|
|
515
|
+
if (!response.ok)
|
|
516
|
+
throw new Error(await remoteGatewayError(response));
|
|
517
|
+
return (await response.json());
|
|
518
|
+
}
|
|
519
|
+
function remoteGatewayFetch(remoteGateway, path, init = {}) {
|
|
520
|
+
const headers = new Headers(init.headers);
|
|
521
|
+
if (remoteGateway.secret)
|
|
522
|
+
headers.set('Authorization', `Bearer ${remoteGateway.secret}`);
|
|
523
|
+
const base = remoteGateway.url.endsWith('/') ? remoteGateway.url.slice(0, -1) : remoteGateway.url;
|
|
524
|
+
return fetch(`${base}${path}`, { ...init, headers });
|
|
525
|
+
}
|
|
526
|
+
async function remoteGatewayError(response) {
|
|
527
|
+
try {
|
|
528
|
+
const body = (await response.json());
|
|
529
|
+
return body.error ?? `Gateway request failed with HTTP ${response.status}`;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return `Gateway request failed with HTTP ${response.status}`;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async function handleLatestVersion(reply) {
|
|
536
|
+
const now = Date.now();
|
|
537
|
+
if (latestVersionCache && now - latestVersionCache.fetchedAt < LATEST_VERSION_CACHE_TTL_MS) {
|
|
538
|
+
return { latest: latestVersionCache.version };
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const response = await fetch('https://registry.npmjs.org/airlock-bot/latest');
|
|
542
|
+
const data = (await response.json());
|
|
543
|
+
if (!data.version)
|
|
544
|
+
throw new Error('Registry response did not include a version');
|
|
545
|
+
latestVersionCache = { version: data.version, fetchedAt: now };
|
|
546
|
+
return { latest: data.version };
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
reply.code(502);
|
|
550
|
+
return { error: 'Failed to fetch latest version' };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
230
553
|
export function saveRules(configPath, body) {
|
|
231
554
|
const kind = parseKind(body.kind);
|
|
232
555
|
const id = parseId(body.id);
|
|
@@ -429,6 +752,39 @@ function withTimeout(promise, timeoutMs, message) {
|
|
|
429
752
|
clearTimeout(timer);
|
|
430
753
|
});
|
|
431
754
|
}
|
|
755
|
+
function parseAuditLogQuery(query) {
|
|
756
|
+
const input = asRecord(query);
|
|
757
|
+
const result = {};
|
|
758
|
+
if (typeof input.agent === 'string' && input.agent.trim())
|
|
759
|
+
result.agent = input.agent.trim();
|
|
760
|
+
if (typeof input.tool === 'string' && input.tool.trim())
|
|
761
|
+
result.tool = input.tool.trim();
|
|
762
|
+
if (typeof input.since === 'string' && input.since.trim())
|
|
763
|
+
result.since = input.since.trim();
|
|
764
|
+
if (typeof input.limit === 'string' && input.limit.trim()) {
|
|
765
|
+
const limit = Number(input.limit);
|
|
766
|
+
if (Number.isFinite(limit))
|
|
767
|
+
result.limit = Math.trunc(limit);
|
|
768
|
+
}
|
|
769
|
+
return result;
|
|
770
|
+
}
|
|
771
|
+
function parseMcpProviderStatus(provider) {
|
|
772
|
+
try {
|
|
773
|
+
const result = McpServerConfig.safeParse(provider);
|
|
774
|
+
if (result.success)
|
|
775
|
+
return { success: true, data: result.data };
|
|
776
|
+
return {
|
|
777
|
+
success: false,
|
|
778
|
+
error: result.error.issues.map((issue) => issue.message).join('; '),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
return {
|
|
783
|
+
success: false,
|
|
784
|
+
error: err instanceof Error ? err.message : String(err),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
}
|
|
432
788
|
function readConfigObject(configPath) {
|
|
433
789
|
if (!existsSync(configPath))
|
|
434
790
|
throw new Error(`Config file not found: ${configPath}`);
|
|
@@ -712,9 +1068,12 @@ const INDEX_HTML = `<!doctype html>
|
|
|
712
1068
|
border-color: #b9c8ff;
|
|
713
1069
|
color: #173c9c;
|
|
714
1070
|
}
|
|
1071
|
+
.surface-group {
|
|
1072
|
+
margin-bottom: 14px;
|
|
1073
|
+
}
|
|
715
1074
|
.status-grid {
|
|
716
1075
|
display: grid;
|
|
717
|
-
grid-template-columns: repeat(
|
|
1076
|
+
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
|
718
1077
|
gap: 10px;
|
|
719
1078
|
}
|
|
720
1079
|
.status-tile {
|
|
@@ -816,6 +1175,118 @@ const INDEX_HTML = `<!doctype html>
|
|
|
816
1175
|
font-size: 13px;
|
|
817
1176
|
font-weight: 650;
|
|
818
1177
|
}
|
|
1178
|
+
.activity-panel {
|
|
1179
|
+
display: grid;
|
|
1180
|
+
grid-template-rows: auto auto minmax(0, 1fr);
|
|
1181
|
+
gap: 10px;
|
|
1182
|
+
max-height: 440px;
|
|
1183
|
+
padding: 12px;
|
|
1184
|
+
border: 1px solid var(--line);
|
|
1185
|
+
border-radius: 8px;
|
|
1186
|
+
background: var(--panel);
|
|
1187
|
+
box-shadow: var(--shadow);
|
|
1188
|
+
}
|
|
1189
|
+
.activity-panel[hidden] {
|
|
1190
|
+
display: none;
|
|
1191
|
+
}
|
|
1192
|
+
.activity-header {
|
|
1193
|
+
display: flex;
|
|
1194
|
+
align-items: flex-start;
|
|
1195
|
+
justify-content: space-between;
|
|
1196
|
+
gap: 10px;
|
|
1197
|
+
}
|
|
1198
|
+
.activity-header h3 {
|
|
1199
|
+
margin: 0 0 3px;
|
|
1200
|
+
font-size: 14px;
|
|
1201
|
+
}
|
|
1202
|
+
.log-toolbar {
|
|
1203
|
+
display: grid;
|
|
1204
|
+
grid-template-columns: repeat(4, minmax(100px, 1fr)) auto;
|
|
1205
|
+
gap: 8px;
|
|
1206
|
+
}
|
|
1207
|
+
.log-toolbar input, .log-toolbar select {
|
|
1208
|
+
min-height: 34px;
|
|
1209
|
+
min-width: 0;
|
|
1210
|
+
border: 1px solid var(--line);
|
|
1211
|
+
border-radius: 7px;
|
|
1212
|
+
padding: 0 10px;
|
|
1213
|
+
background: #fff;
|
|
1214
|
+
color: var(--ink);
|
|
1215
|
+
}
|
|
1216
|
+
.log-list {
|
|
1217
|
+
display: grid;
|
|
1218
|
+
gap: 8px;
|
|
1219
|
+
min-height: 0;
|
|
1220
|
+
max-height: 280px;
|
|
1221
|
+
overflow: auto;
|
|
1222
|
+
padding-right: 4px;
|
|
1223
|
+
}
|
|
1224
|
+
.log-row {
|
|
1225
|
+
display: grid;
|
|
1226
|
+
grid-template-columns: 172px minmax(120px, 180px) minmax(140px, 1fr) 104px;
|
|
1227
|
+
gap: 10px;
|
|
1228
|
+
padding: 10px;
|
|
1229
|
+
border: 1px solid var(--line);
|
|
1230
|
+
border-radius: 7px;
|
|
1231
|
+
background: #fbfcfe;
|
|
1232
|
+
font-size: 13px;
|
|
1233
|
+
}
|
|
1234
|
+
.log-row.pending {
|
|
1235
|
+
cursor: pointer;
|
|
1236
|
+
border-color: #f1d99a;
|
|
1237
|
+
background: #fffaf0;
|
|
1238
|
+
}
|
|
1239
|
+
.log-cell {
|
|
1240
|
+
min-width: 0;
|
|
1241
|
+
overflow-wrap: anywhere;
|
|
1242
|
+
}
|
|
1243
|
+
.approval-controls {
|
|
1244
|
+
flex-wrap: wrap;
|
|
1245
|
+
justify-content: flex-end;
|
|
1246
|
+
}
|
|
1247
|
+
.approval-controls label {
|
|
1248
|
+
display: inline-flex;
|
|
1249
|
+
align-items: center;
|
|
1250
|
+
gap: 6px;
|
|
1251
|
+
color: var(--muted);
|
|
1252
|
+
font-size: 12px;
|
|
1253
|
+
}
|
|
1254
|
+
.approval-controls input {
|
|
1255
|
+
width: 14px;
|
|
1256
|
+
height: 14px;
|
|
1257
|
+
}
|
|
1258
|
+
.approval-actions {
|
|
1259
|
+
grid-column: 1 / -1;
|
|
1260
|
+
display: flex;
|
|
1261
|
+
flex-wrap: wrap;
|
|
1262
|
+
gap: 8px;
|
|
1263
|
+
}
|
|
1264
|
+
button.approve {
|
|
1265
|
+
border-color: #b8dfca;
|
|
1266
|
+
background: #e8f6ef;
|
|
1267
|
+
color: var(--green);
|
|
1268
|
+
font-weight: 700;
|
|
1269
|
+
}
|
|
1270
|
+
button.deny {
|
|
1271
|
+
border-color: #f2c0bb;
|
|
1272
|
+
background: #fff0ee;
|
|
1273
|
+
color: var(--red);
|
|
1274
|
+
font-weight: 700;
|
|
1275
|
+
}
|
|
1276
|
+
.log-args {
|
|
1277
|
+
grid-column: 1 / -1;
|
|
1278
|
+
max-height: 112px;
|
|
1279
|
+
overflow: auto;
|
|
1280
|
+
margin: 0;
|
|
1281
|
+
padding: 8px;
|
|
1282
|
+
border: 1px solid var(--line);
|
|
1283
|
+
border-radius: 6px;
|
|
1284
|
+
background: #f5f7fb;
|
|
1285
|
+
color: var(--ink);
|
|
1286
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1287
|
+
font-size: 12px;
|
|
1288
|
+
white-space: pre-wrap;
|
|
1289
|
+
}
|
|
819
1290
|
.provider-list {
|
|
820
1291
|
background: var(--panel);
|
|
821
1292
|
border: 1px solid var(--line);
|
|
@@ -846,6 +1317,9 @@ const INDEX_HTML = `<!doctype html>
|
|
|
846
1317
|
border-radius: 8px;
|
|
847
1318
|
box-shadow: var(--shadow);
|
|
848
1319
|
}
|
|
1320
|
+
.toolbar[hidden], .table[hidden] {
|
|
1321
|
+
display: none;
|
|
1322
|
+
}
|
|
849
1323
|
.toolbar input, .toolbar select, .details input {
|
|
850
1324
|
min-height: 34px;
|
|
851
1325
|
border: 1px solid var(--line);
|
|
@@ -1055,10 +1529,67 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1055
1529
|
text-align: center;
|
|
1056
1530
|
color: var(--muted);
|
|
1057
1531
|
}
|
|
1532
|
+
.modal-backdrop {
|
|
1533
|
+
position: fixed;
|
|
1534
|
+
inset: 0;
|
|
1535
|
+
z-index: 20;
|
|
1536
|
+
display: none;
|
|
1537
|
+
align-items: center;
|
|
1538
|
+
justify-content: center;
|
|
1539
|
+
padding: 18px;
|
|
1540
|
+
background: rgba(15, 23, 42, 0.38);
|
|
1541
|
+
}
|
|
1542
|
+
.modal-backdrop.open {
|
|
1543
|
+
display: flex;
|
|
1544
|
+
}
|
|
1545
|
+
.modal {
|
|
1546
|
+
width: min(720px, 100%);
|
|
1547
|
+
max-height: min(760px, 92vh);
|
|
1548
|
+
display: grid;
|
|
1549
|
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
1550
|
+
border: 1px solid var(--line);
|
|
1551
|
+
border-radius: 8px;
|
|
1552
|
+
background: #fff;
|
|
1553
|
+
box-shadow: 0 28px 72px rgba(15, 23, 42, 0.28);
|
|
1554
|
+
}
|
|
1555
|
+
.modal-header, .modal-footer {
|
|
1556
|
+
display: flex;
|
|
1557
|
+
align-items: center;
|
|
1558
|
+
justify-content: space-between;
|
|
1559
|
+
gap: 10px;
|
|
1560
|
+
padding: 14px;
|
|
1561
|
+
border-bottom: 1px solid var(--line);
|
|
1562
|
+
}
|
|
1563
|
+
.modal-footer {
|
|
1564
|
+
justify-content: flex-start;
|
|
1565
|
+
border-top: 1px solid var(--line);
|
|
1566
|
+
border-bottom: 0;
|
|
1567
|
+
flex-wrap: wrap;
|
|
1568
|
+
}
|
|
1569
|
+
.modal-body {
|
|
1570
|
+
min-height: 0;
|
|
1571
|
+
overflow: auto;
|
|
1572
|
+
padding: 14px;
|
|
1573
|
+
}
|
|
1574
|
+
.modal-title {
|
|
1575
|
+
min-width: 0;
|
|
1576
|
+
overflow-wrap: anywhere;
|
|
1577
|
+
font-weight: 750;
|
|
1578
|
+
}
|
|
1579
|
+
.detail-label {
|
|
1580
|
+
margin: 12px 0 4px;
|
|
1581
|
+
color: var(--muted);
|
|
1582
|
+
font-size: 12px;
|
|
1583
|
+
font-weight: 700;
|
|
1584
|
+
text-transform: uppercase;
|
|
1585
|
+
letter-spacing: 0.08em;
|
|
1586
|
+
}
|
|
1058
1587
|
@media (max-width: 1080px) {
|
|
1059
1588
|
.layout { grid-template-columns: 220px minmax(0, 1fr); }
|
|
1060
1589
|
.details { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
|
|
1061
1590
|
.status-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
|
1591
|
+
.log-toolbar { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
|
1592
|
+
.log-row { grid-template-columns: 1fr 1fr; }
|
|
1062
1593
|
.tool-row { grid-template-columns: minmax(160px, 240px) minmax(0, 1fr); }
|
|
1063
1594
|
.rule-actions { grid-column: 1 / -1; justify-content: flex-start; }
|
|
1064
1595
|
}
|
|
@@ -1069,6 +1600,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1069
1600
|
.tool-row { grid-template-columns: 1fr; }
|
|
1070
1601
|
.hero { flex-direction: column; }
|
|
1071
1602
|
.status-grid { grid-template-columns: 1fr; }
|
|
1603
|
+
.log-toolbar { grid-template-columns: 1fr; }
|
|
1072
1604
|
.provider-item { grid-template-columns: 1fr 1fr; }
|
|
1073
1605
|
}
|
|
1074
1606
|
</style>
|
|
@@ -1082,15 +1614,20 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1082
1614
|
</div>
|
|
1083
1615
|
<div class="top-actions">
|
|
1084
1616
|
<button id="refreshStatus">Refresh Status</button>
|
|
1617
|
+
<button id="refreshLogs">Refresh Activity</button>
|
|
1085
1618
|
<button id="refreshTools">Refresh Tools</button>
|
|
1086
1619
|
<button id="saveRules" class="primary">Save</button>
|
|
1087
1620
|
</div>
|
|
1088
1621
|
</header>
|
|
1089
1622
|
<div class="layout">
|
|
1090
1623
|
<aside>
|
|
1091
|
-
<
|
|
1624
|
+
<div class="surface-group">
|
|
1625
|
+
<div class="section-title" style="margin-top:0">System</div>
|
|
1626
|
+
<button id="viewActivity" class="entity">Activity</button>
|
|
1627
|
+
<button id="manageProviders" class="entity">Providers</button>
|
|
1628
|
+
</div>
|
|
1092
1629
|
<div class="row">
|
|
1093
|
-
<div class="section-title"
|
|
1630
|
+
<div class="section-title">Agents</div>
|
|
1094
1631
|
<button id="addAgent" title="Add agent">+</button>
|
|
1095
1632
|
</div>
|
|
1096
1633
|
<div id="agents"></div>
|
|
@@ -1114,7 +1651,29 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1114
1651
|
</div>
|
|
1115
1652
|
<div id="statusGrid" class="status-grid"></div>
|
|
1116
1653
|
<div id="staleNotice" class="stale-notice" hidden></div>
|
|
1117
|
-
<
|
|
1654
|
+
<section id="activitySurface" class="activity-panel" hidden>
|
|
1655
|
+
<div class="activity-header">
|
|
1656
|
+
<div>
|
|
1657
|
+
<h3>Activity</h3>
|
|
1658
|
+
<div id="activityMeta" class="subtle"></div>
|
|
1659
|
+
</div>
|
|
1660
|
+
<div class="approval-controls row">
|
|
1661
|
+
<label><input type="checkbox" id="approvalNotifs"> Notifications</label>
|
|
1662
|
+
<label><input type="checkbox" id="approvalSound"> Sound</label>
|
|
1663
|
+
<span id="versionInfo" class="subtle"></span>
|
|
1664
|
+
<span id="pendingBadge" class="pill">pending 0</span>
|
|
1665
|
+
</div>
|
|
1666
|
+
</div>
|
|
1667
|
+
<div class="log-toolbar">
|
|
1668
|
+
<select id="logAgent"></select>
|
|
1669
|
+
<input id="logTool" placeholder="Tool">
|
|
1670
|
+
<input id="logSince" placeholder="Since ISO timestamp">
|
|
1671
|
+
<input id="logLimit" placeholder="Limit" value="50">
|
|
1672
|
+
<button id="applyLogFilters">Apply</button>
|
|
1673
|
+
</div>
|
|
1674
|
+
<div id="activityList" class="log-list"></div>
|
|
1675
|
+
</section>
|
|
1676
|
+
<div id="rulesToolbar" class="toolbar">
|
|
1118
1677
|
<input id="search" type="search" placeholder="Search tools, endpoints, providers">
|
|
1119
1678
|
<select id="providerFilter"></select>
|
|
1120
1679
|
<select id="decisionFilter">
|
|
@@ -1153,10 +1712,28 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1153
1712
|
</section>
|
|
1154
1713
|
</div>
|
|
1155
1714
|
</div>
|
|
1715
|
+
<div id="approvalModal" class="modal-backdrop">
|
|
1716
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="approvalModalTitle">
|
|
1717
|
+
<div class="modal-header">
|
|
1718
|
+
<div>
|
|
1719
|
+
<div id="approvalModalTitle" class="modal-title"></div>
|
|
1720
|
+
<div id="approvalModalMeta" class="subtle"></div>
|
|
1721
|
+
</div>
|
|
1722
|
+
<button id="approvalModalClose" title="Close">Close</button>
|
|
1723
|
+
</div>
|
|
1724
|
+
<div id="approvalModalBody" class="modal-body"></div>
|
|
1725
|
+
<div id="approvalModalFooter" class="modal-footer"></div>
|
|
1726
|
+
</div>
|
|
1727
|
+
</div>
|
|
1156
1728
|
<script>
|
|
1157
1729
|
const state = {
|
|
1158
1730
|
config: null,
|
|
1159
1731
|
status: null,
|
|
1732
|
+
audit: { dbPath: '', entries: [], pending: [] },
|
|
1733
|
+
livePending: {},
|
|
1734
|
+
currentApprovalCode: '',
|
|
1735
|
+
activityAgentId: '',
|
|
1736
|
+
auditFilters: { agent: '', tool: '', since: '', limit: '50' },
|
|
1160
1737
|
tools: [],
|
|
1161
1738
|
toolsLoaded: false,
|
|
1162
1739
|
errors: [],
|
|
@@ -1188,7 +1765,11 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1188
1765
|
if (!state.activeId) {
|
|
1189
1766
|
const firstAgent = Object.keys(state.config.agents)[0];
|
|
1190
1767
|
const firstProfile = Object.keys(state.config.profiles)[0];
|
|
1191
|
-
if (firstAgent)
|
|
1768
|
+
if (firstAgent) {
|
|
1769
|
+
state.activityAgentId = firstAgent;
|
|
1770
|
+
state.auditFilters.agent = firstAgent;
|
|
1771
|
+
setActive('agent', firstAgent);
|
|
1772
|
+
}
|
|
1192
1773
|
else if (firstProfile) setActive('profile', firstProfile);
|
|
1193
1774
|
}
|
|
1194
1775
|
hydrateDrafts();
|
|
@@ -1226,6 +1807,24 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1226
1807
|
}
|
|
1227
1808
|
}
|
|
1228
1809
|
|
|
1810
|
+
async function refreshLogs() {
|
|
1811
|
+
el('refreshLogs').disabled = true;
|
|
1812
|
+
el('refreshLogs').textContent = 'Loading...';
|
|
1813
|
+
try {
|
|
1814
|
+
const params = new URLSearchParams();
|
|
1815
|
+
for (const [key, value] of Object.entries(state.auditFilters)) {
|
|
1816
|
+
if (String(value || '').trim()) params.set(key, String(value).trim());
|
|
1817
|
+
}
|
|
1818
|
+
state.audit = await api('/api/logs' + (params.toString() ? '?' + params.toString() : ''));
|
|
1819
|
+
render();
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
alert(error.message);
|
|
1822
|
+
} finally {
|
|
1823
|
+
el('refreshLogs').disabled = false;
|
|
1824
|
+
el('refreshLogs').textContent = 'Refresh Activity';
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1229
1828
|
function hydrateDrafts() {
|
|
1230
1829
|
for (const [id, agent] of Object.entries(state.config.agents)) {
|
|
1231
1830
|
const key = keyFor('agent', id);
|
|
@@ -1242,7 +1841,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1242
1841
|
}
|
|
1243
1842
|
|
|
1244
1843
|
function currentDraft() {
|
|
1245
|
-
if (state.activeKind === 'providers') return null;
|
|
1844
|
+
if (state.activeKind === 'providers' || state.activeKind === 'activity') return null;
|
|
1246
1845
|
if (!state.activeId) return null;
|
|
1247
1846
|
return state.drafts[keyFor(state.activeKind, state.activeId)];
|
|
1248
1847
|
}
|
|
@@ -1250,8 +1849,18 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1250
1849
|
function setActive(kind, id) {
|
|
1251
1850
|
state.activeKind = kind;
|
|
1252
1851
|
state.activeId = id || '';
|
|
1852
|
+
if (kind === 'agent') {
|
|
1853
|
+
state.activityAgentId = state.activeId;
|
|
1854
|
+
state.auditFilters.agent = state.activeId;
|
|
1855
|
+
}
|
|
1856
|
+
if (kind === 'activity') {
|
|
1857
|
+
state.activityAgentId = state.activityAgentId || firstAgentId();
|
|
1858
|
+
state.auditFilters.agent = state.activityAgentId;
|
|
1859
|
+
state.activeId = state.activityAgentId;
|
|
1860
|
+
}
|
|
1253
1861
|
hydrateDrafts();
|
|
1254
1862
|
render();
|
|
1863
|
+
if (kind === 'activity') refreshLogs().catch((error) => alert(error.message));
|
|
1255
1864
|
}
|
|
1256
1865
|
|
|
1257
1866
|
function render() {
|
|
@@ -1261,17 +1870,20 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1261
1870
|
renderProviderFilter();
|
|
1262
1871
|
renderStatusGrid();
|
|
1263
1872
|
renderStaleNotice();
|
|
1873
|
+
renderActivity();
|
|
1264
1874
|
renderDetails();
|
|
1265
1875
|
renderTools();
|
|
1266
1876
|
}
|
|
1267
1877
|
|
|
1268
1878
|
function renderStatusGrid() {
|
|
1269
|
-
const
|
|
1879
|
+
const pending = pendingApprovals();
|
|
1880
|
+
const summary = state.status?.summary || { ok: 0, down: 0, disabled: 0, tools: state.tools.length, pendingApprovals: pending.length };
|
|
1270
1881
|
el('statusGrid').innerHTML =
|
|
1271
1882
|
statusTile(summary.ok, 'Providers OK', 'runtime-ok') +
|
|
1272
1883
|
statusTile(summary.down, 'Providers Down', summary.down > 0 ? 'runtime-down' : '') +
|
|
1273
1884
|
statusTile(summary.disabled, 'Disabled', 'runtime-disabled') +
|
|
1274
|
-
statusTile(summary.tools || state.tools.length, 'Tools Visible', '')
|
|
1885
|
+
statusTile(summary.tools || state.tools.length, 'Tools Visible', '') +
|
|
1886
|
+
statusTile(pending.length, 'Pending Approvals', pending.length ? 'runtime-down' : '');
|
|
1275
1887
|
}
|
|
1276
1888
|
|
|
1277
1889
|
function statusTile(value, label, className) {
|
|
@@ -1289,8 +1901,205 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1289
1901
|
: '';
|
|
1290
1902
|
}
|
|
1291
1903
|
|
|
1904
|
+
function pendingApprovals(agentId = '') {
|
|
1905
|
+
const byCode = new Map();
|
|
1906
|
+
for (const request of Object.values(state.livePending || {})) {
|
|
1907
|
+
const pending = normalizePending(request, true);
|
|
1908
|
+
if (pending.code && (!agentId || pending.agentId === agentId)) byCode.set(pending.code, pending);
|
|
1909
|
+
}
|
|
1910
|
+
for (const request of state.audit.pending || []) {
|
|
1911
|
+
const pending = normalizePending(request, false);
|
|
1912
|
+
if (pending.code && (!agentId || pending.agentId === agentId) && !byCode.has(pending.code)) byCode.set(pending.code, pending);
|
|
1913
|
+
}
|
|
1914
|
+
return Array.from(byCode.values());
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function normalizePending(request, live) {
|
|
1918
|
+
const args = live ? request.args : parseArgs(request.args);
|
|
1919
|
+
return {
|
|
1920
|
+
id: request.id || '',
|
|
1921
|
+
code: request.code || '',
|
|
1922
|
+
agentId: request.agentId || request.agent_id || '',
|
|
1923
|
+
tool: request.tool || '',
|
|
1924
|
+
args,
|
|
1925
|
+
status: request.status || 'pending',
|
|
1926
|
+
createdAt: request.createdAt || request.created_at || '',
|
|
1927
|
+
timeoutMs: request.timeoutMs || 0,
|
|
1928
|
+
live
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function parseArgs(value) {
|
|
1933
|
+
if (!value) return {};
|
|
1934
|
+
if (typeof value === 'object') return value;
|
|
1935
|
+
try {
|
|
1936
|
+
const parsed = JSON.parse(value);
|
|
1937
|
+
return parsed && typeof parsed === 'object' ? parsed : { value: parsed };
|
|
1938
|
+
} catch {
|
|
1939
|
+
return { value: String(value) };
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function renderActivity() {
|
|
1944
|
+
el('activitySurface').hidden = state.activeKind !== 'activity';
|
|
1945
|
+
renderActivityAgentFilter();
|
|
1946
|
+
const agentId = state.auditFilters.agent || state.activityAgentId || '';
|
|
1947
|
+
const pending = pendingApprovals(agentId);
|
|
1948
|
+
const entries = state.audit.entries || [];
|
|
1949
|
+
const rows = [
|
|
1950
|
+
...pending.map(pendingRow),
|
|
1951
|
+
...entries.map(auditRow)
|
|
1952
|
+
];
|
|
1953
|
+
el('pendingBadge').textContent = 'pending ' + pending.length;
|
|
1954
|
+
el('activityMeta').textContent = state.audit.dbPath
|
|
1955
|
+
? 'Audit log: ' + state.audit.dbPath + (agentId ? ' · agent ' + agentId : '')
|
|
1956
|
+
: 'Audit log not loaded yet';
|
|
1957
|
+
el('activityList').innerHTML = rows.length
|
|
1958
|
+
? rows.join('')
|
|
1959
|
+
: '<div class="empty">No recent activity.</div>';
|
|
1960
|
+
document.querySelectorAll('[data-pending-row]').forEach((row) => {
|
|
1961
|
+
row.addEventListener('click', (event) => {
|
|
1962
|
+
if (event.target.closest('[data-approval-action]')) return;
|
|
1963
|
+
openApprovalModal(row.dataset.code);
|
|
1964
|
+
});
|
|
1965
|
+
});
|
|
1966
|
+
document.querySelectorAll('[data-approval-action]').forEach((button) => {
|
|
1967
|
+
button.addEventListener('click', (event) => {
|
|
1968
|
+
event.stopPropagation();
|
|
1969
|
+
actApproval(
|
|
1970
|
+
button.dataset.approvalAction,
|
|
1971
|
+
button.dataset.code,
|
|
1972
|
+
button.dataset.remember || '',
|
|
1973
|
+
button.dataset.duration || ''
|
|
1974
|
+
).catch((error) => alert(error.message));
|
|
1975
|
+
});
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
function renderActivityAgentFilter() {
|
|
1980
|
+
const agents = Object.keys(state.config.agents);
|
|
1981
|
+
if (!state.activityAgentId) state.activityAgentId = agents[0] || '';
|
|
1982
|
+
if (!state.auditFilters.agent) state.auditFilters.agent = state.activityAgentId;
|
|
1983
|
+
el('logAgent').innerHTML =
|
|
1984
|
+
'<option value="">All agents</option>' +
|
|
1985
|
+
agents.map((id) => '<option value="' + escapeHtml(id) + '">' + escapeHtml(id) + '</option>').join('');
|
|
1986
|
+
el('logAgent').value = state.auditFilters.agent;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function pendingRow(entry) {
|
|
1990
|
+
return '<div class="log-row pending" data-pending-row data-code="' + escapeHtml(entry.code) + '">' +
|
|
1991
|
+
'<div class="log-cell"><strong>pending approval</strong><br>' + escapeHtml(formatTime(entry.createdAt)) + '</div>' +
|
|
1992
|
+
'<div class="log-cell">' + escapeHtml(entry.agentId) + '</div>' +
|
|
1993
|
+
'<div class="log-cell">' + escapeHtml(entry.tool) + '<br><span class="subtle">code ' + escapeHtml(entry.code) + '</span></div>' +
|
|
1994
|
+
'<div class="log-cell"><span class="tag tag-warn">' + escapeHtml(entry.status) + '</span></div>' +
|
|
1995
|
+
'<pre class="log-args">' + escapeHtml(prettyJson(entry.args)) + '</pre>' +
|
|
1996
|
+
'<div class="approval-actions">' + approvalButtons(entry.code) + '</div>' +
|
|
1997
|
+
'</div>';
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function approvalButtons(code) {
|
|
2001
|
+
return '<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '">Approve</button>' +
|
|
2002
|
+
'<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '" data-remember="temporary" data-duration="3600000">Allow 1h</button>' +
|
|
2003
|
+
'<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '" data-remember="always">Always Allow</button>' +
|
|
2004
|
+
'<button class="deny" data-approval-action="deny" data-code="' + escapeHtml(code) + '">Deny</button>';
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
async function actApproval(action, code, remember, durationMs) {
|
|
2008
|
+
if (!code) return;
|
|
2009
|
+
const params = new URLSearchParams({ code });
|
|
2010
|
+
if (remember) params.set('remember', remember);
|
|
2011
|
+
if (durationMs) params.set('duration_ms', durationMs);
|
|
2012
|
+
const response = await fetch('/' + action + '?' + params.toString(), { method: 'POST' });
|
|
2013
|
+
if (!response.ok) {
|
|
2014
|
+
let message = 'Approval request failed';
|
|
2015
|
+
try {
|
|
2016
|
+
const body = await response.json();
|
|
2017
|
+
message = body.error || message;
|
|
2018
|
+
} catch {
|
|
2019
|
+
message = await response.text();
|
|
2020
|
+
}
|
|
2021
|
+
throw new Error(message);
|
|
2022
|
+
}
|
|
2023
|
+
delete state.livePending[code];
|
|
2024
|
+
closeApprovalModal();
|
|
2025
|
+
await Promise.all([refreshLogs(), refreshStatus()]);
|
|
2026
|
+
render();
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function findPendingApproval(code) {
|
|
2030
|
+
const agentId = state.activeKind === 'activity' ? state.auditFilters.agent || '' : '';
|
|
2031
|
+
return pendingApprovals(agentId).find((entry) => entry.code === code);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function openApprovalModal(code) {
|
|
2035
|
+
const pending = findPendingApproval(code);
|
|
2036
|
+
if (!pending) return;
|
|
2037
|
+
state.currentApprovalCode = code;
|
|
2038
|
+
el('approvalModalTitle').textContent = pending.tool;
|
|
2039
|
+
el('approvalModalMeta').textContent = 'agent ' + pending.agentId + ' · code ' + pending.code;
|
|
2040
|
+
el('approvalModalBody').innerHTML =
|
|
2041
|
+
'<div class="detail-label">Status</div><div>' + escapeHtml(pending.status) + '</div>' +
|
|
2042
|
+
'<div class="detail-label">Created</div><div>' + escapeHtml(formatTime(pending.createdAt)) + '</div>' +
|
|
2043
|
+
(pending.timeoutMs ? '<div class="detail-label">Timeout</div><div>' + Math.round(pending.timeoutMs / 1000) + 's</div>' : '') +
|
|
2044
|
+
'<div class="detail-label">Arguments</div><pre class="log-args">' + escapeHtml(prettyJson(pending.args)) + '</pre>';
|
|
2045
|
+
el('approvalModalFooter').innerHTML = approvalButtons(pending.code);
|
|
2046
|
+
el('approvalModal').classList.add('open');
|
|
2047
|
+
el('approvalModalFooter').querySelectorAll('[data-approval-action]').forEach((button) => {
|
|
2048
|
+
button.addEventListener('click', () => {
|
|
2049
|
+
actApproval(
|
|
2050
|
+
button.dataset.approvalAction,
|
|
2051
|
+
button.dataset.code,
|
|
2052
|
+
button.dataset.remember || '',
|
|
2053
|
+
button.dataset.duration || ''
|
|
2054
|
+
).catch((error) => alert(error.message));
|
|
2055
|
+
});
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function closeApprovalModal() {
|
|
2060
|
+
el('approvalModal').classList.remove('open');
|
|
2061
|
+
state.currentApprovalCode = '';
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function auditRow(entry) {
|
|
2065
|
+
return '<div class="log-row">' +
|
|
2066
|
+
'<div class="log-cell">' + escapeHtml(formatTime(entry.ts)) + '</div>' +
|
|
2067
|
+
'<div class="log-cell">' + escapeHtml(entry.agent_id) + '</div>' +
|
|
2068
|
+
'<div class="log-cell">' + escapeHtml(entry.tool) + '</div>' +
|
|
2069
|
+
'<div class="log-cell"><span class="tag ' + resultTagClass(entry.result) + '">' + escapeHtml(entry.result) + '</span>' + (entry.duration_ms ? '<br><span class="subtle">' + entry.duration_ms + 'ms</span>' : '') + '</div>' +
|
|
2070
|
+
'<pre class="log-args">' + escapeHtml(prettyJson(entry.args)) + (entry.error ? '\\nerror: ' + escapeHtml(entry.error) : '') + '</pre>' +
|
|
2071
|
+
'</div>';
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function resultTagClass(result) {
|
|
2075
|
+
if (String(result).includes('denied') || String(result).includes('error') || String(result).includes('timeout')) return 'tag-danger';
|
|
2076
|
+
if (String(result).includes('hitl')) return 'tag-warn';
|
|
2077
|
+
return 'tag-good';
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function prettyJson(value) {
|
|
2081
|
+
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
|
2082
|
+
try {
|
|
2083
|
+
return JSON.stringify(JSON.parse(value), null, 2);
|
|
2084
|
+
} catch {
|
|
2085
|
+
return String(value || '');
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function formatTime(value) {
|
|
2090
|
+
if (!value) return '';
|
|
2091
|
+
const date = new Date(value);
|
|
2092
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
2093
|
+
return date.toLocaleString();
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function firstAgentId() {
|
|
2097
|
+
return Object.keys(state.config?.agents || {})[0] || '';
|
|
2098
|
+
}
|
|
2099
|
+
|
|
1292
2100
|
function renderProviderNav() {
|
|
1293
2101
|
el('manageProviders').classList.toggle('active', state.activeKind === 'providers');
|
|
2102
|
+
el('viewActivity').classList.toggle('active', state.activeKind === 'activity');
|
|
1294
2103
|
}
|
|
1295
2104
|
|
|
1296
2105
|
function renderProvidersManager() {
|
|
@@ -1381,16 +2190,24 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1381
2190
|
|
|
1382
2191
|
function renderDetails() {
|
|
1383
2192
|
const draft = currentDraft();
|
|
1384
|
-
el('activeTitle').textContent =
|
|
2193
|
+
el('activeTitle').textContent =
|
|
2194
|
+
state.activeKind === 'providers'
|
|
2195
|
+
? 'Providers'
|
|
2196
|
+
: state.activeKind === 'activity'
|
|
2197
|
+
? 'Activity'
|
|
2198
|
+
: state.activeId || 'Select an agent or profile';
|
|
1385
2199
|
el('activeMeta').textContent =
|
|
1386
2200
|
state.activeKind === 'providers'
|
|
1387
2201
|
? 'Top-level tool sources'
|
|
1388
|
-
: state.activeKind === '
|
|
1389
|
-
? '
|
|
1390
|
-
:
|
|
2202
|
+
: state.activeKind === 'activity'
|
|
2203
|
+
? 'Approval and audit history'
|
|
2204
|
+
: state.activeKind === 'agent'
|
|
2205
|
+
? 'Agent allow/ask/deny policy'
|
|
2206
|
+
: 'Reusable profile allow/ask/deny policy';
|
|
1391
2207
|
el('deleteEntity').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1392
2208
|
el('addProvider').style.display = state.activeKind === 'providers' ? 'inline-flex' : 'none';
|
|
1393
|
-
el('deleteEntity').
|
|
2209
|
+
el('deleteEntity').style.display = state.activeKind === 'activity' ? 'none' : el('deleteEntity').style.display;
|
|
2210
|
+
el('deleteEntity').disabled = !state.activeId || state.activeKind === 'activity';
|
|
1394
2211
|
el('profilePanel').style.display = state.activeKind === 'agent' ? 'block' : 'none';
|
|
1395
2212
|
|
|
1396
2213
|
if (!draft) {
|
|
@@ -1405,6 +2222,12 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1405
2222
|
'<span class="pill runtime-down">down ' + status.down + '</span>' +
|
|
1406
2223
|
'<span class="pill runtime-stale">stale ' + stale + '</span>' +
|
|
1407
2224
|
'<span class="pill">tools ' + status.tools + '</span>';
|
|
2225
|
+
} else if (state.activeKind === 'activity') {
|
|
2226
|
+
const agentId = state.auditFilters.agent || '';
|
|
2227
|
+
el('summary').innerHTML =
|
|
2228
|
+
'<span class="pill">agent ' + escapeHtml(agentId || 'all') + '</span>' +
|
|
2229
|
+
'<span class="pill runtime-down">pending ' + pendingApprovals(agentId).length + '</span>' +
|
|
2230
|
+
'<span class="pill">entries ' + (state.audit.entries || []).length + '</span>';
|
|
1408
2231
|
} else {
|
|
1409
2232
|
el('summary').innerHTML = '<div class="empty">Nothing selected.</div>';
|
|
1410
2233
|
}
|
|
@@ -1441,23 +2264,19 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1441
2264
|
function renderDiagnostics() {
|
|
1442
2265
|
const diagnostics = [...(state.config.diagnostics || [])];
|
|
1443
2266
|
for (const error of state.errors) diagnostics.push({ level: 'warn', message: error });
|
|
2267
|
+
if (state.audit.error) diagnostics.push({ level: 'warn', message: state.audit.error });
|
|
1444
2268
|
el('diagnostics').innerHTML = diagnostics.length
|
|
1445
2269
|
? diagnostics.map((d) => '<div class="diagnostic ' + escapeHtml(d.level) + '">' + escapeHtml((d.agent ? '[' + d.agent + '] ' : '') + d.message) + '</div>').join('')
|
|
1446
2270
|
: '<div class="subtle">No diagnostics.</div>';
|
|
1447
2271
|
}
|
|
1448
2272
|
|
|
1449
2273
|
function renderTools() {
|
|
1450
|
-
|
|
1451
|
-
el('
|
|
1452
|
-
el('
|
|
1453
|
-
|
|
1454
|
-
el('bulkAsk').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1455
|
-
el('bulkDeny').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1456
|
-
el('bulkClear').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1457
|
-
el('resetRules').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1458
|
-
el('recommendedRules').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1459
|
-
el('resetAllRules').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
2274
|
+
const rulesVisible = state.activeKind !== 'providers' && state.activeKind !== 'activity';
|
|
2275
|
+
el('rulesToolbar').hidden = !rulesVisible;
|
|
2276
|
+
el('tools').hidden = state.activeKind === 'activity';
|
|
2277
|
+
if (state.activeKind === 'activity') return;
|
|
1460
2278
|
if (state.activeKind === 'providers') {
|
|
2279
|
+
el('tools').hidden = false;
|
|
1461
2280
|
renderProvidersManager();
|
|
1462
2281
|
return;
|
|
1463
2282
|
}
|
|
@@ -1742,10 +2561,86 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1742
2561
|
}[char]));
|
|
1743
2562
|
}
|
|
1744
2563
|
|
|
2564
|
+
function initApprovalSettings() {
|
|
2565
|
+
el('approvalNotifs').checked = localStorage.getItem('airlock:notifs') === 'true';
|
|
2566
|
+
el('approvalSound').checked = localStorage.getItem('airlock:sound') !== 'false';
|
|
2567
|
+
el('approvalNotifs').addEventListener('change', () => {
|
|
2568
|
+
localStorage.setItem('airlock:notifs', el('approvalNotifs').checked);
|
|
2569
|
+
if (el('approvalNotifs').checked && 'Notification' in window) Notification.requestPermission();
|
|
2570
|
+
});
|
|
2571
|
+
el('approvalSound').addEventListener('change', () => {
|
|
2572
|
+
localStorage.setItem('airlock:sound', el('approvalSound').checked);
|
|
2573
|
+
});
|
|
2574
|
+
if ('Notification' in window && el('approvalNotifs').checked) Notification.requestPermission();
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
function connectApprovalEvents() {
|
|
2578
|
+
if (!('EventSource' in window)) return;
|
|
2579
|
+
const events = new EventSource('/events');
|
|
2580
|
+
events.onmessage = (event) => {
|
|
2581
|
+
const message = JSON.parse(event.data);
|
|
2582
|
+
if (message.type === 'new') {
|
|
2583
|
+
state.livePending[message.request.code] = message.request;
|
|
2584
|
+
notifyApproval(message.request);
|
|
2585
|
+
refreshLogs().catch(() => {});
|
|
2586
|
+
render();
|
|
2587
|
+
}
|
|
2588
|
+
if (message.type === 'resolved') {
|
|
2589
|
+
delete state.livePending[message.code];
|
|
2590
|
+
refreshLogs().catch(() => {});
|
|
2591
|
+
refreshStatus().catch(() => {});
|
|
2592
|
+
render();
|
|
2593
|
+
}
|
|
2594
|
+
};
|
|
2595
|
+
events.onerror = () => {
|
|
2596
|
+
events.close();
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
function notifyApproval(request) {
|
|
2601
|
+
if (!el('approvalNotifs').checked) return;
|
|
2602
|
+
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
|
2603
|
+
new Notification('Airlock: ' + request.tool, {
|
|
2604
|
+
body: 'agent: ' + request.agentId + '\\n' + request.code,
|
|
2605
|
+
tag: request.code,
|
|
2606
|
+
silent: !el('approvalSound').checked
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
async function checkVersion() {
|
|
2611
|
+
try {
|
|
2612
|
+
const current = await fetch('/version').then((response) => response.json());
|
|
2613
|
+
const latest = await fetch('/version/latest').then((response) => response.json());
|
|
2614
|
+
el('versionInfo').textContent = current.version
|
|
2615
|
+
? 'v' + current.version + (latest.latest && isNewer(latest.latest, current.version) ? ' · update v' + latest.latest : '')
|
|
2616
|
+
: '';
|
|
2617
|
+
} catch {
|
|
2618
|
+
el('versionInfo').textContent = '';
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function isNewer(latest, current) {
|
|
2623
|
+
const next = String(latest).split('.').map(Number);
|
|
2624
|
+
const now = String(current).split('.').map(Number);
|
|
2625
|
+
for (let i = 0; i < 3; i++) {
|
|
2626
|
+
if ((next[i] || 0) > (now[i] || 0)) return true;
|
|
2627
|
+
if ((next[i] || 0) < (now[i] || 0)) return false;
|
|
2628
|
+
}
|
|
2629
|
+
return false;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function firstPendingCode() {
|
|
2633
|
+
const agentId = state.activeKind === 'activity' ? state.auditFilters.agent || '' : '';
|
|
2634
|
+
const first = pendingApprovals(agentId)[0];
|
|
2635
|
+
return first ? first.code : '';
|
|
2636
|
+
}
|
|
2637
|
+
|
|
1745
2638
|
el('refreshTools').addEventListener('click', refreshTools);
|
|
1746
2639
|
el('refreshStatus').addEventListener('click', refreshStatus);
|
|
2640
|
+
el('refreshLogs').addEventListener('click', refreshLogs);
|
|
1747
2641
|
el('saveRules').addEventListener('click', () => saveCurrent().catch((error) => alert(error.message)));
|
|
1748
2642
|
el('manageProviders').addEventListener('click', () => setActive('providers'));
|
|
2643
|
+
el('viewActivity').addEventListener('click', () => setActive('activity'));
|
|
1749
2644
|
el('addProvider').addEventListener('click', () => addProvider().catch((error) => alert(error.message)));
|
|
1750
2645
|
el('addAgent').addEventListener('click', () => addEntity('agent').catch((error) => alert(error.message)));
|
|
1751
2646
|
el('addProfile').addEventListener('click', () => addEntity('profile').catch((error) => alert(error.message)));
|
|
@@ -1753,6 +2648,19 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1753
2648
|
el('search').addEventListener('input', (event) => { state.search = event.target.value; renderTools(); });
|
|
1754
2649
|
el('providerFilter').addEventListener('change', (event) => { state.provider = event.target.value; renderTools(); });
|
|
1755
2650
|
el('decisionFilter').addEventListener('change', (event) => { state.decision = event.target.value; renderTools(); });
|
|
2651
|
+
el('applyLogFilters').addEventListener('click', () => {
|
|
2652
|
+
state.auditFilters.agent = el('logAgent').value;
|
|
2653
|
+
state.activityAgentId = state.auditFilters.agent || state.activityAgentId;
|
|
2654
|
+
state.auditFilters.tool = el('logTool').value;
|
|
2655
|
+
state.auditFilters.since = el('logSince').value;
|
|
2656
|
+
state.auditFilters.limit = el('logLimit').value;
|
|
2657
|
+
refreshLogs().catch((error) => alert(error.message));
|
|
2658
|
+
});
|
|
2659
|
+
el('logAgent').addEventListener('change', () => {
|
|
2660
|
+
state.auditFilters.agent = el('logAgent').value;
|
|
2661
|
+
if (state.auditFilters.agent) state.activityAgentId = state.auditFilters.agent;
|
|
2662
|
+
refreshLogs().catch((error) => alert(error.message));
|
|
2663
|
+
});
|
|
1756
2664
|
el('bulkAllow').addEventListener('click', () => setVisible('allow'));
|
|
1757
2665
|
el('bulkAsk').addEventListener('click', () => setVisible('ask'));
|
|
1758
2666
|
el('bulkDeny').addEventListener('click', () => setVisible('deny'));
|
|
@@ -1760,8 +2668,29 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1760
2668
|
el('resetRules').addEventListener('click', resetVisibleToCurrentConfig);
|
|
1761
2669
|
el('resetAllRules').addEventListener('click', resetAllToCurrentConfig);
|
|
1762
2670
|
el('recommendedRules').addEventListener('click', setRecommended);
|
|
2671
|
+
el('approvalModalClose').addEventListener('click', closeApprovalModal);
|
|
2672
|
+
el('approvalModal').addEventListener('click', (event) => {
|
|
2673
|
+
if (event.target === el('approvalModal')) closeApprovalModal();
|
|
2674
|
+
});
|
|
2675
|
+
document.addEventListener('keydown', (event) => {
|
|
2676
|
+
if (event.key === 'Escape') {
|
|
2677
|
+
closeApprovalModal();
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
2681
|
+
const target = event.target;
|
|
2682
|
+
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
|
|
2683
|
+
const key = event.key.toLowerCase();
|
|
2684
|
+
if (key !== 'a' && key !== 'd') return;
|
|
2685
|
+
const code = state.currentApprovalCode || firstPendingCode();
|
|
2686
|
+
if (!code) return;
|
|
2687
|
+
actApproval(key === 'a' ? 'approve' : 'deny', code).catch((error) => alert(error.message));
|
|
2688
|
+
});
|
|
1763
2689
|
|
|
1764
|
-
|
|
2690
|
+
initApprovalSettings();
|
|
2691
|
+
connectApprovalEvents();
|
|
2692
|
+
checkVersion().catch(() => {});
|
|
2693
|
+
loadState().then(() => Promise.all([refreshStatus(), refreshLogs(), refreshTools()])).catch((error) => {
|
|
1765
2694
|
document.body.innerHTML = '<div class="empty">' + escapeHtml(error.message) + '</div>';
|
|
1766
2695
|
});
|
|
1767
2696
|
</script>
|