airlock-bot 0.2.35 → 0.2.37
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 +31 -1
- 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 +41 -1
- package/dist/config/loader.js.map +1 -1
- package/dist/config/profiles.d.ts +3 -6
- package/dist/config/profiles.d.ts.map +1 -1
- package/dist/config/profiles.js +52 -19
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/schema.d.ts +368 -8
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +35 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/configure-web/cli.d.ts +41 -2
- package/dist/configure-web/cli.d.ts.map +1 -1
- package/dist/configure-web/cli.js +1283 -55
- 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/middleware/chain-builder.d.ts +1 -1
- package/dist/middleware/chain-builder.d.ts.map +1 -1
- package/dist/middleware/chain-builder.js +4 -2
- package/dist/middleware/chain-builder.js.map +1 -1
- package/dist/middleware/core/arg-policy.d.ts +12 -0
- package/dist/middleware/core/arg-policy.d.ts.map +1 -0
- package/dist/middleware/core/arg-policy.js +91 -0
- package/dist/middleware/core/arg-policy.js.map +1 -0
- 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/examples/profiles.yaml +4 -2
- package/package.json +1 -1
- package/schema.json +79 -0
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
1
2
|
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
|
|
2
3
|
import { parseArgs } from 'util';
|
|
3
4
|
import Fastify from 'fastify';
|
|
4
5
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
6
|
+
import { AuditDb } from '../audit/db.js';
|
|
5
7
|
import { buildAdapters } from '../backend/factory.js';
|
|
6
|
-
import { GatewayConfig, getMcpConfigs, } from '../config/schema.js';
|
|
8
|
+
import { AuditConfig, GatewayConfig, McpServerConfig, ProviderConfig, getMcpConfigs, } from '../config/schema.js';
|
|
9
|
+
import { rememberAllow } from '../config/mutator.js';
|
|
7
10
|
import { validateConfig } from '../config/loader.js';
|
|
11
|
+
import { applyProfiles } from '../config/profiles.js';
|
|
8
12
|
import { ClientPool } from '../pool/pool.js';
|
|
9
13
|
import { checkSuspiciousPatterns } from '../registry/sanitizer.js';
|
|
14
|
+
import { bestSpecificity } from '../allowlist/pattern.js';
|
|
15
|
+
import { VERSION } from '../version.js';
|
|
16
|
+
const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
17
|
+
let latestVersionCache = null;
|
|
10
18
|
const HELP = `
|
|
11
19
|
airlock configure-web - browser UI for profiles, agents, and allow/ask/deny lists
|
|
12
20
|
|
|
@@ -31,9 +39,58 @@ Options:
|
|
|
31
39
|
--host <host> Bind host (default: 127.0.0.1)
|
|
32
40
|
-h, --help Show this help
|
|
33
41
|
`;
|
|
42
|
+
const DASHBOARD_HELP = `
|
|
43
|
+
airlock dashboard - standalone admin dashboard for a remote Airlock gateway
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
airlock dashboard [options]
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
-c, --config <path> Airlock config file (default: ./airlock.yaml)
|
|
50
|
+
-p, --port <port> Web UI port (default: 4177)
|
|
51
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
52
|
+
--gateway-url <url> Gateway URL (default: http://127.0.0.1:4111)
|
|
53
|
+
--gateway-secret <secret> Gateway admin bearer token (default: AIRLOCK_GATEWAY_SECRET or AIRLOCK_API_SECRET)
|
|
54
|
+
-h, --help Show this help
|
|
55
|
+
`;
|
|
34
56
|
export async function runCommandCenter(argv) {
|
|
35
57
|
await runConfigureWeb(argv, 'run');
|
|
36
58
|
}
|
|
59
|
+
export async function runDashboard(argv) {
|
|
60
|
+
const { values } = parseArgs({
|
|
61
|
+
args: argv,
|
|
62
|
+
options: {
|
|
63
|
+
config: { type: 'string', short: 'c', default: './airlock.yaml' },
|
|
64
|
+
port: { type: 'string', short: 'p', default: '4177' },
|
|
65
|
+
host: { type: 'string', default: '127.0.0.1' },
|
|
66
|
+
'gateway-url': { type: 'string', default: 'http://127.0.0.1:4111' },
|
|
67
|
+
'gateway-secret': { type: 'string' },
|
|
68
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
69
|
+
},
|
|
70
|
+
allowPositionals: false,
|
|
71
|
+
});
|
|
72
|
+
if (values.help) {
|
|
73
|
+
console.log(DASHBOARD_HELP);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
const configPath = values.config ?? './airlock.yaml';
|
|
77
|
+
const port = Number(values.port ?? '4177');
|
|
78
|
+
const host = values.host ?? '127.0.0.1';
|
|
79
|
+
const gatewayUrl = values['gateway-url'] ?? 'http://127.0.0.1:4111';
|
|
80
|
+
const gatewaySecret = values['gateway-secret'] ??
|
|
81
|
+
process.env['AIRLOCK_GATEWAY_SECRET'] ??
|
|
82
|
+
process.env['AIRLOCK_API_SECRET'];
|
|
83
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
84
|
+
throw new Error(`Invalid --port: ${values.port}`);
|
|
85
|
+
}
|
|
86
|
+
const app = createConfigureWebApp(configPath, {
|
|
87
|
+
remoteGateway: { url: gatewayUrl, secret: gatewaySecret },
|
|
88
|
+
});
|
|
89
|
+
await app.listen({ host, port });
|
|
90
|
+
console.log(`Airlock dashboard running at http://${host}:${port}`);
|
|
91
|
+
console.log(`Editing ${configPath}`);
|
|
92
|
+
console.log(`Gateway ${gatewayUrl}`);
|
|
93
|
+
}
|
|
37
94
|
export async function runConfigureWeb(argv, entrypoint = 'configure-web') {
|
|
38
95
|
const { values } = parseArgs({
|
|
39
96
|
args: argv,
|
|
@@ -60,7 +117,7 @@ export async function runConfigureWeb(argv, entrypoint = 'configure-web') {
|
|
|
60
117
|
console.log(`Airlock command center running at http://${host}:${port}`);
|
|
61
118
|
console.log(`Editing ${configPath}`);
|
|
62
119
|
}
|
|
63
|
-
export function createConfigureWebApp(configPath) {
|
|
120
|
+
export function createConfigureWebApp(configPath, options = {}) {
|
|
64
121
|
const app = Fastify({ logger: false });
|
|
65
122
|
app.get('/', async (_request, reply) => {
|
|
66
123
|
reply.type('text/html; charset=utf-8').send(INDEX_HTML);
|
|
@@ -68,6 +125,9 @@ export function createConfigureWebApp(configPath) {
|
|
|
68
125
|
app.get('/api/state', () => readState(configPath));
|
|
69
126
|
app.get('/api/status', async (_request, reply) => {
|
|
70
127
|
try {
|
|
128
|
+
if (options.remoteGateway) {
|
|
129
|
+
return await readRemoteCommandCenterStatus(configPath, options.remoteGateway);
|
|
130
|
+
}
|
|
71
131
|
return await readCommandCenterStatus(configPath);
|
|
72
132
|
}
|
|
73
133
|
catch (err) {
|
|
@@ -75,8 +135,28 @@ export function createConfigureWebApp(configPath) {
|
|
|
75
135
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
76
136
|
}
|
|
77
137
|
});
|
|
138
|
+
app.get('/api/logs', async (request, reply) => {
|
|
139
|
+
try {
|
|
140
|
+
if (options.remoteGateway) {
|
|
141
|
+
return await readRemoteAuditLogs(options.remoteGateway, parseAuditLogQuery(request.query));
|
|
142
|
+
}
|
|
143
|
+
return readAuditLogs(configPath, parseAuditLogQuery(request.query));
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
reply.code(500);
|
|
147
|
+
return {
|
|
148
|
+
dbPath: '',
|
|
149
|
+
entries: [],
|
|
150
|
+
pending: [],
|
|
151
|
+
error: err instanceof Error ? err.message : String(err),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
});
|
|
78
155
|
app.get('/api/tools', async (_request, reply) => {
|
|
79
156
|
try {
|
|
157
|
+
if (options.remoteGateway) {
|
|
158
|
+
return await remoteGatewayJson(options.remoteGateway, '/admin/tools');
|
|
159
|
+
}
|
|
80
160
|
return await discoverTools(configPath);
|
|
81
161
|
}
|
|
82
162
|
catch (err) {
|
|
@@ -84,6 +164,12 @@ export function createConfigureWebApp(configPath) {
|
|
|
84
164
|
return { error: err instanceof Error ? err.message : String(err), tools: [] };
|
|
85
165
|
}
|
|
86
166
|
});
|
|
167
|
+
if (options.remoteGateway) {
|
|
168
|
+
registerRemoteGatewayRoutes(app, configPath, options.remoteGateway);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
options.approvals?.registerRoutes(app, configPath);
|
|
172
|
+
}
|
|
87
173
|
app.post('/api/rules', async (request, reply) => {
|
|
88
174
|
try {
|
|
89
175
|
const body = request.body;
|
|
@@ -151,9 +237,8 @@ export function readState(configPath) {
|
|
|
151
237
|
export async function readCommandCenterStatus(configPath) {
|
|
152
238
|
const raw = readConfigObject(configPath);
|
|
153
239
|
const editableProviders = toEditableProviders(asRecord(raw.providers));
|
|
154
|
-
const parsed = GatewayConfig.parse(withoutDisabledProviders(raw));
|
|
155
|
-
const mcpConfigs = getMcpConfigs(parsed.providers);
|
|
156
240
|
const statuses = new Map();
|
|
241
|
+
const enabledProviders = {};
|
|
157
242
|
for (const [id, provider] of Object.entries(editableProviders)) {
|
|
158
243
|
statuses.set(id, {
|
|
159
244
|
id,
|
|
@@ -164,7 +249,26 @@ export async function readCommandCenterStatus(configPath) {
|
|
|
164
249
|
toolFingerprint: '',
|
|
165
250
|
error: provider.enabled ? 'Not checked yet' : undefined,
|
|
166
251
|
});
|
|
252
|
+
if (!provider.enabled)
|
|
253
|
+
continue;
|
|
254
|
+
const rawProvider = asRecord(raw.providers)[id];
|
|
255
|
+
if (provider.type === 'builtin') {
|
|
256
|
+
enabledProviders[id] = rawProvider;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const parsedProvider = parseMcpProviderStatus(rawProvider);
|
|
260
|
+
if (!parsedProvider.success) {
|
|
261
|
+
const status = statuses.get(id);
|
|
262
|
+
if (status) {
|
|
263
|
+
status.status = 'down';
|
|
264
|
+
status.error = parsedProvider.error;
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
enabledProviders[id] = parsedProvider.data;
|
|
167
269
|
}
|
|
270
|
+
const parsed = GatewayConfig.parse({ ...raw, providers: enabledProviders });
|
|
271
|
+
const mcpConfigs = getMcpConfigs(parsed.providers);
|
|
168
272
|
const pool = new ClientPool(mcpConfigs, {
|
|
169
273
|
stdioStderr: 'ignore',
|
|
170
274
|
healthCheck: false,
|
|
@@ -216,17 +320,243 @@ export async function readCommandCenterStatus(configPath) {
|
|
|
216
320
|
await pool.stop().catch(() => { });
|
|
217
321
|
}
|
|
218
322
|
const providers = Array.from(statuses.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
323
|
+
const auditLogs = readAuditLogs(configPath, { limit: 1 });
|
|
219
324
|
return {
|
|
220
325
|
generatedAt: new Date().toISOString(),
|
|
326
|
+
auditDbPath: parsed.audit.db_path,
|
|
221
327
|
providers,
|
|
222
328
|
summary: {
|
|
223
329
|
ok: providers.filter((provider) => provider.status === 'ok').length,
|
|
224
330
|
down: providers.filter((provider) => provider.status === 'down').length,
|
|
225
331
|
disabled: providers.filter((provider) => provider.status === 'disabled').length,
|
|
226
332
|
tools: providers.reduce((sum, provider) => sum + provider.toolCount, 0),
|
|
333
|
+
pendingApprovals: auditLogs.pending.length,
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export function readAuditLogs(configPath, query = {}) {
|
|
338
|
+
const parsed = readConfigObject(configPath);
|
|
339
|
+
const auditConfig = AuditConfig.parse(parsed.audit ?? {});
|
|
340
|
+
const dbPath = auditConfig.db_path;
|
|
341
|
+
if (dbPath === ':memory:' || !existsSync(dbPath)) {
|
|
342
|
+
return { dbPath, entries: [], pending: [] };
|
|
343
|
+
}
|
|
344
|
+
const db = new AuditDb(dbPath);
|
|
345
|
+
try {
|
|
346
|
+
return {
|
|
347
|
+
dbPath,
|
|
348
|
+
entries: db.queryAudit(query),
|
|
349
|
+
pending: db.getPendingHitl(),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
db.close();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function readRemoteCommandCenterStatus(configPath, remoteGateway) {
|
|
357
|
+
const raw = readConfigObject(configPath);
|
|
358
|
+
const config = GatewayConfig.parse(raw);
|
|
359
|
+
const health = await remoteGatewayJson(remoteGateway, '/health');
|
|
360
|
+
const mcpHealth = health.mcpHealth ?? {};
|
|
361
|
+
const providers = Object.entries(config.providers).map(([id, provider]) => {
|
|
362
|
+
const providerConfig = ProviderConfig.parse(provider);
|
|
363
|
+
const type = providerConfig === 'builtin' ? 'builtin' : providerConfig.type;
|
|
364
|
+
const enabled = providerConfig === 'builtin' ? true : providerConfig.enabled;
|
|
365
|
+
const runtime = mcpHealth[id];
|
|
366
|
+
return {
|
|
367
|
+
id,
|
|
368
|
+
type,
|
|
369
|
+
enabled,
|
|
370
|
+
status: enabled ? (runtime ?? 'ok') : 'disabled',
|
|
371
|
+
toolCount: 0,
|
|
372
|
+
toolFingerprint: '',
|
|
373
|
+
...(runtime === 'down' ? { error: 'Provider is down on the gateway' } : {}),
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
const ok = providers.filter((provider) => provider.status === 'ok').length;
|
|
377
|
+
const down = providers.filter((provider) => provider.status === 'down').length;
|
|
378
|
+
const disabled = providers.filter((provider) => provider.status === 'disabled').length;
|
|
379
|
+
return {
|
|
380
|
+
generatedAt: new Date().toISOString(),
|
|
381
|
+
auditDbPath: `gateway:${remoteGateway.url}`,
|
|
382
|
+
providers,
|
|
383
|
+
summary: {
|
|
384
|
+
ok,
|
|
385
|
+
down,
|
|
386
|
+
disabled,
|
|
387
|
+
tools: 0,
|
|
388
|
+
pendingApprovals: health.pendingApprovals ?? 0,
|
|
227
389
|
},
|
|
228
390
|
};
|
|
229
391
|
}
|
|
392
|
+
async function readRemoteAuditLogs(remoteGateway, query = {}) {
|
|
393
|
+
const params = new URLSearchParams();
|
|
394
|
+
for (const [key, value] of Object.entries(query)) {
|
|
395
|
+
if (value !== undefined && value !== '')
|
|
396
|
+
params.set(key, String(value));
|
|
397
|
+
}
|
|
398
|
+
const suffix = params.toString() ? `?${params.toString()}` : '';
|
|
399
|
+
const [entries, pending] = await Promise.all([
|
|
400
|
+
remoteGatewayJson(remoteGateway, `/audit${suffix}`),
|
|
401
|
+
remoteGatewayJson(remoteGateway, '/hitl/pending'),
|
|
402
|
+
]);
|
|
403
|
+
return {
|
|
404
|
+
dbPath: `gateway:${remoteGateway.url}`,
|
|
405
|
+
entries,
|
|
406
|
+
pending: pending.map((entry) => ({
|
|
407
|
+
id: entry.id,
|
|
408
|
+
code: entry.code,
|
|
409
|
+
agent_id: entry.agent_id ?? entry.agentId ?? '',
|
|
410
|
+
tool: entry.tool,
|
|
411
|
+
args: JSON.stringify(entry.args ?? {}),
|
|
412
|
+
status: 'pending',
|
|
413
|
+
created_at: entry.created_at ?? entry.createdAt ?? new Date().toISOString(),
|
|
414
|
+
})),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function registerRemoteGatewayRoutes(app, configPath, remoteGateway) {
|
|
418
|
+
app.get('/events', (request, reply) => {
|
|
419
|
+
const controller = new AbortController();
|
|
420
|
+
request.raw.on('close', () => controller.abort());
|
|
421
|
+
void (async () => {
|
|
422
|
+
const response = await remoteGatewayFetch(remoteGateway, '/events', {
|
|
423
|
+
signal: controller.signal,
|
|
424
|
+
});
|
|
425
|
+
if (!response.ok || !response.body) {
|
|
426
|
+
reply.code(response.status || 502).send({ error: await remoteGatewayError(response) });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
reply.hijack();
|
|
430
|
+
reply.raw.writeHead(200, {
|
|
431
|
+
'Content-Type': 'text/event-stream',
|
|
432
|
+
'Cache-Control': 'no-cache',
|
|
433
|
+
Connection: 'keep-alive',
|
|
434
|
+
});
|
|
435
|
+
const reader = response.body.getReader();
|
|
436
|
+
try {
|
|
437
|
+
while (true) {
|
|
438
|
+
const result = await reader.read();
|
|
439
|
+
if (result.done)
|
|
440
|
+
break;
|
|
441
|
+
if (result.value)
|
|
442
|
+
reply.raw.write(Buffer.from(result.value));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
/* client closed */
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
reply.raw.end();
|
|
450
|
+
}
|
|
451
|
+
})().catch((err) => {
|
|
452
|
+
if (!reply.sent) {
|
|
453
|
+
reply.code(502).send({ error: err instanceof Error ? err.message : String(err) });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
app.post('/approve', async (request, reply) => {
|
|
458
|
+
const query = request.query;
|
|
459
|
+
const code = typeof query.code === 'string' ? query.code : '';
|
|
460
|
+
if (!code)
|
|
461
|
+
return reply.send({ ok: true });
|
|
462
|
+
const remember = typeof query.remember === 'string' ? query.remember : undefined;
|
|
463
|
+
if (remember) {
|
|
464
|
+
const result = await rememberRemoteDecision(configPath, remoteGateway, code, remember, query.duration_ms);
|
|
465
|
+
if (result.error)
|
|
466
|
+
return reply.code(result.status).send({ error: result.error });
|
|
467
|
+
}
|
|
468
|
+
await forwardRemoteApproval(remoteGateway, 'approve', code);
|
|
469
|
+
return reply.send({ ok: true });
|
|
470
|
+
});
|
|
471
|
+
app.post('/deny', async (request, reply) => {
|
|
472
|
+
const query = request.query;
|
|
473
|
+
const code = typeof query.code === 'string' ? query.code : '';
|
|
474
|
+
if (code)
|
|
475
|
+
await forwardRemoteApproval(remoteGateway, 'deny', code);
|
|
476
|
+
return reply.send({ ok: true });
|
|
477
|
+
});
|
|
478
|
+
app.get('/version', () => ({ version: VERSION }));
|
|
479
|
+
app.get('/version/latest', async (_request, reply) => handleLatestVersion(reply));
|
|
480
|
+
}
|
|
481
|
+
async function rememberRemoteDecision(configPath, remoteGateway, code, remember, durationMsValue) {
|
|
482
|
+
if (remember !== 'always' && remember !== 'temporary') {
|
|
483
|
+
return { status: 400, error: 'Invalid remember mode' };
|
|
484
|
+
}
|
|
485
|
+
const pending = await remoteGatewayJson(remoteGateway, '/hitl/pending');
|
|
486
|
+
const request = pending.find((entry) => entry.code === code || entry.id === code);
|
|
487
|
+
if (!request)
|
|
488
|
+
return { status: 404, error: 'No pending request found for code' };
|
|
489
|
+
const durationMs = typeof durationMsValue === 'string' && durationMsValue.trim()
|
|
490
|
+
? Number(durationMsValue)
|
|
491
|
+
: undefined;
|
|
492
|
+
if (durationMs !== undefined && (!Number.isFinite(durationMs) || durationMs <= 0)) {
|
|
493
|
+
return { status: 400, error: 'Invalid duration_ms' };
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
rememberAllow({
|
|
497
|
+
configPath,
|
|
498
|
+
agentId: request.agent_id ?? request.agentId ?? '',
|
|
499
|
+
tool: request.tool,
|
|
500
|
+
mode: remember,
|
|
501
|
+
...(durationMs ? { durationMs } : {}),
|
|
502
|
+
});
|
|
503
|
+
return { status: 200 };
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
return {
|
|
507
|
+
status: 500,
|
|
508
|
+
error: err instanceof Error ? err.message : 'Failed to update config',
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function forwardRemoteApproval(remoteGateway, action, code) {
|
|
513
|
+
const params = new URLSearchParams({ code });
|
|
514
|
+
const response = await remoteGatewayFetch(remoteGateway, `/${action}?${params.toString()}`, {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
});
|
|
517
|
+
if (!response.ok)
|
|
518
|
+
throw new Error(await remoteGatewayError(response));
|
|
519
|
+
}
|
|
520
|
+
async function remoteGatewayJson(remoteGateway, path, init = {}) {
|
|
521
|
+
const response = await remoteGatewayFetch(remoteGateway, path, init);
|
|
522
|
+
if (!response.ok)
|
|
523
|
+
throw new Error(await remoteGatewayError(response));
|
|
524
|
+
return (await response.json());
|
|
525
|
+
}
|
|
526
|
+
function remoteGatewayFetch(remoteGateway, path, init = {}) {
|
|
527
|
+
const headers = new Headers(init.headers);
|
|
528
|
+
if (remoteGateway.secret)
|
|
529
|
+
headers.set('Authorization', `Bearer ${remoteGateway.secret}`);
|
|
530
|
+
const base = remoteGateway.url.endsWith('/') ? remoteGateway.url.slice(0, -1) : remoteGateway.url;
|
|
531
|
+
return fetch(`${base}${path}`, { ...init, headers });
|
|
532
|
+
}
|
|
533
|
+
async function remoteGatewayError(response) {
|
|
534
|
+
try {
|
|
535
|
+
const body = (await response.json());
|
|
536
|
+
return body.error ?? `Gateway request failed with HTTP ${response.status}`;
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return `Gateway request failed with HTTP ${response.status}`;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async function handleLatestVersion(reply) {
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
if (latestVersionCache && now - latestVersionCache.fetchedAt < LATEST_VERSION_CACHE_TTL_MS) {
|
|
545
|
+
return { latest: latestVersionCache.version };
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const response = await fetch('https://registry.npmjs.org/airlock-bot/latest');
|
|
549
|
+
const data = (await response.json());
|
|
550
|
+
if (!data.version)
|
|
551
|
+
throw new Error('Registry response did not include a version');
|
|
552
|
+
latestVersionCache = { version: data.version, fetchedAt: now };
|
|
553
|
+
return { latest: data.version };
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
reply.code(502);
|
|
557
|
+
return { error: 'Failed to fetch latest version' };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
230
560
|
export function saveRules(configPath, body) {
|
|
231
561
|
const kind = parseKind(body.kind);
|
|
232
562
|
const id = parseId(body.id);
|
|
@@ -241,6 +571,7 @@ export function saveRules(configPath, body) {
|
|
|
241
571
|
existing.deny = parseStringArray(body.deny, 'deny');
|
|
242
572
|
}
|
|
243
573
|
else {
|
|
574
|
+
existing.extends = parseStringArray(body.extends, 'extends');
|
|
244
575
|
existing.allow = parseStringArray(body.allow, 'allow');
|
|
245
576
|
existing.ask = parseStringArray(body.ask, 'ask');
|
|
246
577
|
existing.deny = parseStringArray(body.deny, 'deny');
|
|
@@ -261,15 +592,32 @@ export function createEntity(configPath, body) {
|
|
|
261
592
|
const section = asMutableRecord(doc[sectionName]);
|
|
262
593
|
if (section[id])
|
|
263
594
|
throw new Error(`${kind} "${id}" already exists`);
|
|
264
|
-
|
|
265
|
-
|
|
595
|
+
const baseId = parseOptionalId(body.baseId);
|
|
596
|
+
const base = baseId && section[baseId] ? structuredClone(section[baseId]) : undefined;
|
|
597
|
+
const createdToken = kind === 'agent' ? createAgentToken() : undefined;
|
|
598
|
+
if (kind === 'agent') {
|
|
599
|
+
const agent = asMutableRecord(base);
|
|
600
|
+
const profileIds = parseOptionalStringArray(body.profileIds, 'profileIds');
|
|
601
|
+
delete agent.token;
|
|
602
|
+
agent.extends = profileIds ?? stringArray(agent.extends);
|
|
603
|
+
agent.allow = stringArray(agent.allow);
|
|
604
|
+
agent.ask = stringArray(agent.ask);
|
|
605
|
+
agent.deny = stringArray(agent.deny);
|
|
606
|
+
agent.token = createdToken;
|
|
607
|
+
section[id] = agent;
|
|
608
|
+
}
|
|
609
|
+
else if (base) {
|
|
610
|
+
section[id] = structuredClone(base);
|
|
266
611
|
}
|
|
267
612
|
else {
|
|
268
|
-
section[id] = { allow: [], ask: [], deny: [] };
|
|
613
|
+
section[id] = { extends: [], allow: [], ask: [], deny: [] };
|
|
269
614
|
}
|
|
270
615
|
doc[sectionName] = section;
|
|
271
616
|
writeValidatedConfig(configPath, doc);
|
|
272
|
-
return
|
|
617
|
+
return {
|
|
618
|
+
...readState(configPath),
|
|
619
|
+
...(createdToken ? { createdToken: { agentId: id, token: createdToken } } : {}),
|
|
620
|
+
};
|
|
273
621
|
}
|
|
274
622
|
export function deleteEntity(configPath, kind, id) {
|
|
275
623
|
const parsedKind = parseKind(kind);
|
|
@@ -362,6 +710,44 @@ export function annotationTags(annotations, suspiciousPatterns = []) {
|
|
|
362
710
|
tags.push('injection');
|
|
363
711
|
return tags;
|
|
364
712
|
}
|
|
713
|
+
export function resolveEditableRuleDecision(rules, toolName) {
|
|
714
|
+
return resolveEditableRuleMatch(rules, toolName).decision;
|
|
715
|
+
}
|
|
716
|
+
export function resolveEditableRuleMatch(rules, toolName) {
|
|
717
|
+
const deny = bestRuleMatch(rules.deny, toolName);
|
|
718
|
+
const ask = bestRuleMatch(rules.ask, toolName);
|
|
719
|
+
const allow = bestRuleMatch(rules.allow, toolName);
|
|
720
|
+
const denySpec = deny?.specificity ?? -1;
|
|
721
|
+
const askSpec = ask?.specificity ?? -1;
|
|
722
|
+
const allowSpec = allow?.specificity ?? -1;
|
|
723
|
+
const best = Math.max(denySpec, askSpec, allowSpec);
|
|
724
|
+
if (best < 0)
|
|
725
|
+
return { decision: 'unset', source: 'none' };
|
|
726
|
+
if (denySpec === best)
|
|
727
|
+
return editableRuleMatch('deny', deny, toolName);
|
|
728
|
+
if (askSpec === best)
|
|
729
|
+
return editableRuleMatch('ask', ask, toolName);
|
|
730
|
+
return editableRuleMatch('allow', allow, toolName);
|
|
731
|
+
}
|
|
732
|
+
function editableRuleMatch(decision, match, toolName) {
|
|
733
|
+
if (!match)
|
|
734
|
+
return { decision, source: 'none' };
|
|
735
|
+
return {
|
|
736
|
+
decision,
|
|
737
|
+
source: match.pattern === toolName ? 'exact' : 'pattern',
|
|
738
|
+
pattern: match.pattern,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function bestRuleMatch(patterns, toolName) {
|
|
742
|
+
let best;
|
|
743
|
+
for (const pattern of patterns) {
|
|
744
|
+
const specificity = bestSpecificity([pattern], toolName);
|
|
745
|
+
if (specificity >= 0 && (!best || specificity > best.specificity)) {
|
|
746
|
+
best = { pattern, specificity };
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return best;
|
|
750
|
+
}
|
|
365
751
|
export async function discoverTools(configPath) {
|
|
366
752
|
const raw = readConfigObject(configPath);
|
|
367
753
|
const parsed = GatewayConfig.parse(withoutDisabledProviders(raw));
|
|
@@ -429,6 +815,39 @@ function withTimeout(promise, timeoutMs, message) {
|
|
|
429
815
|
clearTimeout(timer);
|
|
430
816
|
});
|
|
431
817
|
}
|
|
818
|
+
function parseAuditLogQuery(query) {
|
|
819
|
+
const input = asRecord(query);
|
|
820
|
+
const result = {};
|
|
821
|
+
if (typeof input.agent === 'string' && input.agent.trim())
|
|
822
|
+
result.agent = input.agent.trim();
|
|
823
|
+
if (typeof input.tool === 'string' && input.tool.trim())
|
|
824
|
+
result.tool = input.tool.trim();
|
|
825
|
+
if (typeof input.since === 'string' && input.since.trim())
|
|
826
|
+
result.since = input.since.trim();
|
|
827
|
+
if (typeof input.limit === 'string' && input.limit.trim()) {
|
|
828
|
+
const limit = Number(input.limit);
|
|
829
|
+
if (Number.isFinite(limit))
|
|
830
|
+
result.limit = Math.trunc(limit);
|
|
831
|
+
}
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
function parseMcpProviderStatus(provider) {
|
|
835
|
+
try {
|
|
836
|
+
const result = McpServerConfig.safeParse(provider);
|
|
837
|
+
if (result.success)
|
|
838
|
+
return { success: true, data: result.data };
|
|
839
|
+
return {
|
|
840
|
+
success: false,
|
|
841
|
+
error: result.error.issues.map((issue) => issue.message).join('; '),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
catch (err) {
|
|
845
|
+
return {
|
|
846
|
+
success: false,
|
|
847
|
+
error: err instanceof Error ? err.message : String(err),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
}
|
|
432
851
|
function readConfigObject(configPath) {
|
|
433
852
|
if (!existsSync(configPath))
|
|
434
853
|
throw new Error(`Config file not found: ${configPath}`);
|
|
@@ -464,6 +883,7 @@ function parseGatewayConfig(doc) {
|
|
|
464
883
|
],
|
|
465
884
|
};
|
|
466
885
|
}
|
|
886
|
+
applyProfiles(result.data);
|
|
467
887
|
return { config: result.data, diagnostics: validateConfig(result.data) };
|
|
468
888
|
}
|
|
469
889
|
catch (err) {
|
|
@@ -482,6 +902,7 @@ function toEditableAgents(input) {
|
|
|
482
902
|
allow: stringArray(agent.allow),
|
|
483
903
|
ask: stringArray(agent.ask),
|
|
484
904
|
deny: stringArray(agent.deny),
|
|
905
|
+
hasToken: typeof agent.token === 'string' && agent.token.trim().length > 0,
|
|
485
906
|
},
|
|
486
907
|
];
|
|
487
908
|
}));
|
|
@@ -512,6 +933,7 @@ function toEditableProfiles(input) {
|
|
|
512
933
|
return [
|
|
513
934
|
id,
|
|
514
935
|
{
|
|
936
|
+
extends: stringArray(profile.extends),
|
|
515
937
|
allow: stringArray(profile.allow),
|
|
516
938
|
ask: stringArray(profile.ask),
|
|
517
939
|
deny: stringArray(profile.deny),
|
|
@@ -539,6 +961,14 @@ function parseId(value) {
|
|
|
539
961
|
}
|
|
540
962
|
return id;
|
|
541
963
|
}
|
|
964
|
+
function parseOptionalId(value) {
|
|
965
|
+
if (value === undefined || value === null || value === '')
|
|
966
|
+
return undefined;
|
|
967
|
+
return parseId(value);
|
|
968
|
+
}
|
|
969
|
+
function createAgentToken() {
|
|
970
|
+
return `airlock_agent_${randomBytes(32).toString('base64url')}`;
|
|
971
|
+
}
|
|
542
972
|
function parseBoolean(value) {
|
|
543
973
|
if (typeof value === 'boolean')
|
|
544
974
|
return value;
|
|
@@ -712,9 +1142,12 @@ const INDEX_HTML = `<!doctype html>
|
|
|
712
1142
|
border-color: #b9c8ff;
|
|
713
1143
|
color: #173c9c;
|
|
714
1144
|
}
|
|
1145
|
+
.surface-group {
|
|
1146
|
+
margin-bottom: 14px;
|
|
1147
|
+
}
|
|
715
1148
|
.status-grid {
|
|
716
1149
|
display: grid;
|
|
717
|
-
grid-template-columns: repeat(
|
|
1150
|
+
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
|
718
1151
|
gap: 10px;
|
|
719
1152
|
}
|
|
720
1153
|
.status-tile {
|
|
@@ -816,6 +1249,118 @@ const INDEX_HTML = `<!doctype html>
|
|
|
816
1249
|
font-size: 13px;
|
|
817
1250
|
font-weight: 650;
|
|
818
1251
|
}
|
|
1252
|
+
.activity-panel {
|
|
1253
|
+
display: grid;
|
|
1254
|
+
grid-template-rows: auto auto minmax(0, 1fr);
|
|
1255
|
+
gap: 10px;
|
|
1256
|
+
max-height: 440px;
|
|
1257
|
+
padding: 12px;
|
|
1258
|
+
border: 1px solid var(--line);
|
|
1259
|
+
border-radius: 8px;
|
|
1260
|
+
background: var(--panel);
|
|
1261
|
+
box-shadow: var(--shadow);
|
|
1262
|
+
}
|
|
1263
|
+
.activity-panel[hidden] {
|
|
1264
|
+
display: none;
|
|
1265
|
+
}
|
|
1266
|
+
.activity-header {
|
|
1267
|
+
display: flex;
|
|
1268
|
+
align-items: flex-start;
|
|
1269
|
+
justify-content: space-between;
|
|
1270
|
+
gap: 10px;
|
|
1271
|
+
}
|
|
1272
|
+
.activity-header h3 {
|
|
1273
|
+
margin: 0 0 3px;
|
|
1274
|
+
font-size: 14px;
|
|
1275
|
+
}
|
|
1276
|
+
.log-toolbar {
|
|
1277
|
+
display: grid;
|
|
1278
|
+
grid-template-columns: repeat(4, minmax(100px, 1fr)) auto;
|
|
1279
|
+
gap: 8px;
|
|
1280
|
+
}
|
|
1281
|
+
.log-toolbar input, .log-toolbar select {
|
|
1282
|
+
min-height: 34px;
|
|
1283
|
+
min-width: 0;
|
|
1284
|
+
border: 1px solid var(--line);
|
|
1285
|
+
border-radius: 7px;
|
|
1286
|
+
padding: 0 10px;
|
|
1287
|
+
background: #fff;
|
|
1288
|
+
color: var(--ink);
|
|
1289
|
+
}
|
|
1290
|
+
.log-list {
|
|
1291
|
+
display: grid;
|
|
1292
|
+
gap: 8px;
|
|
1293
|
+
min-height: 0;
|
|
1294
|
+
max-height: 280px;
|
|
1295
|
+
overflow: auto;
|
|
1296
|
+
padding-right: 4px;
|
|
1297
|
+
}
|
|
1298
|
+
.log-row {
|
|
1299
|
+
display: grid;
|
|
1300
|
+
grid-template-columns: 172px minmax(120px, 180px) minmax(140px, 1fr) 104px;
|
|
1301
|
+
gap: 10px;
|
|
1302
|
+
padding: 10px;
|
|
1303
|
+
border: 1px solid var(--line);
|
|
1304
|
+
border-radius: 7px;
|
|
1305
|
+
background: #fbfcfe;
|
|
1306
|
+
font-size: 13px;
|
|
1307
|
+
}
|
|
1308
|
+
.log-row.pending {
|
|
1309
|
+
cursor: pointer;
|
|
1310
|
+
border-color: #f1d99a;
|
|
1311
|
+
background: #fffaf0;
|
|
1312
|
+
}
|
|
1313
|
+
.log-cell {
|
|
1314
|
+
min-width: 0;
|
|
1315
|
+
overflow-wrap: anywhere;
|
|
1316
|
+
}
|
|
1317
|
+
.approval-controls {
|
|
1318
|
+
flex-wrap: wrap;
|
|
1319
|
+
justify-content: flex-end;
|
|
1320
|
+
}
|
|
1321
|
+
.approval-controls label {
|
|
1322
|
+
display: inline-flex;
|
|
1323
|
+
align-items: center;
|
|
1324
|
+
gap: 6px;
|
|
1325
|
+
color: var(--muted);
|
|
1326
|
+
font-size: 12px;
|
|
1327
|
+
}
|
|
1328
|
+
.approval-controls input {
|
|
1329
|
+
width: 14px;
|
|
1330
|
+
height: 14px;
|
|
1331
|
+
}
|
|
1332
|
+
.approval-actions {
|
|
1333
|
+
grid-column: 1 / -1;
|
|
1334
|
+
display: flex;
|
|
1335
|
+
flex-wrap: wrap;
|
|
1336
|
+
gap: 8px;
|
|
1337
|
+
}
|
|
1338
|
+
button.approve {
|
|
1339
|
+
border-color: #b8dfca;
|
|
1340
|
+
background: #e8f6ef;
|
|
1341
|
+
color: var(--green);
|
|
1342
|
+
font-weight: 700;
|
|
1343
|
+
}
|
|
1344
|
+
button.deny {
|
|
1345
|
+
border-color: #f2c0bb;
|
|
1346
|
+
background: #fff0ee;
|
|
1347
|
+
color: var(--red);
|
|
1348
|
+
font-weight: 700;
|
|
1349
|
+
}
|
|
1350
|
+
.log-args {
|
|
1351
|
+
grid-column: 1 / -1;
|
|
1352
|
+
max-height: 112px;
|
|
1353
|
+
overflow: auto;
|
|
1354
|
+
margin: 0;
|
|
1355
|
+
padding: 8px;
|
|
1356
|
+
border: 1px solid var(--line);
|
|
1357
|
+
border-radius: 6px;
|
|
1358
|
+
background: #f5f7fb;
|
|
1359
|
+
color: var(--ink);
|
|
1360
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1361
|
+
font-size: 12px;
|
|
1362
|
+
white-space: pre-wrap;
|
|
1363
|
+
}
|
|
819
1364
|
.provider-list {
|
|
820
1365
|
background: var(--panel);
|
|
821
1366
|
border: 1px solid var(--line);
|
|
@@ -846,6 +1391,9 @@ const INDEX_HTML = `<!doctype html>
|
|
|
846
1391
|
border-radius: 8px;
|
|
847
1392
|
box-shadow: var(--shadow);
|
|
848
1393
|
}
|
|
1394
|
+
.toolbar[hidden], .table[hidden] {
|
|
1395
|
+
display: none;
|
|
1396
|
+
}
|
|
849
1397
|
.toolbar input, .toolbar select, .details input {
|
|
850
1398
|
min-height: 34px;
|
|
851
1399
|
border: 1px solid var(--line);
|
|
@@ -1005,6 +1553,25 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1005
1553
|
color: var(--red);
|
|
1006
1554
|
font-weight: 700;
|
|
1007
1555
|
}
|
|
1556
|
+
.rule-actions button.active.pattern-match {
|
|
1557
|
+
border-style: dashed;
|
|
1558
|
+
font-weight: 650;
|
|
1559
|
+
}
|
|
1560
|
+
.rule-actions button.active.pattern-match[data-decision="allow"] {
|
|
1561
|
+
border-color: #b8dfca;
|
|
1562
|
+
background: #f6fbf8;
|
|
1563
|
+
color: #4d7f63;
|
|
1564
|
+
}
|
|
1565
|
+
.rule-actions button.active.pattern-match[data-decision="ask"] {
|
|
1566
|
+
border-color: #f1d99a;
|
|
1567
|
+
background: #fffaf0;
|
|
1568
|
+
color: #8a6a19;
|
|
1569
|
+
}
|
|
1570
|
+
.rule-actions button.active.pattern-match[data-decision="deny"] {
|
|
1571
|
+
border-color: #f2c0bb;
|
|
1572
|
+
background: #fff8f7;
|
|
1573
|
+
color: #9b4a43;
|
|
1574
|
+
}
|
|
1008
1575
|
.pill {
|
|
1009
1576
|
display: inline-flex;
|
|
1010
1577
|
align-items: center;
|
|
@@ -1055,10 +1622,104 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1055
1622
|
text-align: center;
|
|
1056
1623
|
color: var(--muted);
|
|
1057
1624
|
}
|
|
1625
|
+
.modal-backdrop {
|
|
1626
|
+
position: fixed;
|
|
1627
|
+
inset: 0;
|
|
1628
|
+
z-index: 20;
|
|
1629
|
+
display: none;
|
|
1630
|
+
align-items: center;
|
|
1631
|
+
justify-content: center;
|
|
1632
|
+
padding: 18px;
|
|
1633
|
+
background: rgba(15, 23, 42, 0.38);
|
|
1634
|
+
}
|
|
1635
|
+
.modal-backdrop.open {
|
|
1636
|
+
display: flex;
|
|
1637
|
+
}
|
|
1638
|
+
.modal {
|
|
1639
|
+
width: min(720px, 100%);
|
|
1640
|
+
max-height: min(760px, 92vh);
|
|
1641
|
+
display: grid;
|
|
1642
|
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
1643
|
+
border: 1px solid var(--line);
|
|
1644
|
+
border-radius: 8px;
|
|
1645
|
+
background: #fff;
|
|
1646
|
+
box-shadow: 0 28px 72px rgba(15, 23, 42, 0.28);
|
|
1647
|
+
}
|
|
1648
|
+
.modal-header, .modal-footer {
|
|
1649
|
+
display: flex;
|
|
1650
|
+
align-items: center;
|
|
1651
|
+
justify-content: space-between;
|
|
1652
|
+
gap: 10px;
|
|
1653
|
+
padding: 14px;
|
|
1654
|
+
border-bottom: 1px solid var(--line);
|
|
1655
|
+
}
|
|
1656
|
+
.modal-footer {
|
|
1657
|
+
justify-content: flex-start;
|
|
1658
|
+
border-top: 1px solid var(--line);
|
|
1659
|
+
border-bottom: 0;
|
|
1660
|
+
flex-wrap: wrap;
|
|
1661
|
+
}
|
|
1662
|
+
.modal-body {
|
|
1663
|
+
min-height: 0;
|
|
1664
|
+
overflow: auto;
|
|
1665
|
+
padding: 14px;
|
|
1666
|
+
}
|
|
1667
|
+
.form-grid {
|
|
1668
|
+
display: grid;
|
|
1669
|
+
gap: 12px;
|
|
1670
|
+
}
|
|
1671
|
+
.field {
|
|
1672
|
+
display: grid;
|
|
1673
|
+
gap: 6px;
|
|
1674
|
+
}
|
|
1675
|
+
.field label {
|
|
1676
|
+
color: var(--muted);
|
|
1677
|
+
font-size: 12px;
|
|
1678
|
+
font-weight: 700;
|
|
1679
|
+
text-transform: uppercase;
|
|
1680
|
+
letter-spacing: 0.08em;
|
|
1681
|
+
}
|
|
1682
|
+
.field input, .field select {
|
|
1683
|
+
min-height: 36px;
|
|
1684
|
+
min-width: 0;
|
|
1685
|
+
border: 1px solid var(--line);
|
|
1686
|
+
border-radius: 7px;
|
|
1687
|
+
padding: 0 10px;
|
|
1688
|
+
background: #fff;
|
|
1689
|
+
color: var(--ink);
|
|
1690
|
+
}
|
|
1691
|
+
.profile-select-list {
|
|
1692
|
+
display: grid;
|
|
1693
|
+
gap: 6px;
|
|
1694
|
+
padding: 8px;
|
|
1695
|
+
border: 1px solid var(--line);
|
|
1696
|
+
border-radius: 7px;
|
|
1697
|
+
background: #fbfcfe;
|
|
1698
|
+
}
|
|
1699
|
+
.token-value {
|
|
1700
|
+
width: 100%;
|
|
1701
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1702
|
+
font-size: 12px;
|
|
1703
|
+
}
|
|
1704
|
+
.modal-title {
|
|
1705
|
+
min-width: 0;
|
|
1706
|
+
overflow-wrap: anywhere;
|
|
1707
|
+
font-weight: 750;
|
|
1708
|
+
}
|
|
1709
|
+
.detail-label {
|
|
1710
|
+
margin: 12px 0 4px;
|
|
1711
|
+
color: var(--muted);
|
|
1712
|
+
font-size: 12px;
|
|
1713
|
+
font-weight: 700;
|
|
1714
|
+
text-transform: uppercase;
|
|
1715
|
+
letter-spacing: 0.08em;
|
|
1716
|
+
}
|
|
1058
1717
|
@media (max-width: 1080px) {
|
|
1059
1718
|
.layout { grid-template-columns: 220px minmax(0, 1fr); }
|
|
1060
1719
|
.details { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
|
|
1061
1720
|
.status-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
|
1721
|
+
.log-toolbar { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
|
1722
|
+
.log-row { grid-template-columns: 1fr 1fr; }
|
|
1062
1723
|
.tool-row { grid-template-columns: minmax(160px, 240px) minmax(0, 1fr); }
|
|
1063
1724
|
.rule-actions { grid-column: 1 / -1; justify-content: flex-start; }
|
|
1064
1725
|
}
|
|
@@ -1069,6 +1730,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1069
1730
|
.tool-row { grid-template-columns: 1fr; }
|
|
1070
1731
|
.hero { flex-direction: column; }
|
|
1071
1732
|
.status-grid { grid-template-columns: 1fr; }
|
|
1733
|
+
.log-toolbar { grid-template-columns: 1fr; }
|
|
1072
1734
|
.provider-item { grid-template-columns: 1fr 1fr; }
|
|
1073
1735
|
}
|
|
1074
1736
|
</style>
|
|
@@ -1082,15 +1744,20 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1082
1744
|
</div>
|
|
1083
1745
|
<div class="top-actions">
|
|
1084
1746
|
<button id="refreshStatus">Refresh Status</button>
|
|
1747
|
+
<button id="refreshLogs">Refresh Activity</button>
|
|
1085
1748
|
<button id="refreshTools">Refresh Tools</button>
|
|
1086
1749
|
<button id="saveRules" class="primary">Save</button>
|
|
1087
1750
|
</div>
|
|
1088
1751
|
</header>
|
|
1089
1752
|
<div class="layout">
|
|
1090
1753
|
<aside>
|
|
1091
|
-
<
|
|
1754
|
+
<div class="surface-group">
|
|
1755
|
+
<div class="section-title" style="margin-top:0">System</div>
|
|
1756
|
+
<button id="viewActivity" class="entity">Activity</button>
|
|
1757
|
+
<button id="manageProviders" class="entity">Providers</button>
|
|
1758
|
+
</div>
|
|
1092
1759
|
<div class="row">
|
|
1093
|
-
<div class="section-title"
|
|
1760
|
+
<div class="section-title">Agents</div>
|
|
1094
1761
|
<button id="addAgent" title="Add agent">+</button>
|
|
1095
1762
|
</div>
|
|
1096
1763
|
<div id="agents"></div>
|
|
@@ -1114,7 +1781,29 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1114
1781
|
</div>
|
|
1115
1782
|
<div id="statusGrid" class="status-grid"></div>
|
|
1116
1783
|
<div id="staleNotice" class="stale-notice" hidden></div>
|
|
1117
|
-
<
|
|
1784
|
+
<section id="activitySurface" class="activity-panel" hidden>
|
|
1785
|
+
<div class="activity-header">
|
|
1786
|
+
<div>
|
|
1787
|
+
<h3>Activity</h3>
|
|
1788
|
+
<div id="activityMeta" class="subtle"></div>
|
|
1789
|
+
</div>
|
|
1790
|
+
<div class="approval-controls row">
|
|
1791
|
+
<label><input type="checkbox" id="approvalNotifs"> Notifications</label>
|
|
1792
|
+
<label><input type="checkbox" id="approvalSound"> Sound</label>
|
|
1793
|
+
<span id="versionInfo" class="subtle"></span>
|
|
1794
|
+
<span id="pendingBadge" class="pill">pending 0</span>
|
|
1795
|
+
</div>
|
|
1796
|
+
</div>
|
|
1797
|
+
<div class="log-toolbar">
|
|
1798
|
+
<select id="logAgent"></select>
|
|
1799
|
+
<input id="logTool" placeholder="Tool">
|
|
1800
|
+
<input id="logSince" placeholder="Since ISO timestamp">
|
|
1801
|
+
<input id="logLimit" placeholder="Limit" value="50">
|
|
1802
|
+
<button id="applyLogFilters">Apply</button>
|
|
1803
|
+
</div>
|
|
1804
|
+
<div id="activityList" class="log-list"></div>
|
|
1805
|
+
</section>
|
|
1806
|
+
<div id="rulesToolbar" class="toolbar">
|
|
1118
1807
|
<input id="search" type="search" placeholder="Search tools, endpoints, providers">
|
|
1119
1808
|
<select id="providerFilter"></select>
|
|
1120
1809
|
<select id="decisionFilter">
|
|
@@ -1153,15 +1842,47 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1153
1842
|
</section>
|
|
1154
1843
|
</div>
|
|
1155
1844
|
</div>
|
|
1845
|
+
<div id="approvalModal" class="modal-backdrop">
|
|
1846
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="approvalModalTitle">
|
|
1847
|
+
<div class="modal-header">
|
|
1848
|
+
<div>
|
|
1849
|
+
<div id="approvalModalTitle" class="modal-title"></div>
|
|
1850
|
+
<div id="approvalModalMeta" class="subtle"></div>
|
|
1851
|
+
</div>
|
|
1852
|
+
<button id="approvalModalClose" title="Close">Close</button>
|
|
1853
|
+
</div>
|
|
1854
|
+
<div id="approvalModalBody" class="modal-body"></div>
|
|
1855
|
+
<div id="approvalModalFooter" class="modal-footer"></div>
|
|
1856
|
+
</div>
|
|
1857
|
+
</div>
|
|
1858
|
+
<div id="entityModal" class="modal-backdrop">
|
|
1859
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="entityModalTitle">
|
|
1860
|
+
<div class="modal-header">
|
|
1861
|
+
<div>
|
|
1862
|
+
<div id="entityModalTitle" class="modal-title"></div>
|
|
1863
|
+
<div id="entityModalMeta" class="subtle"></div>
|
|
1864
|
+
</div>
|
|
1865
|
+
<button id="entityModalClose" title="Close">Close</button>
|
|
1866
|
+
</div>
|
|
1867
|
+
<div id="entityModalBody" class="modal-body"></div>
|
|
1868
|
+
<div id="entityModalFooter" class="modal-footer"></div>
|
|
1869
|
+
</div>
|
|
1870
|
+
</div>
|
|
1156
1871
|
<script>
|
|
1157
1872
|
const state = {
|
|
1158
1873
|
config: null,
|
|
1159
1874
|
status: null,
|
|
1875
|
+
audit: { dbPath: '', entries: [], pending: [] },
|
|
1876
|
+
livePending: {},
|
|
1877
|
+
currentApprovalCode: '',
|
|
1878
|
+
activityAgentId: '',
|
|
1879
|
+
auditFilters: { agent: '', tool: '', since: '', limit: '50' },
|
|
1160
1880
|
tools: [],
|
|
1161
1881
|
toolsLoaded: false,
|
|
1162
1882
|
errors: [],
|
|
1163
1883
|
activeKind: 'agent',
|
|
1164
1884
|
activeId: '',
|
|
1885
|
+
createKind: '',
|
|
1165
1886
|
drafts: {},
|
|
1166
1887
|
descriptionExpanded: {},
|
|
1167
1888
|
search: '',
|
|
@@ -1188,7 +1909,11 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1188
1909
|
if (!state.activeId) {
|
|
1189
1910
|
const firstAgent = Object.keys(state.config.agents)[0];
|
|
1190
1911
|
const firstProfile = Object.keys(state.config.profiles)[0];
|
|
1191
|
-
if (firstAgent)
|
|
1912
|
+
if (firstAgent) {
|
|
1913
|
+
state.activityAgentId = firstAgent;
|
|
1914
|
+
state.auditFilters.agent = firstAgent;
|
|
1915
|
+
setActive('agent', firstAgent);
|
|
1916
|
+
}
|
|
1192
1917
|
else if (firstProfile) setActive('profile', firstProfile);
|
|
1193
1918
|
}
|
|
1194
1919
|
hydrateDrafts();
|
|
@@ -1226,6 +1951,24 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1226
1951
|
}
|
|
1227
1952
|
}
|
|
1228
1953
|
|
|
1954
|
+
async function refreshLogs() {
|
|
1955
|
+
el('refreshLogs').disabled = true;
|
|
1956
|
+
el('refreshLogs').textContent = 'Loading...';
|
|
1957
|
+
try {
|
|
1958
|
+
const params = new URLSearchParams();
|
|
1959
|
+
for (const [key, value] of Object.entries(state.auditFilters)) {
|
|
1960
|
+
if (String(value || '').trim()) params.set(key, String(value).trim());
|
|
1961
|
+
}
|
|
1962
|
+
state.audit = await api('/api/logs' + (params.toString() ? '?' + params.toString() : ''));
|
|
1963
|
+
render();
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
alert(error.message);
|
|
1966
|
+
} finally {
|
|
1967
|
+
el('refreshLogs').disabled = false;
|
|
1968
|
+
el('refreshLogs').textContent = 'Refresh Activity';
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1229
1972
|
function hydrateDrafts() {
|
|
1230
1973
|
for (const [id, agent] of Object.entries(state.config.agents)) {
|
|
1231
1974
|
const key = keyFor('agent', id);
|
|
@@ -1242,7 +1985,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1242
1985
|
}
|
|
1243
1986
|
|
|
1244
1987
|
function currentDraft() {
|
|
1245
|
-
if (state.activeKind === 'providers') return null;
|
|
1988
|
+
if (state.activeKind === 'providers' || state.activeKind === 'activity') return null;
|
|
1246
1989
|
if (!state.activeId) return null;
|
|
1247
1990
|
return state.drafts[keyFor(state.activeKind, state.activeId)];
|
|
1248
1991
|
}
|
|
@@ -1250,8 +1993,18 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1250
1993
|
function setActive(kind, id) {
|
|
1251
1994
|
state.activeKind = kind;
|
|
1252
1995
|
state.activeId = id || '';
|
|
1996
|
+
if (kind === 'agent') {
|
|
1997
|
+
state.activityAgentId = state.activeId;
|
|
1998
|
+
state.auditFilters.agent = state.activeId;
|
|
1999
|
+
}
|
|
2000
|
+
if (kind === 'activity') {
|
|
2001
|
+
state.activityAgentId = state.activityAgentId || firstAgentId();
|
|
2002
|
+
state.auditFilters.agent = state.activityAgentId;
|
|
2003
|
+
state.activeId = state.activityAgentId;
|
|
2004
|
+
}
|
|
1253
2005
|
hydrateDrafts();
|
|
1254
2006
|
render();
|
|
2007
|
+
if (kind === 'activity') refreshLogs().catch((error) => alert(error.message));
|
|
1255
2008
|
}
|
|
1256
2009
|
|
|
1257
2010
|
function render() {
|
|
@@ -1261,17 +2014,20 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1261
2014
|
renderProviderFilter();
|
|
1262
2015
|
renderStatusGrid();
|
|
1263
2016
|
renderStaleNotice();
|
|
2017
|
+
renderActivity();
|
|
1264
2018
|
renderDetails();
|
|
1265
2019
|
renderTools();
|
|
1266
2020
|
}
|
|
1267
2021
|
|
|
1268
2022
|
function renderStatusGrid() {
|
|
1269
|
-
const
|
|
2023
|
+
const pending = pendingApprovals();
|
|
2024
|
+
const summary = state.status?.summary || { ok: 0, down: 0, disabled: 0, tools: state.tools.length, pendingApprovals: pending.length };
|
|
1270
2025
|
el('statusGrid').innerHTML =
|
|
1271
2026
|
statusTile(summary.ok, 'Providers OK', 'runtime-ok') +
|
|
1272
2027
|
statusTile(summary.down, 'Providers Down', summary.down > 0 ? 'runtime-down' : '') +
|
|
1273
2028
|
statusTile(summary.disabled, 'Disabled', 'runtime-disabled') +
|
|
1274
|
-
statusTile(summary.tools || state.tools.length, 'Tools Visible', '')
|
|
2029
|
+
statusTile(summary.tools || state.tools.length, 'Tools Visible', '') +
|
|
2030
|
+
statusTile(pending.length, 'Pending Approvals', pending.length ? 'runtime-down' : '');
|
|
1275
2031
|
}
|
|
1276
2032
|
|
|
1277
2033
|
function statusTile(value, label, className) {
|
|
@@ -1289,8 +2045,205 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1289
2045
|
: '';
|
|
1290
2046
|
}
|
|
1291
2047
|
|
|
2048
|
+
function pendingApprovals(agentId = '') {
|
|
2049
|
+
const byCode = new Map();
|
|
2050
|
+
for (const request of Object.values(state.livePending || {})) {
|
|
2051
|
+
const pending = normalizePending(request, true);
|
|
2052
|
+
if (pending.code && (!agentId || pending.agentId === agentId)) byCode.set(pending.code, pending);
|
|
2053
|
+
}
|
|
2054
|
+
for (const request of state.audit.pending || []) {
|
|
2055
|
+
const pending = normalizePending(request, false);
|
|
2056
|
+
if (pending.code && (!agentId || pending.agentId === agentId) && !byCode.has(pending.code)) byCode.set(pending.code, pending);
|
|
2057
|
+
}
|
|
2058
|
+
return Array.from(byCode.values());
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function normalizePending(request, live) {
|
|
2062
|
+
const args = live ? request.args : parseArgs(request.args);
|
|
2063
|
+
return {
|
|
2064
|
+
id: request.id || '',
|
|
2065
|
+
code: request.code || '',
|
|
2066
|
+
agentId: request.agentId || request.agent_id || '',
|
|
2067
|
+
tool: request.tool || '',
|
|
2068
|
+
args,
|
|
2069
|
+
status: request.status || 'pending',
|
|
2070
|
+
createdAt: request.createdAt || request.created_at || '',
|
|
2071
|
+
timeoutMs: request.timeoutMs || 0,
|
|
2072
|
+
live
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function parseArgs(value) {
|
|
2077
|
+
if (!value) return {};
|
|
2078
|
+
if (typeof value === 'object') return value;
|
|
2079
|
+
try {
|
|
2080
|
+
const parsed = JSON.parse(value);
|
|
2081
|
+
return parsed && typeof parsed === 'object' ? parsed : { value: parsed };
|
|
2082
|
+
} catch {
|
|
2083
|
+
return { value: String(value) };
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function renderActivity() {
|
|
2088
|
+
el('activitySurface').hidden = state.activeKind !== 'activity';
|
|
2089
|
+
renderActivityAgentFilter();
|
|
2090
|
+
const agentId = state.auditFilters.agent || state.activityAgentId || '';
|
|
2091
|
+
const pending = pendingApprovals(agentId);
|
|
2092
|
+
const entries = state.audit.entries || [];
|
|
2093
|
+
const rows = [
|
|
2094
|
+
...pending.map(pendingRow),
|
|
2095
|
+
...entries.map(auditRow)
|
|
2096
|
+
];
|
|
2097
|
+
el('pendingBadge').textContent = 'pending ' + pending.length;
|
|
2098
|
+
el('activityMeta').textContent = state.audit.dbPath
|
|
2099
|
+
? 'Audit log: ' + state.audit.dbPath + (agentId ? ' · agent ' + agentId : '')
|
|
2100
|
+
: 'Audit log not loaded yet';
|
|
2101
|
+
el('activityList').innerHTML = rows.length
|
|
2102
|
+
? rows.join('')
|
|
2103
|
+
: '<div class="empty">No recent activity.</div>';
|
|
2104
|
+
document.querySelectorAll('[data-pending-row]').forEach((row) => {
|
|
2105
|
+
row.addEventListener('click', (event) => {
|
|
2106
|
+
if (event.target.closest('[data-approval-action]')) return;
|
|
2107
|
+
openApprovalModal(row.dataset.code);
|
|
2108
|
+
});
|
|
2109
|
+
});
|
|
2110
|
+
document.querySelectorAll('[data-approval-action]').forEach((button) => {
|
|
2111
|
+
button.addEventListener('click', (event) => {
|
|
2112
|
+
event.stopPropagation();
|
|
2113
|
+
actApproval(
|
|
2114
|
+
button.dataset.approvalAction,
|
|
2115
|
+
button.dataset.code,
|
|
2116
|
+
button.dataset.remember || '',
|
|
2117
|
+
button.dataset.duration || ''
|
|
2118
|
+
).catch((error) => alert(error.message));
|
|
2119
|
+
});
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function renderActivityAgentFilter() {
|
|
2124
|
+
const agents = Object.keys(state.config.agents);
|
|
2125
|
+
if (!state.activityAgentId) state.activityAgentId = agents[0] || '';
|
|
2126
|
+
if (!state.auditFilters.agent) state.auditFilters.agent = state.activityAgentId;
|
|
2127
|
+
el('logAgent').innerHTML =
|
|
2128
|
+
'<option value="">All agents</option>' +
|
|
2129
|
+
agents.map((id) => '<option value="' + escapeHtml(id) + '">' + escapeHtml(id) + '</option>').join('');
|
|
2130
|
+
el('logAgent').value = state.auditFilters.agent;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function pendingRow(entry) {
|
|
2134
|
+
return '<div class="log-row pending" data-pending-row data-code="' + escapeHtml(entry.code) + '">' +
|
|
2135
|
+
'<div class="log-cell"><strong>pending approval</strong><br>' + escapeHtml(formatTime(entry.createdAt)) + '</div>' +
|
|
2136
|
+
'<div class="log-cell">' + escapeHtml(entry.agentId) + '</div>' +
|
|
2137
|
+
'<div class="log-cell">' + escapeHtml(entry.tool) + '<br><span class="subtle">code ' + escapeHtml(entry.code) + '</span></div>' +
|
|
2138
|
+
'<div class="log-cell"><span class="tag tag-warn">' + escapeHtml(entry.status) + '</span></div>' +
|
|
2139
|
+
'<pre class="log-args">' + escapeHtml(prettyJson(entry.args)) + '</pre>' +
|
|
2140
|
+
'<div class="approval-actions">' + approvalButtons(entry.code) + '</div>' +
|
|
2141
|
+
'</div>';
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
function approvalButtons(code) {
|
|
2145
|
+
return '<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '">Approve</button>' +
|
|
2146
|
+
'<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '" data-remember="temporary" data-duration="3600000">Allow 1h</button>' +
|
|
2147
|
+
'<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '" data-remember="always">Always Allow</button>' +
|
|
2148
|
+
'<button class="deny" data-approval-action="deny" data-code="' + escapeHtml(code) + '">Deny</button>';
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
async function actApproval(action, code, remember, durationMs) {
|
|
2152
|
+
if (!code) return;
|
|
2153
|
+
const params = new URLSearchParams({ code });
|
|
2154
|
+
if (remember) params.set('remember', remember);
|
|
2155
|
+
if (durationMs) params.set('duration_ms', durationMs);
|
|
2156
|
+
const response = await fetch('/' + action + '?' + params.toString(), { method: 'POST' });
|
|
2157
|
+
if (!response.ok) {
|
|
2158
|
+
let message = 'Approval request failed';
|
|
2159
|
+
try {
|
|
2160
|
+
const body = await response.json();
|
|
2161
|
+
message = body.error || message;
|
|
2162
|
+
} catch {
|
|
2163
|
+
message = await response.text();
|
|
2164
|
+
}
|
|
2165
|
+
throw new Error(message);
|
|
2166
|
+
}
|
|
2167
|
+
delete state.livePending[code];
|
|
2168
|
+
closeApprovalModal();
|
|
2169
|
+
await Promise.all([refreshLogs(), refreshStatus()]);
|
|
2170
|
+
render();
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function findPendingApproval(code) {
|
|
2174
|
+
const agentId = state.activeKind === 'activity' ? state.auditFilters.agent || '' : '';
|
|
2175
|
+
return pendingApprovals(agentId).find((entry) => entry.code === code);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
function openApprovalModal(code) {
|
|
2179
|
+
const pending = findPendingApproval(code);
|
|
2180
|
+
if (!pending) return;
|
|
2181
|
+
state.currentApprovalCode = code;
|
|
2182
|
+
el('approvalModalTitle').textContent = pending.tool;
|
|
2183
|
+
el('approvalModalMeta').textContent = 'agent ' + pending.agentId + ' · code ' + pending.code;
|
|
2184
|
+
el('approvalModalBody').innerHTML =
|
|
2185
|
+
'<div class="detail-label">Status</div><div>' + escapeHtml(pending.status) + '</div>' +
|
|
2186
|
+
'<div class="detail-label">Created</div><div>' + escapeHtml(formatTime(pending.createdAt)) + '</div>' +
|
|
2187
|
+
(pending.timeoutMs ? '<div class="detail-label">Timeout</div><div>' + Math.round(pending.timeoutMs / 1000) + 's</div>' : '') +
|
|
2188
|
+
'<div class="detail-label">Arguments</div><pre class="log-args">' + escapeHtml(prettyJson(pending.args)) + '</pre>';
|
|
2189
|
+
el('approvalModalFooter').innerHTML = approvalButtons(pending.code);
|
|
2190
|
+
el('approvalModal').classList.add('open');
|
|
2191
|
+
el('approvalModalFooter').querySelectorAll('[data-approval-action]').forEach((button) => {
|
|
2192
|
+
button.addEventListener('click', () => {
|
|
2193
|
+
actApproval(
|
|
2194
|
+
button.dataset.approvalAction,
|
|
2195
|
+
button.dataset.code,
|
|
2196
|
+
button.dataset.remember || '',
|
|
2197
|
+
button.dataset.duration || ''
|
|
2198
|
+
).catch((error) => alert(error.message));
|
|
2199
|
+
});
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
function closeApprovalModal() {
|
|
2204
|
+
el('approvalModal').classList.remove('open');
|
|
2205
|
+
state.currentApprovalCode = '';
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function auditRow(entry) {
|
|
2209
|
+
return '<div class="log-row">' +
|
|
2210
|
+
'<div class="log-cell">' + escapeHtml(formatTime(entry.ts)) + '</div>' +
|
|
2211
|
+
'<div class="log-cell">' + escapeHtml(entry.agent_id) + '</div>' +
|
|
2212
|
+
'<div class="log-cell">' + escapeHtml(entry.tool) + '</div>' +
|
|
2213
|
+
'<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>' +
|
|
2214
|
+
'<pre class="log-args">' + escapeHtml(prettyJson(entry.args)) + (entry.error ? '\\nerror: ' + escapeHtml(entry.error) : '') + '</pre>' +
|
|
2215
|
+
'</div>';
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function resultTagClass(result) {
|
|
2219
|
+
if (String(result).includes('denied') || String(result).includes('error') || String(result).includes('timeout')) return 'tag-danger';
|
|
2220
|
+
if (String(result).includes('hitl')) return 'tag-warn';
|
|
2221
|
+
return 'tag-good';
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function prettyJson(value) {
|
|
2225
|
+
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
|
2226
|
+
try {
|
|
2227
|
+
return JSON.stringify(JSON.parse(value), null, 2);
|
|
2228
|
+
} catch {
|
|
2229
|
+
return String(value || '');
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
function formatTime(value) {
|
|
2234
|
+
if (!value) return '';
|
|
2235
|
+
const date = new Date(value);
|
|
2236
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
2237
|
+
return date.toLocaleString();
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function firstAgentId() {
|
|
2241
|
+
return Object.keys(state.config?.agents || {})[0] || '';
|
|
2242
|
+
}
|
|
2243
|
+
|
|
1292
2244
|
function renderProviderNav() {
|
|
1293
2245
|
el('manageProviders').classList.toggle('active', state.activeKind === 'providers');
|
|
2246
|
+
el('viewActivity').classList.toggle('active', state.activeKind === 'activity');
|
|
1294
2247
|
}
|
|
1295
2248
|
|
|
1296
2249
|
function renderProvidersManager() {
|
|
@@ -1381,17 +2334,26 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1381
2334
|
|
|
1382
2335
|
function renderDetails() {
|
|
1383
2336
|
const draft = currentDraft();
|
|
1384
|
-
el('activeTitle').textContent =
|
|
2337
|
+
el('activeTitle').textContent =
|
|
2338
|
+
state.activeKind === 'providers'
|
|
2339
|
+
? 'Providers'
|
|
2340
|
+
: state.activeKind === 'activity'
|
|
2341
|
+
? 'Activity'
|
|
2342
|
+
: state.activeId || 'Select an agent or profile';
|
|
1385
2343
|
el('activeMeta').textContent =
|
|
1386
2344
|
state.activeKind === 'providers'
|
|
1387
2345
|
? 'Top-level tool sources'
|
|
1388
|
-
: state.activeKind === '
|
|
1389
|
-
? '
|
|
1390
|
-
:
|
|
2346
|
+
: state.activeKind === 'activity'
|
|
2347
|
+
? 'Approval and audit history'
|
|
2348
|
+
: state.activeKind === 'agent'
|
|
2349
|
+
? 'Agent allow/ask/deny policy'
|
|
2350
|
+
: 'Reusable profile allow/ask/deny policy';
|
|
1391
2351
|
el('deleteEntity').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1392
2352
|
el('addProvider').style.display = state.activeKind === 'providers' ? 'inline-flex' : 'none';
|
|
1393
|
-
el('deleteEntity').
|
|
1394
|
-
el('
|
|
2353
|
+
el('deleteEntity').style.display = state.activeKind === 'activity' ? 'none' : el('deleteEntity').style.display;
|
|
2354
|
+
el('deleteEntity').disabled = !state.activeId || state.activeKind === 'activity';
|
|
2355
|
+
el('profilePanel').style.display =
|
|
2356
|
+
state.activeKind === 'agent' || state.activeKind === 'profile' ? 'block' : 'none';
|
|
1395
2357
|
|
|
1396
2358
|
if (!draft) {
|
|
1397
2359
|
if (state.activeKind === 'providers') {
|
|
@@ -1405,6 +2367,12 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1405
2367
|
'<span class="pill runtime-down">down ' + status.down + '</span>' +
|
|
1406
2368
|
'<span class="pill runtime-stale">stale ' + stale + '</span>' +
|
|
1407
2369
|
'<span class="pill">tools ' + status.tools + '</span>';
|
|
2370
|
+
} else if (state.activeKind === 'activity') {
|
|
2371
|
+
const agentId = state.auditFilters.agent || '';
|
|
2372
|
+
el('summary').innerHTML =
|
|
2373
|
+
'<span class="pill">agent ' + escapeHtml(agentId || 'all') + '</span>' +
|
|
2374
|
+
'<span class="pill runtime-down">pending ' + pendingApprovals(agentId).length + '</span>' +
|
|
2375
|
+
'<span class="pill">entries ' + (state.audit.entries || []).length + '</span>';
|
|
1408
2376
|
} else {
|
|
1409
2377
|
el('summary').innerHTML = '<div class="empty">Nothing selected.</div>';
|
|
1410
2378
|
}
|
|
@@ -1413,12 +2381,18 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1413
2381
|
}
|
|
1414
2382
|
|
|
1415
2383
|
const denyCount = draft.deny.length;
|
|
2384
|
+
const tokenPill = state.activeKind === 'agent'
|
|
2385
|
+
? '<span class="pill ' + (state.config.agents[state.activeId]?.hasToken ? 'tag-good' : 'tag-danger') + '">' + (state.config.agents[state.activeId]?.hasToken ? 'token set' : 'token missing') + '</span>'
|
|
2386
|
+
: '';
|
|
1416
2387
|
el('summary').innerHTML =
|
|
1417
2388
|
'<span class="pill">allow ' + draft.allow.length + '</span>' +
|
|
1418
2389
|
'<span class="pill">ask ' + draft.ask.length + '</span>' +
|
|
1419
|
-
'<span class="pill">deny ' + denyCount + '</span>'
|
|
2390
|
+
'<span class="pill">deny ' + denyCount + '</span>' +
|
|
2391
|
+
tokenPill;
|
|
1420
2392
|
|
|
1421
|
-
const profiles = Object.keys(state.config.profiles)
|
|
2393
|
+
const profiles = Object.keys(state.config.profiles).filter(
|
|
2394
|
+
(id) => state.activeKind !== 'profile' || id !== state.activeId
|
|
2395
|
+
);
|
|
1422
2396
|
el('profileChecks').innerHTML = profiles.length
|
|
1423
2397
|
? profiles.map((id) => {
|
|
1424
2398
|
const checked = (draft.extends || []).includes(id) ? ' checked' : '';
|
|
@@ -1441,23 +2415,19 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1441
2415
|
function renderDiagnostics() {
|
|
1442
2416
|
const diagnostics = [...(state.config.diagnostics || [])];
|
|
1443
2417
|
for (const error of state.errors) diagnostics.push({ level: 'warn', message: error });
|
|
2418
|
+
if (state.audit.error) diagnostics.push({ level: 'warn', message: state.audit.error });
|
|
1444
2419
|
el('diagnostics').innerHTML = diagnostics.length
|
|
1445
2420
|
? diagnostics.map((d) => '<div class="diagnostic ' + escapeHtml(d.level) + '">' + escapeHtml((d.agent ? '[' + d.agent + '] ' : '') + d.message) + '</div>').join('')
|
|
1446
2421
|
: '<div class="subtle">No diagnostics.</div>';
|
|
1447
2422
|
}
|
|
1448
2423
|
|
|
1449
2424
|
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';
|
|
2425
|
+
const rulesVisible = state.activeKind !== 'providers' && state.activeKind !== 'activity';
|
|
2426
|
+
el('rulesToolbar').hidden = !rulesVisible;
|
|
2427
|
+
el('tools').hidden = state.activeKind === 'activity';
|
|
2428
|
+
if (state.activeKind === 'activity') return;
|
|
1460
2429
|
if (state.activeKind === 'providers') {
|
|
2430
|
+
el('tools').hidden = false;
|
|
1461
2431
|
renderProvidersManager();
|
|
1462
2432
|
return;
|
|
1463
2433
|
}
|
|
@@ -1473,7 +2443,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1473
2443
|
|
|
1474
2444
|
const visible = filteredTools();
|
|
1475
2445
|
el('tools').innerHTML = visible.length
|
|
1476
|
-
? visible.map((tool) => toolRow(tool,
|
|
2446
|
+
? visible.map((tool) => toolRow(tool, getDecisionMatch(draft, tool.name))).join('')
|
|
1477
2447
|
: '<div class="empty">No tools match the current filters.</div>';
|
|
1478
2448
|
|
|
1479
2449
|
document.querySelectorAll('[data-rule]').forEach((button) => {
|
|
@@ -1502,7 +2472,8 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1502
2472
|
});
|
|
1503
2473
|
}
|
|
1504
2474
|
|
|
1505
|
-
function toolRow(tool,
|
|
2475
|
+
function toolRow(tool, match) {
|
|
2476
|
+
const decision = match.decision;
|
|
1506
2477
|
const tags = tool.tags && tool.tags.length ? tool.tags : ['untagged'];
|
|
1507
2478
|
const tagHtml = tags.map((tag) => '<span class="tag ' + tagClass(tag) + '">' + escapeHtml(tag) + '</span>').join('');
|
|
1508
2479
|
const recommended = '<span class="tag tag-recommended ' + recommendedClass(tool.recommended) + '">recommended ' + escapeHtml(tool.recommended) + '</span>';
|
|
@@ -1517,10 +2488,10 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1517
2488
|
'<div class="tool-name"><span class="provider">' + escapeHtml(tool.provider) + '</span><br>' + escapeHtml(tool.shortName || tool.name) + '</div>' +
|
|
1518
2489
|
'<div class="description"><div class="description-content' + descriptionClass + '">' + formatDescription(description) + '</div>' + toggle + '<div class="tag-list">' + tagHtml + recommended + '</div></div>' +
|
|
1519
2490
|
'<div class="rule-actions">' +
|
|
1520
|
-
ruleButton(tool.name, 'allow',
|
|
1521
|
-
ruleButton(tool.name, 'ask',
|
|
1522
|
-
ruleButton(tool.name, 'deny',
|
|
1523
|
-
ruleButton(tool.name, 'unset',
|
|
2491
|
+
ruleButton(tool.name, 'allow', match) +
|
|
2492
|
+
ruleButton(tool.name, 'ask', match) +
|
|
2493
|
+
ruleButton(tool.name, 'deny', match) +
|
|
2494
|
+
ruleButton(tool.name, 'unset', match) +
|
|
1524
2495
|
'</div>' +
|
|
1525
2496
|
'</div>';
|
|
1526
2497
|
}
|
|
@@ -1575,17 +2546,67 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1575
2546
|
return 'tag-good';
|
|
1576
2547
|
}
|
|
1577
2548
|
|
|
1578
|
-
function ruleButton(tool, decision,
|
|
1579
|
-
const active = decision ===
|
|
2549
|
+
function ruleButton(tool, decision, activeMatch) {
|
|
2550
|
+
const active = decision === activeMatch.decision ? ' active' : '';
|
|
2551
|
+
const patternMatch = active && activeMatch.source === 'pattern' ? ' pattern-match' : '';
|
|
1580
2552
|
const label = decision === 'unset' ? 'Clear' : decision;
|
|
1581
|
-
|
|
2553
|
+
const title = activeMatch.source === 'pattern' && decision === activeMatch.decision
|
|
2554
|
+
? decision + ' by ' + activeMatch.pattern + '. Click to add an exact rule.'
|
|
2555
|
+
: decision === activeMatch.decision && activeMatch.source === 'exact'
|
|
2556
|
+
? decision + ' by exact rule.'
|
|
2557
|
+
: decision === 'unset'
|
|
2558
|
+
? 'Clear exact rule for this tool.'
|
|
2559
|
+
: 'Set exact ' + decision + ' rule for this tool.';
|
|
2560
|
+
return '<button class="' + active + patternMatch + '" title="' + escapeHtml(title) + '" data-rule data-tool="' + escapeHtml(tool) + '" data-decision="' + decision + '">' + label + '</button>';
|
|
1582
2561
|
}
|
|
1583
2562
|
|
|
1584
2563
|
function getDecision(draft, tool) {
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
2564
|
+
return getDecisionMatch(draft, tool).decision;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
function getDecisionMatch(draft, tool) {
|
|
2568
|
+
const deny = bestRuleMatch(draft.deny, tool);
|
|
2569
|
+
const ask = bestRuleMatch(draft.ask, tool);
|
|
2570
|
+
const allow = bestRuleMatch(draft.allow, tool);
|
|
2571
|
+
const denySpec = deny ? deny.specificity : -1;
|
|
2572
|
+
const askSpec = ask ? ask.specificity : -1;
|
|
2573
|
+
const allowSpec = allow ? allow.specificity : -1;
|
|
2574
|
+
const best = Math.max(denySpec, askSpec, allowSpec);
|
|
2575
|
+
if (best < 0) return { decision: 'unset', source: 'none' };
|
|
2576
|
+
if (denySpec === best) return decisionMatch('deny', deny, tool);
|
|
2577
|
+
if (askSpec === best) return decisionMatch('ask', ask, tool);
|
|
2578
|
+
return decisionMatch('allow', allow, tool);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
function decisionMatch(decision, match, tool) {
|
|
2582
|
+
if (!match) return { decision, source: 'none' };
|
|
2583
|
+
return {
|
|
2584
|
+
decision,
|
|
2585
|
+
source: match.pattern === tool ? 'exact' : 'pattern',
|
|
2586
|
+
pattern: match.pattern
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function bestRuleMatch(patterns, tool) {
|
|
2591
|
+
let best = null;
|
|
2592
|
+
for (const pattern of patterns || []) {
|
|
2593
|
+
const score = ruleSpecificity(pattern, tool);
|
|
2594
|
+
if (score >= 0 && (!best || score > best.specificity)) best = { pattern, specificity: score };
|
|
2595
|
+
}
|
|
2596
|
+
return best;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
function ruleSpecificity(pattern, tool) {
|
|
2600
|
+
if (pattern === tool) return pattern.length + 1;
|
|
2601
|
+
if (pattern.endsWith('/*')) {
|
|
2602
|
+
const prefix = pattern.slice(0, -1);
|
|
2603
|
+
return tool.startsWith(prefix) && !tool.slice(prefix.length).includes('/') ? prefix.length : -1;
|
|
2604
|
+
}
|
|
2605
|
+
if (pattern.endsWith('*')) {
|
|
2606
|
+
const prefix = pattern.slice(0, -1);
|
|
2607
|
+
return tool.startsWith(prefix) ? prefix.length : -1;
|
|
2608
|
+
}
|
|
2609
|
+
return -1;
|
|
1589
2610
|
}
|
|
1590
2611
|
|
|
1591
2612
|
function setDecision(draft, tool, decision) {
|
|
@@ -1654,12 +2675,104 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1654
2675
|
render();
|
|
1655
2676
|
}
|
|
1656
2677
|
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2678
|
+
function openCreateModal(kind) {
|
|
2679
|
+
state.createKind = kind;
|
|
2680
|
+
renderCreateModal();
|
|
2681
|
+
el('entityModal').classList.add('open');
|
|
2682
|
+
setTimeout(() => el('entityId')?.focus(), 0);
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
function closeEntityModal() {
|
|
2686
|
+
el('entityModal').classList.remove('open');
|
|
2687
|
+
state.createKind = '';
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function renderCreateModal() {
|
|
2691
|
+
const kind = state.createKind;
|
|
2692
|
+
const isAgent = kind === 'agent';
|
|
2693
|
+
const section = isAgent ? state.config.agents : state.config.profiles;
|
|
2694
|
+
const baseOptions = ['<option value="">Blank</option>'].concat(
|
|
2695
|
+
Object.keys(section).map((id) => '<option value="' + escapeHtml(id) + '">' + escapeHtml(id) + '</option>')
|
|
2696
|
+
).join('');
|
|
2697
|
+
el('entityModalTitle').textContent = isAgent ? 'New agent' : 'New profile';
|
|
2698
|
+
el('entityModalMeta').textContent = isAgent ? 'Creates a bearer token and permission policy' : 'Creates a reusable permission policy';
|
|
2699
|
+
el('entityModalBody').innerHTML =
|
|
2700
|
+
'<div class="form-grid">' +
|
|
2701
|
+
'<div class="field"><label for="entityId">Id</label><input id="entityId" autocomplete="off" placeholder="' + (isAgent ? 'claude-code' : 'readonly') + '"></div>' +
|
|
2702
|
+
'<div class="field"><label for="entityBase">Start from</label><select id="entityBase">' + baseOptions + '</select></div>' +
|
|
2703
|
+
(isAgent ? profilePicker() : '') +
|
|
2704
|
+
'</div>';
|
|
2705
|
+
el('entityModalFooter').innerHTML =
|
|
2706
|
+
'<button id="entityCreate" class="primary">Create</button>' +
|
|
2707
|
+
'<button id="entityCancel">Cancel</button>';
|
|
2708
|
+
el('entityCreate').addEventListener('click', () => submitCreateEntity().catch((error) => alert(error.message)));
|
|
2709
|
+
el('entityCancel').addEventListener('click', closeEntityModal);
|
|
2710
|
+
el('entityId').addEventListener('keydown', (event) => {
|
|
2711
|
+
if (event.key === 'Enter') submitCreateEntity().catch((error) => alert(error.message));
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function profilePicker() {
|
|
2716
|
+
const profiles = Object.keys(state.config.profiles);
|
|
2717
|
+
if (profiles.length === 0) {
|
|
2718
|
+
return '<div class="field"><label>Profiles</label><div class="subtle">No profiles yet.</div></div>';
|
|
2719
|
+
}
|
|
2720
|
+
return '<div class="field"><label>Profiles</label><div class="profile-select-list">' +
|
|
2721
|
+
profiles.map((id) => '<label class="check"><input type="checkbox" data-create-profile="' + escapeHtml(id) + '"> ' + escapeHtml(id) + '</label>').join('') +
|
|
2722
|
+
'</div></div>';
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
async function submitCreateEntity() {
|
|
2726
|
+
const kind = state.createKind;
|
|
2727
|
+
const id = el('entityId').value.trim();
|
|
2728
|
+
if (!id) throw new Error('Id is required');
|
|
2729
|
+
const profileIds = Array.from(document.querySelectorAll('[data-create-profile]:checked')).map((box) => box.dataset.createProfile);
|
|
2730
|
+
const body = {
|
|
2731
|
+
kind,
|
|
2732
|
+
id,
|
|
2733
|
+
baseId: el('entityBase').value,
|
|
2734
|
+
profileIds
|
|
2735
|
+
};
|
|
2736
|
+
const result = await api('/api/entities', { method: 'POST', body: JSON.stringify(body) });
|
|
2737
|
+
state.config = result;
|
|
1661
2738
|
state.drafts = {};
|
|
1662
|
-
setActive(kind, id
|
|
2739
|
+
setActive(kind, id);
|
|
2740
|
+
if (result.createdToken?.token) {
|
|
2741
|
+
showCreatedToken(result.createdToken.agentId, result.createdToken.token);
|
|
2742
|
+
} else {
|
|
2743
|
+
closeEntityModal();
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
function showCreatedToken(agentId, token) {
|
|
2748
|
+
el('entityModalTitle').textContent = 'Agent token';
|
|
2749
|
+
el('entityModalMeta').textContent = agentId;
|
|
2750
|
+
el('entityModalBody').innerHTML =
|
|
2751
|
+
'<div class="form-grid">' +
|
|
2752
|
+
'<div class="field"><label for="createdToken">Bearer token</label><input id="createdToken" class="token-value" readonly value="' + escapeHtml(token) + '"></div>' +
|
|
2753
|
+
'<div class="field"><label>Header</label><input class="token-value" readonly value="Authorization: Bearer ' + escapeHtml(token) + '"></div>' +
|
|
2754
|
+
'</div>';
|
|
2755
|
+
el('entityModalFooter').innerHTML =
|
|
2756
|
+
'<button id="copyCreatedToken" class="primary">Copy Token</button>' +
|
|
2757
|
+
'<button id="copyCreatedHeader">Copy Header</button>' +
|
|
2758
|
+
'<button id="entityDone">Done</button>';
|
|
2759
|
+
el('copyCreatedToken').addEventListener('click', () => copyText(token));
|
|
2760
|
+
el('copyCreatedHeader').addEventListener('click', () => copyText('Authorization: Bearer ' + token));
|
|
2761
|
+
el('entityDone').addEventListener('click', closeEntityModal);
|
|
2762
|
+
el('createdToken').select();
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
async function copyText(value) {
|
|
2766
|
+
if (navigator.clipboard?.writeText) {
|
|
2767
|
+
await navigator.clipboard.writeText(value);
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
const textarea = document.createElement('textarea');
|
|
2771
|
+
textarea.value = value;
|
|
2772
|
+
document.body.appendChild(textarea);
|
|
2773
|
+
textarea.select();
|
|
2774
|
+
document.execCommand('copy');
|
|
2775
|
+
textarea.remove();
|
|
1663
2776
|
}
|
|
1664
2777
|
|
|
1665
2778
|
async function addProvider() {
|
|
@@ -1742,17 +2855,106 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1742
2855
|
}[char]));
|
|
1743
2856
|
}
|
|
1744
2857
|
|
|
2858
|
+
function initApprovalSettings() {
|
|
2859
|
+
el('approvalNotifs').checked = localStorage.getItem('airlock:notifs') === 'true';
|
|
2860
|
+
el('approvalSound').checked = localStorage.getItem('airlock:sound') !== 'false';
|
|
2861
|
+
el('approvalNotifs').addEventListener('change', () => {
|
|
2862
|
+
localStorage.setItem('airlock:notifs', el('approvalNotifs').checked);
|
|
2863
|
+
if (el('approvalNotifs').checked && 'Notification' in window) Notification.requestPermission();
|
|
2864
|
+
});
|
|
2865
|
+
el('approvalSound').addEventListener('change', () => {
|
|
2866
|
+
localStorage.setItem('airlock:sound', el('approvalSound').checked);
|
|
2867
|
+
});
|
|
2868
|
+
if ('Notification' in window && el('approvalNotifs').checked) Notification.requestPermission();
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
function connectApprovalEvents() {
|
|
2872
|
+
if (!('EventSource' in window)) return;
|
|
2873
|
+
const events = new EventSource('/events');
|
|
2874
|
+
events.onmessage = (event) => {
|
|
2875
|
+
const message = JSON.parse(event.data);
|
|
2876
|
+
if (message.type === 'new') {
|
|
2877
|
+
state.livePending[message.request.code] = message.request;
|
|
2878
|
+
notifyApproval(message.request);
|
|
2879
|
+
refreshLogs().catch(() => {});
|
|
2880
|
+
render();
|
|
2881
|
+
}
|
|
2882
|
+
if (message.type === 'resolved') {
|
|
2883
|
+
delete state.livePending[message.code];
|
|
2884
|
+
refreshLogs().catch(() => {});
|
|
2885
|
+
refreshStatus().catch(() => {});
|
|
2886
|
+
render();
|
|
2887
|
+
}
|
|
2888
|
+
};
|
|
2889
|
+
events.onerror = () => {
|
|
2890
|
+
events.close();
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function notifyApproval(request) {
|
|
2895
|
+
if (!el('approvalNotifs').checked) return;
|
|
2896
|
+
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
|
2897
|
+
new Notification('Airlock: ' + request.tool, {
|
|
2898
|
+
body: 'agent: ' + request.agentId + '\\n' + request.code,
|
|
2899
|
+
tag: request.code,
|
|
2900
|
+
silent: !el('approvalSound').checked
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
async function checkVersion() {
|
|
2905
|
+
try {
|
|
2906
|
+
const current = await fetch('/version').then((response) => response.json());
|
|
2907
|
+
const latest = await fetch('/version/latest').then((response) => response.json());
|
|
2908
|
+
el('versionInfo').textContent = current.version
|
|
2909
|
+
? 'v' + current.version + (latest.latest && isNewer(latest.latest, current.version) ? ' · update v' + latest.latest : '')
|
|
2910
|
+
: '';
|
|
2911
|
+
} catch {
|
|
2912
|
+
el('versionInfo').textContent = '';
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
function isNewer(latest, current) {
|
|
2917
|
+
const next = String(latest).split('.').map(Number);
|
|
2918
|
+
const now = String(current).split('.').map(Number);
|
|
2919
|
+
for (let i = 0; i < 3; i++) {
|
|
2920
|
+
if ((next[i] || 0) > (now[i] || 0)) return true;
|
|
2921
|
+
if ((next[i] || 0) < (now[i] || 0)) return false;
|
|
2922
|
+
}
|
|
2923
|
+
return false;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
function firstPendingCode() {
|
|
2927
|
+
const agentId = state.activeKind === 'activity' ? state.auditFilters.agent || '' : '';
|
|
2928
|
+
const first = pendingApprovals(agentId)[0];
|
|
2929
|
+
return first ? first.code : '';
|
|
2930
|
+
}
|
|
2931
|
+
|
|
1745
2932
|
el('refreshTools').addEventListener('click', refreshTools);
|
|
1746
2933
|
el('refreshStatus').addEventListener('click', refreshStatus);
|
|
2934
|
+
el('refreshLogs').addEventListener('click', refreshLogs);
|
|
1747
2935
|
el('saveRules').addEventListener('click', () => saveCurrent().catch((error) => alert(error.message)));
|
|
1748
2936
|
el('manageProviders').addEventListener('click', () => setActive('providers'));
|
|
2937
|
+
el('viewActivity').addEventListener('click', () => setActive('activity'));
|
|
1749
2938
|
el('addProvider').addEventListener('click', () => addProvider().catch((error) => alert(error.message)));
|
|
1750
|
-
el('addAgent').addEventListener('click', () =>
|
|
1751
|
-
el('addProfile').addEventListener('click', () =>
|
|
2939
|
+
el('addAgent').addEventListener('click', () => openCreateModal('agent'));
|
|
2940
|
+
el('addProfile').addEventListener('click', () => openCreateModal('profile'));
|
|
1752
2941
|
el('deleteEntity').addEventListener('click', () => deleteCurrent().catch((error) => alert(error.message)));
|
|
1753
2942
|
el('search').addEventListener('input', (event) => { state.search = event.target.value; renderTools(); });
|
|
1754
2943
|
el('providerFilter').addEventListener('change', (event) => { state.provider = event.target.value; renderTools(); });
|
|
1755
2944
|
el('decisionFilter').addEventListener('change', (event) => { state.decision = event.target.value; renderTools(); });
|
|
2945
|
+
el('applyLogFilters').addEventListener('click', () => {
|
|
2946
|
+
state.auditFilters.agent = el('logAgent').value;
|
|
2947
|
+
state.activityAgentId = state.auditFilters.agent || state.activityAgentId;
|
|
2948
|
+
state.auditFilters.tool = el('logTool').value;
|
|
2949
|
+
state.auditFilters.since = el('logSince').value;
|
|
2950
|
+
state.auditFilters.limit = el('logLimit').value;
|
|
2951
|
+
refreshLogs().catch((error) => alert(error.message));
|
|
2952
|
+
});
|
|
2953
|
+
el('logAgent').addEventListener('change', () => {
|
|
2954
|
+
state.auditFilters.agent = el('logAgent').value;
|
|
2955
|
+
if (state.auditFilters.agent) state.activityAgentId = state.auditFilters.agent;
|
|
2956
|
+
refreshLogs().catch((error) => alert(error.message));
|
|
2957
|
+
});
|
|
1756
2958
|
el('bulkAllow').addEventListener('click', () => setVisible('allow'));
|
|
1757
2959
|
el('bulkAsk').addEventListener('click', () => setVisible('ask'));
|
|
1758
2960
|
el('bulkDeny').addEventListener('click', () => setVisible('deny'));
|
|
@@ -1760,8 +2962,34 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1760
2962
|
el('resetRules').addEventListener('click', resetVisibleToCurrentConfig);
|
|
1761
2963
|
el('resetAllRules').addEventListener('click', resetAllToCurrentConfig);
|
|
1762
2964
|
el('recommendedRules').addEventListener('click', setRecommended);
|
|
2965
|
+
el('approvalModalClose').addEventListener('click', closeApprovalModal);
|
|
2966
|
+
el('entityModalClose').addEventListener('click', closeEntityModal);
|
|
2967
|
+
el('approvalModal').addEventListener('click', (event) => {
|
|
2968
|
+
if (event.target === el('approvalModal')) closeApprovalModal();
|
|
2969
|
+
});
|
|
2970
|
+
el('entityModal').addEventListener('click', (event) => {
|
|
2971
|
+
if (event.target === el('entityModal')) closeEntityModal();
|
|
2972
|
+
});
|
|
2973
|
+
document.addEventListener('keydown', (event) => {
|
|
2974
|
+
if (event.key === 'Escape') {
|
|
2975
|
+
closeEntityModal();
|
|
2976
|
+
closeApprovalModal();
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
2980
|
+
const target = event.target;
|
|
2981
|
+
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
|
|
2982
|
+
const key = event.key.toLowerCase();
|
|
2983
|
+
if (key !== 'a' && key !== 'd') return;
|
|
2984
|
+
const code = state.currentApprovalCode || firstPendingCode();
|
|
2985
|
+
if (!code) return;
|
|
2986
|
+
actApproval(key === 'a' ? 'approve' : 'deny', code).catch((error) => alert(error.message));
|
|
2987
|
+
});
|
|
1763
2988
|
|
|
1764
|
-
|
|
2989
|
+
initApprovalSettings();
|
|
2990
|
+
connectApprovalEvents();
|
|
2991
|
+
checkVersion().catch(() => {});
|
|
2992
|
+
loadState().then(() => Promise.all([refreshStatus(), refreshLogs(), refreshTools()])).catch((error) => {
|
|
1765
2993
|
document.body.innerHTML = '<div class="empty">' + escapeHtml(error.message) + '</div>';
|
|
1766
2994
|
});
|
|
1767
2995
|
</script>
|