airlock-bot 0.2.34 → 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/allowlist/engine.d.ts.map +1 -1
- package/dist/allowlist/engine.js +10 -3
- package/dist/allowlist/engine.js.map +1 -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 +40 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/mutator.d.ts +17 -0
- package/dist/config/mutator.d.ts.map +1 -0
- package/dist/config/mutator.js +66 -0
- package/dist/config/mutator.js.map +1 -0
- package/dist/config/schema.d.ts +168 -14
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +12 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/configure-web/cli.d.ts +57 -3
- package/dist/configure-web/cli.d.ts.map +1 -1
- package/dist/configure-web/cli.js +1225 -37
- package/dist/configure-web/cli.js.map +1 -1
- package/dist/gateway.d.ts +8 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +83 -24
- 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.d.ts +3 -1
- package/dist/hitl/provider-factory.d.ts.map +1 -1
- package/dist/hitl/provider-factory.js +5 -5
- package/dist/hitl/provider-factory.js.map +1 -1
- package/dist/hitl/providers/dashboard.d.ts +5 -5
- package/dist/hitl/providers/dashboard.d.ts.map +1 -1
- package/dist/hitl/providers/dashboard.js +29 -416
- 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 +40 -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 +54 -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
|
|
|
@@ -19,7 +24,69 @@ Options:
|
|
|
19
24
|
--host <host> Bind host (default: 127.0.0.1)
|
|
20
25
|
-h, --help Show this help
|
|
21
26
|
`;
|
|
22
|
-
|
|
27
|
+
const RUN_HELP = `
|
|
28
|
+
airlock run - browser command center for provider health and permissions
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
airlock run [options]
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-c, --config <path> Airlock config file (default: ./airlock.yaml)
|
|
35
|
+
-p, --port <port> Web UI port (default: 4177)
|
|
36
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
37
|
+
-h, --help Show this help
|
|
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
|
+
`;
|
|
53
|
+
export async function runCommandCenter(argv) {
|
|
54
|
+
await runConfigureWeb(argv, 'run');
|
|
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
|
+
}
|
|
89
|
+
export async function runConfigureWeb(argv, entrypoint = 'configure-web') {
|
|
23
90
|
const { values } = parseArgs({
|
|
24
91
|
args: argv,
|
|
25
92
|
options: {
|
|
@@ -31,7 +98,7 @@ export async function runConfigureWeb(argv) {
|
|
|
31
98
|
allowPositionals: false,
|
|
32
99
|
});
|
|
33
100
|
if (values.help) {
|
|
34
|
-
console.log(HELP);
|
|
101
|
+
console.log(entrypoint === 'run' ? RUN_HELP : HELP);
|
|
35
102
|
process.exit(0);
|
|
36
103
|
}
|
|
37
104
|
const configPath = values.config ?? './airlock.yaml';
|
|
@@ -42,17 +109,49 @@ export async function runConfigureWeb(argv) {
|
|
|
42
109
|
}
|
|
43
110
|
const app = createConfigureWebApp(configPath);
|
|
44
111
|
await app.listen({ host, port });
|
|
45
|
-
console.log(`Airlock
|
|
112
|
+
console.log(`Airlock command center running at http://${host}:${port}`);
|
|
46
113
|
console.log(`Editing ${configPath}`);
|
|
47
114
|
}
|
|
48
|
-
export function createConfigureWebApp(configPath) {
|
|
115
|
+
export function createConfigureWebApp(configPath, options = {}) {
|
|
49
116
|
const app = Fastify({ logger: false });
|
|
50
117
|
app.get('/', async (_request, reply) => {
|
|
51
118
|
reply.type('text/html; charset=utf-8').send(INDEX_HTML);
|
|
52
119
|
});
|
|
53
120
|
app.get('/api/state', () => readState(configPath));
|
|
121
|
+
app.get('/api/status', async (_request, reply) => {
|
|
122
|
+
try {
|
|
123
|
+
if (options.remoteGateway) {
|
|
124
|
+
return await readRemoteCommandCenterStatus(configPath, options.remoteGateway);
|
|
125
|
+
}
|
|
126
|
+
return await readCommandCenterStatus(configPath);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
reply.code(500);
|
|
130
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
131
|
+
}
|
|
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
|
+
});
|
|
54
150
|
app.get('/api/tools', async (_request, reply) => {
|
|
55
151
|
try {
|
|
152
|
+
if (options.remoteGateway) {
|
|
153
|
+
return await remoteGatewayJson(options.remoteGateway, '/admin/tools');
|
|
154
|
+
}
|
|
56
155
|
return await discoverTools(configPath);
|
|
57
156
|
}
|
|
58
157
|
catch (err) {
|
|
@@ -60,6 +159,12 @@ export function createConfigureWebApp(configPath) {
|
|
|
60
159
|
return { error: err instanceof Error ? err.message : String(err), tools: [] };
|
|
61
160
|
}
|
|
62
161
|
});
|
|
162
|
+
if (options.remoteGateway) {
|
|
163
|
+
registerRemoteGatewayRoutes(app, configPath, options.remoteGateway);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
options.approvals?.registerRoutes(app, configPath);
|
|
167
|
+
}
|
|
63
168
|
app.post('/api/rules', async (request, reply) => {
|
|
64
169
|
try {
|
|
65
170
|
const body = request.body;
|
|
@@ -124,6 +229,327 @@ export function readState(configPath) {
|
|
|
124
229
|
diagnostics: config ? validateConfig(config) : validation.diagnostics,
|
|
125
230
|
};
|
|
126
231
|
}
|
|
232
|
+
export async function readCommandCenterStatus(configPath) {
|
|
233
|
+
const raw = readConfigObject(configPath);
|
|
234
|
+
const editableProviders = toEditableProviders(asRecord(raw.providers));
|
|
235
|
+
const statuses = new Map();
|
|
236
|
+
const enabledProviders = {};
|
|
237
|
+
for (const [id, provider] of Object.entries(editableProviders)) {
|
|
238
|
+
statuses.set(id, {
|
|
239
|
+
id,
|
|
240
|
+
type: provider.type,
|
|
241
|
+
enabled: provider.enabled,
|
|
242
|
+
status: provider.enabled ? 'down' : 'disabled',
|
|
243
|
+
toolCount: 0,
|
|
244
|
+
toolFingerprint: '',
|
|
245
|
+
error: provider.enabled ? 'Not checked yet' : undefined,
|
|
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;
|
|
264
|
+
}
|
|
265
|
+
const parsed = GatewayConfig.parse({ ...raw, providers: enabledProviders });
|
|
266
|
+
const mcpConfigs = getMcpConfigs(parsed.providers);
|
|
267
|
+
const pool = new ClientPool(mcpConfigs, {
|
|
268
|
+
stdioStderr: 'ignore',
|
|
269
|
+
healthCheck: false,
|
|
270
|
+
retryFailedConnections: false,
|
|
271
|
+
});
|
|
272
|
+
try {
|
|
273
|
+
await Promise.race([
|
|
274
|
+
pool.initialize(),
|
|
275
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
276
|
+
]);
|
|
277
|
+
const adapters = buildAdapters(parsed, pool);
|
|
278
|
+
await Promise.all(adapters.map(async (adapter) => {
|
|
279
|
+
const id = adapter.id.startsWith('builtin:')
|
|
280
|
+
? adapter.id.slice('builtin:'.length)
|
|
281
|
+
: adapter.id.startsWith('mcp:')
|
|
282
|
+
? adapter.id.slice('mcp:'.length)
|
|
283
|
+
: adapter.id;
|
|
284
|
+
const status = statuses.get(id);
|
|
285
|
+
if (!status)
|
|
286
|
+
return;
|
|
287
|
+
try {
|
|
288
|
+
const tools = await withTimeout(adapter.listTools(), 3_000, `${id}: timed out listing tools`);
|
|
289
|
+
status.toolCount = tools.length;
|
|
290
|
+
status.toolFingerprint = toolFingerprint(tools.map((tool) => tool.name));
|
|
291
|
+
status.status = 'ok';
|
|
292
|
+
status.error = undefined;
|
|
293
|
+
const serverInfo = pool.getServerInfo(id);
|
|
294
|
+
if (serverInfo)
|
|
295
|
+
status.serverInfo = serverInfo;
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
status.status = 'down';
|
|
299
|
+
status.error = err instanceof Error ? err.message : String(err);
|
|
300
|
+
}
|
|
301
|
+
}));
|
|
302
|
+
for (const [id, mcpConfig] of Object.entries(mcpConfigs)) {
|
|
303
|
+
const status = statuses.get(id);
|
|
304
|
+
if (!status)
|
|
305
|
+
continue;
|
|
306
|
+
status.type = mcpConfig.type;
|
|
307
|
+
status.serverInfo = pool.getServerInfo(id);
|
|
308
|
+
if (!pool.isReady(id) && !status.error) {
|
|
309
|
+
status.status = 'down';
|
|
310
|
+
status.error = 'MCP provider is not connected';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
await pool.stop().catch(() => { });
|
|
316
|
+
}
|
|
317
|
+
const providers = Array.from(statuses.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
318
|
+
const auditLogs = readAuditLogs(configPath, { limit: 1 });
|
|
319
|
+
return {
|
|
320
|
+
generatedAt: new Date().toISOString(),
|
|
321
|
+
auditDbPath: parsed.audit.db_path,
|
|
322
|
+
providers,
|
|
323
|
+
summary: {
|
|
324
|
+
ok: providers.filter((provider) => provider.status === 'ok').length,
|
|
325
|
+
down: providers.filter((provider) => provider.status === 'down').length,
|
|
326
|
+
disabled: providers.filter((provider) => provider.status === 'disabled').length,
|
|
327
|
+
tools: providers.reduce((sum, provider) => sum + provider.toolCount, 0),
|
|
328
|
+
pendingApprovals: auditLogs.pending.length,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
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
|
+
}
|
|
127
553
|
export function saveRules(configPath, body) {
|
|
128
554
|
const kind = parseKind(body.kind);
|
|
129
555
|
const id = parseId(body.id);
|
|
@@ -147,6 +573,9 @@ export function saveRules(configPath, body) {
|
|
|
147
573
|
writeValidatedConfig(configPath, doc);
|
|
148
574
|
return readState(configPath);
|
|
149
575
|
}
|
|
576
|
+
export function toolFingerprint(toolNames) {
|
|
577
|
+
return toolNames.slice().sort().join('\n');
|
|
578
|
+
}
|
|
150
579
|
export function createEntity(configPath, body) {
|
|
151
580
|
const kind = parseKind(body.kind);
|
|
152
581
|
const id = parseId(body.id);
|
|
@@ -323,6 +752,39 @@ function withTimeout(promise, timeoutMs, message) {
|
|
|
323
752
|
clearTimeout(timer);
|
|
324
753
|
});
|
|
325
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
|
+
}
|
|
326
788
|
function readConfigObject(configPath) {
|
|
327
789
|
if (!existsSync(configPath))
|
|
328
790
|
throw new Error(`Config file not found: ${configPath}`);
|
|
@@ -486,7 +948,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
486
948
|
<head>
|
|
487
949
|
<meta charset="utf-8">
|
|
488
950
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
489
|
-
<title>Airlock
|
|
951
|
+
<title>Airlock Command Center</title>
|
|
490
952
|
<style>
|
|
491
953
|
:root {
|
|
492
954
|
color-scheme: light;
|
|
@@ -606,9 +1068,40 @@ const INDEX_HTML = `<!doctype html>
|
|
|
606
1068
|
border-color: #b9c8ff;
|
|
607
1069
|
color: #173c9c;
|
|
608
1070
|
}
|
|
1071
|
+
.surface-group {
|
|
1072
|
+
margin-bottom: 14px;
|
|
1073
|
+
}
|
|
1074
|
+
.status-grid {
|
|
1075
|
+
display: grid;
|
|
1076
|
+
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
|
1077
|
+
gap: 10px;
|
|
1078
|
+
}
|
|
1079
|
+
.status-tile {
|
|
1080
|
+
min-width: 0;
|
|
1081
|
+
padding: 12px;
|
|
1082
|
+
border: 1px solid var(--line);
|
|
1083
|
+
border-radius: 8px;
|
|
1084
|
+
background: #fff;
|
|
1085
|
+
box-shadow: var(--shadow);
|
|
1086
|
+
}
|
|
1087
|
+
.status-value {
|
|
1088
|
+
font-size: 24px;
|
|
1089
|
+
font-weight: 750;
|
|
1090
|
+
line-height: 1;
|
|
1091
|
+
}
|
|
1092
|
+
.status-value.runtime-ok { color: var(--green); }
|
|
1093
|
+
.status-value.runtime-down { color: var(--red); }
|
|
1094
|
+
.status-value.runtime-disabled { color: var(--muted); }
|
|
1095
|
+
.status-label {
|
|
1096
|
+
margin-top: 6px;
|
|
1097
|
+
color: var(--muted);
|
|
1098
|
+
font-size: 12px;
|
|
1099
|
+
text-transform: uppercase;
|
|
1100
|
+
letter-spacing: 0.08em;
|
|
1101
|
+
}
|
|
609
1102
|
.provider-item {
|
|
610
1103
|
display: grid;
|
|
611
|
-
grid-template-columns: minmax(
|
|
1104
|
+
grid-template-columns: minmax(170px, 1fr) 94px 82px 82px 64px 64px 64px;
|
|
612
1105
|
align-items: center;
|
|
613
1106
|
gap: 10px;
|
|
614
1107
|
padding: 12px;
|
|
@@ -649,6 +1142,151 @@ const INDEX_HTML = `<!doctype html>
|
|
|
649
1142
|
color: var(--red);
|
|
650
1143
|
font-weight: 700;
|
|
651
1144
|
}
|
|
1145
|
+
.pill.runtime-ok {
|
|
1146
|
+
border-color: #b8dfca;
|
|
1147
|
+
background: #e8f6ef;
|
|
1148
|
+
color: var(--green);
|
|
1149
|
+
font-weight: 700;
|
|
1150
|
+
}
|
|
1151
|
+
.pill.runtime-down {
|
|
1152
|
+
border-color: #f2c0bb;
|
|
1153
|
+
background: #fff0ee;
|
|
1154
|
+
color: var(--red);
|
|
1155
|
+
font-weight: 700;
|
|
1156
|
+
}
|
|
1157
|
+
.pill.runtime-disabled {
|
|
1158
|
+
border-color: #d7dee8;
|
|
1159
|
+
background: #f2f5f8;
|
|
1160
|
+
color: var(--muted);
|
|
1161
|
+
font-weight: 700;
|
|
1162
|
+
}
|
|
1163
|
+
.pill.runtime-stale {
|
|
1164
|
+
border-color: #f1d99a;
|
|
1165
|
+
background: #fff4d6;
|
|
1166
|
+
color: var(--amber);
|
|
1167
|
+
font-weight: 700;
|
|
1168
|
+
}
|
|
1169
|
+
.stale-notice {
|
|
1170
|
+
padding: 10px 12px;
|
|
1171
|
+
border: 1px solid #f1d99a;
|
|
1172
|
+
border-radius: 8px;
|
|
1173
|
+
background: #fff8e1;
|
|
1174
|
+
color: #6a4400;
|
|
1175
|
+
font-size: 13px;
|
|
1176
|
+
font-weight: 650;
|
|
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
|
+
}
|
|
652
1290
|
.provider-list {
|
|
653
1291
|
background: var(--panel);
|
|
654
1292
|
border: 1px solid var(--line);
|
|
@@ -679,6 +1317,9 @@ const INDEX_HTML = `<!doctype html>
|
|
|
679
1317
|
border-radius: 8px;
|
|
680
1318
|
box-shadow: var(--shadow);
|
|
681
1319
|
}
|
|
1320
|
+
.toolbar[hidden], .table[hidden] {
|
|
1321
|
+
display: none;
|
|
1322
|
+
}
|
|
682
1323
|
.toolbar input, .toolbar select, .details input {
|
|
683
1324
|
min-height: 34px;
|
|
684
1325
|
border: 1px solid var(--line);
|
|
@@ -888,9 +1529,67 @@ const INDEX_HTML = `<!doctype html>
|
|
|
888
1529
|
text-align: center;
|
|
889
1530
|
color: var(--muted);
|
|
890
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
|
+
}
|
|
891
1587
|
@media (max-width: 1080px) {
|
|
892
1588
|
.layout { grid-template-columns: 220px minmax(0, 1fr); }
|
|
893
1589
|
.details { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
|
|
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; }
|
|
894
1593
|
.tool-row { grid-template-columns: minmax(160px, 240px) minmax(0, 1fr); }
|
|
895
1594
|
.rule-actions { grid-column: 1 / -1; justify-content: flex-start; }
|
|
896
1595
|
}
|
|
@@ -900,6 +1599,9 @@ const INDEX_HTML = `<!doctype html>
|
|
|
900
1599
|
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
901
1600
|
.tool-row { grid-template-columns: 1fr; }
|
|
902
1601
|
.hero { flex-direction: column; }
|
|
1602
|
+
.status-grid { grid-template-columns: 1fr; }
|
|
1603
|
+
.log-toolbar { grid-template-columns: 1fr; }
|
|
1604
|
+
.provider-item { grid-template-columns: 1fr 1fr; }
|
|
903
1605
|
}
|
|
904
1606
|
</style>
|
|
905
1607
|
</head>
|
|
@@ -907,19 +1609,25 @@ const INDEX_HTML = `<!doctype html>
|
|
|
907
1609
|
<div class="app">
|
|
908
1610
|
<header>
|
|
909
1611
|
<div>
|
|
910
|
-
<h1>Airlock
|
|
1612
|
+
<h1>Airlock Command Center</h1>
|
|
911
1613
|
<div id="configPath" class="subtle"></div>
|
|
912
1614
|
</div>
|
|
913
1615
|
<div class="top-actions">
|
|
914
|
-
<button id="
|
|
1616
|
+
<button id="refreshStatus">Refresh Status</button>
|
|
1617
|
+
<button id="refreshLogs">Refresh Activity</button>
|
|
1618
|
+
<button id="refreshTools">Refresh Tools</button>
|
|
915
1619
|
<button id="saveRules" class="primary">Save</button>
|
|
916
1620
|
</div>
|
|
917
1621
|
</header>
|
|
918
1622
|
<div class="layout">
|
|
919
1623
|
<aside>
|
|
920
|
-
<
|
|
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>
|
|
921
1629
|
<div class="row">
|
|
922
|
-
<div class="section-title"
|
|
1630
|
+
<div class="section-title">Agents</div>
|
|
923
1631
|
<button id="addAgent" title="Add agent">+</button>
|
|
924
1632
|
</div>
|
|
925
1633
|
<div id="agents"></div>
|
|
@@ -941,7 +1649,31 @@ const INDEX_HTML = `<!doctype html>
|
|
|
941
1649
|
<button id="deleteEntity">Delete</button>
|
|
942
1650
|
</div>
|
|
943
1651
|
</div>
|
|
944
|
-
<div class="
|
|
1652
|
+
<div id="statusGrid" class="status-grid"></div>
|
|
1653
|
+
<div id="staleNotice" class="stale-notice" hidden></div>
|
|
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">
|
|
945
1677
|
<input id="search" type="search" placeholder="Search tools, endpoints, providers">
|
|
946
1678
|
<select id="providerFilter"></select>
|
|
947
1679
|
<select id="decisionFilter">
|
|
@@ -980,10 +1712,30 @@ const INDEX_HTML = `<!doctype html>
|
|
|
980
1712
|
</section>
|
|
981
1713
|
</div>
|
|
982
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>
|
|
983
1728
|
<script>
|
|
984
1729
|
const state = {
|
|
985
1730
|
config: null,
|
|
1731
|
+
status: null,
|
|
1732
|
+
audit: { dbPath: '', entries: [], pending: [] },
|
|
1733
|
+
livePending: {},
|
|
1734
|
+
currentApprovalCode: '',
|
|
1735
|
+
activityAgentId: '',
|
|
1736
|
+
auditFilters: { agent: '', tool: '', since: '', limit: '50' },
|
|
986
1737
|
tools: [],
|
|
1738
|
+
toolsLoaded: false,
|
|
987
1739
|
errors: [],
|
|
988
1740
|
activeKind: 'agent',
|
|
989
1741
|
activeId: '',
|
|
@@ -1013,7 +1765,11 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1013
1765
|
if (!state.activeId) {
|
|
1014
1766
|
const firstAgent = Object.keys(state.config.agents)[0];
|
|
1015
1767
|
const firstProfile = Object.keys(state.config.profiles)[0];
|
|
1016
|
-
if (firstAgent)
|
|
1768
|
+
if (firstAgent) {
|
|
1769
|
+
state.activityAgentId = firstAgent;
|
|
1770
|
+
state.auditFilters.agent = firstAgent;
|
|
1771
|
+
setActive('agent', firstAgent);
|
|
1772
|
+
}
|
|
1017
1773
|
else if (firstProfile) setActive('profile', firstProfile);
|
|
1018
1774
|
}
|
|
1019
1775
|
hydrateDrafts();
|
|
@@ -1026,13 +1782,46 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1026
1782
|
try {
|
|
1027
1783
|
const result = await api('/api/tools');
|
|
1028
1784
|
state.tools = result.tools || [];
|
|
1785
|
+
state.toolsLoaded = true;
|
|
1029
1786
|
state.errors = result.errors || [];
|
|
1030
1787
|
render();
|
|
1031
1788
|
} catch (error) {
|
|
1032
1789
|
alert(error.message);
|
|
1033
1790
|
} finally {
|
|
1034
1791
|
el('refreshTools').disabled = false;
|
|
1035
|
-
el('refreshTools').textContent = 'Refresh
|
|
1792
|
+
el('refreshTools').textContent = 'Refresh Tools';
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
async function refreshStatus() {
|
|
1797
|
+
el('refreshStatus').disabled = true;
|
|
1798
|
+
el('refreshStatus').textContent = 'Checking...';
|
|
1799
|
+
try {
|
|
1800
|
+
state.status = await api('/api/status');
|
|
1801
|
+
render();
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
alert(error.message);
|
|
1804
|
+
} finally {
|
|
1805
|
+
el('refreshStatus').disabled = false;
|
|
1806
|
+
el('refreshStatus').textContent = 'Refresh Status';
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
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';
|
|
1036
1825
|
}
|
|
1037
1826
|
}
|
|
1038
1827
|
|
|
@@ -1052,7 +1841,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1052
1841
|
}
|
|
1053
1842
|
|
|
1054
1843
|
function currentDraft() {
|
|
1055
|
-
if (state.activeKind === 'providers') return null;
|
|
1844
|
+
if (state.activeKind === 'providers' || state.activeKind === 'activity') return null;
|
|
1056
1845
|
if (!state.activeId) return null;
|
|
1057
1846
|
return state.drafts[keyFor(state.activeKind, state.activeId)];
|
|
1058
1847
|
}
|
|
@@ -1060,8 +1849,18 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1060
1849
|
function setActive(kind, id) {
|
|
1061
1850
|
state.activeKind = kind;
|
|
1062
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
|
+
}
|
|
1063
1861
|
hydrateDrafts();
|
|
1064
1862
|
render();
|
|
1863
|
+
if (kind === 'activity') refreshLogs().catch((error) => alert(error.message));
|
|
1065
1864
|
}
|
|
1066
1865
|
|
|
1067
1866
|
function render() {
|
|
@@ -1069,12 +1868,238 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1069
1868
|
renderProviderNav();
|
|
1070
1869
|
renderEntities();
|
|
1071
1870
|
renderProviderFilter();
|
|
1871
|
+
renderStatusGrid();
|
|
1872
|
+
renderStaleNotice();
|
|
1873
|
+
renderActivity();
|
|
1072
1874
|
renderDetails();
|
|
1073
1875
|
renderTools();
|
|
1074
1876
|
}
|
|
1075
1877
|
|
|
1878
|
+
function renderStatusGrid() {
|
|
1879
|
+
const pending = pendingApprovals();
|
|
1880
|
+
const summary = state.status?.summary || { ok: 0, down: 0, disabled: 0, tools: state.tools.length, pendingApprovals: pending.length };
|
|
1881
|
+
el('statusGrid').innerHTML =
|
|
1882
|
+
statusTile(summary.ok, 'Providers OK', 'runtime-ok') +
|
|
1883
|
+
statusTile(summary.down, 'Providers Down', summary.down > 0 ? 'runtime-down' : '') +
|
|
1884
|
+
statusTile(summary.disabled, 'Disabled', 'runtime-disabled') +
|
|
1885
|
+
statusTile(summary.tools || state.tools.length, 'Tools Visible', '') +
|
|
1886
|
+
statusTile(pending.length, 'Pending Approvals', pending.length ? 'runtime-down' : '');
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function statusTile(value, label, className) {
|
|
1890
|
+
return '<div class="status-tile">' +
|
|
1891
|
+
'<div class="status-value ' + escapeHtml(className) + '">' + escapeHtml(value) + '</div>' +
|
|
1892
|
+
'<div class="status-label">' + escapeHtml(label) + '</div>' +
|
|
1893
|
+
'</div>';
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function renderStaleNotice() {
|
|
1897
|
+
const stale = staleProviders();
|
|
1898
|
+
el('staleNotice').hidden = stale.length === 0;
|
|
1899
|
+
el('staleNotice').textContent = stale.length
|
|
1900
|
+
? 'Tool list stale: ' + stale.join(', ')
|
|
1901
|
+
: '';
|
|
1902
|
+
}
|
|
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
|
+
|
|
1076
2100
|
function renderProviderNav() {
|
|
1077
2101
|
el('manageProviders').classList.toggle('active', state.activeKind === 'providers');
|
|
2102
|
+
el('viewActivity').classList.toggle('active', state.activeKind === 'activity');
|
|
1078
2103
|
}
|
|
1079
2104
|
|
|
1080
2105
|
function renderProvidersManager() {
|
|
@@ -1094,18 +2119,56 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1094
2119
|
}
|
|
1095
2120
|
|
|
1096
2121
|
function providerRow(id, provider) {
|
|
2122
|
+
const runtime = providerStatus(id);
|
|
2123
|
+
const stale = isProviderToolListStale(id);
|
|
1097
2124
|
const disabled = provider.enabled ? '' : ' disabled';
|
|
1098
2125
|
const toggleLabel = provider.enabled ? 'Disable' : 'Enable';
|
|
1099
2126
|
const statusClass = provider.enabled ? 'provider-status-enabled' : 'provider-status-disabled';
|
|
2127
|
+
const runtimeClass = stale
|
|
2128
|
+
? 'runtime-stale'
|
|
2129
|
+
: runtime
|
|
2130
|
+
? 'runtime-' + runtime.status
|
|
2131
|
+
: 'runtime-disabled';
|
|
2132
|
+
const runtimeText = stale ? 'stale' : runtime ? runtime.status : provider.enabled ? 'unknown' : 'disabled';
|
|
2133
|
+
const tools = runtime ? String(runtime.toolCount) : '0';
|
|
2134
|
+
const server = runtime?.serverInfo ? runtime.serverInfo.name + ' ' + runtime.serverInfo.version : '';
|
|
2135
|
+
const title = stale ? 'Live tool names differ from the loaded tool table' : runtime?.error || server || runtimeText;
|
|
1100
2136
|
return '<div class="provider-item' + disabled + '">' +
|
|
1101
2137
|
'<div class="provider-name">' + escapeHtml(id) + '<span>' + escapeHtml(provider.type) + '</span></div>' +
|
|
1102
2138
|
'<span class="pill ' + statusClass + '">' + (provider.enabled ? 'enabled' : 'disabled') + '</span>' +
|
|
2139
|
+
'<span class="pill ' + runtimeClass + '" title="' + escapeHtml(title) + '">' + escapeHtml(runtimeText) + '</span>' +
|
|
2140
|
+
'<span class="pill">' + escapeHtml(tools) + ' tools</span>' +
|
|
1103
2141
|
'<button data-provider-toggle data-provider="' + escapeHtml(id) + '">' + toggleLabel + '</button>' +
|
|
1104
2142
|
'<button data-provider-edit data-provider="' + escapeHtml(id) + '">Edit</button>' +
|
|
1105
2143
|
'<button data-provider-delete data-provider="' + escapeHtml(id) + '">Del</button>' +
|
|
1106
2144
|
'</div>';
|
|
1107
2145
|
}
|
|
1108
2146
|
|
|
2147
|
+
function providerStatus(id) {
|
|
2148
|
+
return (state.status?.providers || []).find((provider) => provider.id === id);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function staleProviders() {
|
|
2152
|
+
if (!state.toolsLoaded || !state.status) return [];
|
|
2153
|
+
return state.status.providers
|
|
2154
|
+
.filter((provider) => isProviderToolListStale(provider.id))
|
|
2155
|
+
.map((provider) => provider.id);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function isProviderToolListStale(providerId) {
|
|
2159
|
+
const status = providerStatus(providerId);
|
|
2160
|
+
if (!state.toolsLoaded || !status || status.status !== 'ok') return false;
|
|
2161
|
+
return status.toolFingerprint !== loadedToolFingerprint(providerId);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
function loadedToolFingerprint(providerId) {
|
|
2165
|
+
return state.tools
|
|
2166
|
+
.filter((tool) => tool.provider === providerId)
|
|
2167
|
+
.map((tool) => tool.name)
|
|
2168
|
+
.sort()
|
|
2169
|
+
.join('\\n');
|
|
2170
|
+
}
|
|
2171
|
+
|
|
1109
2172
|
function renderEntities() {
|
|
1110
2173
|
el('agents').innerHTML = Object.keys(state.config.agents).map((id) => entityButton('agent', id)).join('');
|
|
1111
2174
|
el('profiles').innerHTML = Object.keys(state.config.profiles).map((id) => entityButton('profile', id)).join('');
|
|
@@ -1127,26 +2190,44 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1127
2190
|
|
|
1128
2191
|
function renderDetails() {
|
|
1129
2192
|
const draft = currentDraft();
|
|
1130
|
-
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';
|
|
1131
2199
|
el('activeMeta').textContent =
|
|
1132
2200
|
state.activeKind === 'providers'
|
|
1133
2201
|
? 'Top-level tool sources'
|
|
1134
|
-
: state.activeKind === '
|
|
1135
|
-
? '
|
|
1136
|
-
:
|
|
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';
|
|
1137
2207
|
el('deleteEntity').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1138
2208
|
el('addProvider').style.display = state.activeKind === 'providers' ? 'inline-flex' : 'none';
|
|
1139
|
-
el('deleteEntity').
|
|
2209
|
+
el('deleteEntity').style.display = state.activeKind === 'activity' ? 'none' : el('deleteEntity').style.display;
|
|
2210
|
+
el('deleteEntity').disabled = !state.activeId || state.activeKind === 'activity';
|
|
1140
2211
|
el('profilePanel').style.display = state.activeKind === 'agent' ? 'block' : 'none';
|
|
1141
2212
|
|
|
1142
2213
|
if (!draft) {
|
|
1143
2214
|
if (state.activeKind === 'providers') {
|
|
1144
2215
|
const providers = Object.values(state.config.providers);
|
|
1145
2216
|
const enabled = providers.filter((provider) => provider.enabled).length;
|
|
2217
|
+
const status = state.status?.summary || { ok: 0, down: 0, disabled: providers.length - enabled, tools: state.tools.length };
|
|
2218
|
+
const stale = staleProviders().length;
|
|
1146
2219
|
el('summary').innerHTML =
|
|
1147
2220
|
'<span class="pill">enabled ' + enabled + '</span>' +
|
|
1148
|
-
'<span class="pill">
|
|
1149
|
-
'<span class="pill">
|
|
2221
|
+
'<span class="pill runtime-ok">ok ' + status.ok + '</span>' +
|
|
2222
|
+
'<span class="pill runtime-down">down ' + status.down + '</span>' +
|
|
2223
|
+
'<span class="pill runtime-stale">stale ' + stale + '</span>' +
|
|
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>';
|
|
1150
2231
|
} else {
|
|
1151
2232
|
el('summary').innerHTML = '<div class="empty">Nothing selected.</div>';
|
|
1152
2233
|
}
|
|
@@ -1183,23 +2264,19 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1183
2264
|
function renderDiagnostics() {
|
|
1184
2265
|
const diagnostics = [...(state.config.diagnostics || [])];
|
|
1185
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 });
|
|
1186
2268
|
el('diagnostics').innerHTML = diagnostics.length
|
|
1187
2269
|
? diagnostics.map((d) => '<div class="diagnostic ' + escapeHtml(d.level) + '">' + escapeHtml((d.agent ? '[' + d.agent + '] ' : '') + d.message) + '</div>').join('')
|
|
1188
2270
|
: '<div class="subtle">No diagnostics.</div>';
|
|
1189
2271
|
}
|
|
1190
2272
|
|
|
1191
2273
|
function renderTools() {
|
|
1192
|
-
|
|
1193
|
-
el('
|
|
1194
|
-
el('
|
|
1195
|
-
|
|
1196
|
-
el('bulkAsk').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1197
|
-
el('bulkDeny').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1198
|
-
el('bulkClear').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1199
|
-
el('resetRules').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1200
|
-
el('recommendedRules').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1201
|
-
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;
|
|
1202
2278
|
if (state.activeKind === 'providers') {
|
|
2279
|
+
el('tools').hidden = false;
|
|
1203
2280
|
renderProvidersManager();
|
|
1204
2281
|
return;
|
|
1205
2282
|
}
|
|
@@ -1412,7 +2489,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1412
2489
|
const body = { id: id.trim(), type: type.trim(), enabled: true };
|
|
1413
2490
|
await fillProviderFields(body);
|
|
1414
2491
|
state.config = await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
|
|
1415
|
-
await refreshTools();
|
|
2492
|
+
await Promise.all([refreshStatus(), refreshTools()]);
|
|
1416
2493
|
render();
|
|
1417
2494
|
}
|
|
1418
2495
|
|
|
@@ -1422,7 +2499,7 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1422
2499
|
const body = { id, type: provider.type, enabled: provider.enabled };
|
|
1423
2500
|
await fillProviderFields(body, provider);
|
|
1424
2501
|
state.config = await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
|
|
1425
|
-
await refreshTools();
|
|
2502
|
+
await Promise.all([refreshStatus(), refreshTools()]);
|
|
1426
2503
|
render();
|
|
1427
2504
|
}
|
|
1428
2505
|
|
|
@@ -1431,14 +2508,14 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1431
2508
|
if (!provider) return;
|
|
1432
2509
|
const body = { ...provider, id, enabled: !provider.enabled };
|
|
1433
2510
|
state.config = await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
|
|
1434
|
-
await refreshTools();
|
|
2511
|
+
await Promise.all([refreshStatus(), refreshTools()]);
|
|
1435
2512
|
render();
|
|
1436
2513
|
}
|
|
1437
2514
|
|
|
1438
2515
|
async function deleteProvider(id) {
|
|
1439
2516
|
if (!confirm('Delete provider "' + id + '"?')) return;
|
|
1440
2517
|
state.config = await api('/api/providers/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
1441
|
-
await refreshTools();
|
|
2518
|
+
await Promise.all([refreshStatus(), refreshTools()]);
|
|
1442
2519
|
render();
|
|
1443
2520
|
}
|
|
1444
2521
|
|
|
@@ -1484,9 +2561,86 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1484
2561
|
}[char]));
|
|
1485
2562
|
}
|
|
1486
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
|
+
|
|
1487
2638
|
el('refreshTools').addEventListener('click', refreshTools);
|
|
2639
|
+
el('refreshStatus').addEventListener('click', refreshStatus);
|
|
2640
|
+
el('refreshLogs').addEventListener('click', refreshLogs);
|
|
1488
2641
|
el('saveRules').addEventListener('click', () => saveCurrent().catch((error) => alert(error.message)));
|
|
1489
2642
|
el('manageProviders').addEventListener('click', () => setActive('providers'));
|
|
2643
|
+
el('viewActivity').addEventListener('click', () => setActive('activity'));
|
|
1490
2644
|
el('addProvider').addEventListener('click', () => addProvider().catch((error) => alert(error.message)));
|
|
1491
2645
|
el('addAgent').addEventListener('click', () => addEntity('agent').catch((error) => alert(error.message)));
|
|
1492
2646
|
el('addProfile').addEventListener('click', () => addEntity('profile').catch((error) => alert(error.message)));
|
|
@@ -1494,6 +2648,19 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1494
2648
|
el('search').addEventListener('input', (event) => { state.search = event.target.value; renderTools(); });
|
|
1495
2649
|
el('providerFilter').addEventListener('change', (event) => { state.provider = event.target.value; renderTools(); });
|
|
1496
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
|
+
});
|
|
1497
2664
|
el('bulkAllow').addEventListener('click', () => setVisible('allow'));
|
|
1498
2665
|
el('bulkAsk').addEventListener('click', () => setVisible('ask'));
|
|
1499
2666
|
el('bulkDeny').addEventListener('click', () => setVisible('deny'));
|
|
@@ -1501,8 +2668,29 @@ const INDEX_HTML = `<!doctype html>
|
|
|
1501
2668
|
el('resetRules').addEventListener('click', resetVisibleToCurrentConfig);
|
|
1502
2669
|
el('resetAllRules').addEventListener('click', resetAllToCurrentConfig);
|
|
1503
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
|
+
});
|
|
1504
2689
|
|
|
1505
|
-
|
|
2690
|
+
initApprovalSettings();
|
|
2691
|
+
connectApprovalEvents();
|
|
2692
|
+
checkVersion().catch(() => {});
|
|
2693
|
+
loadState().then(() => Promise.all([refreshStatus(), refreshLogs(), refreshTools()])).catch((error) => {
|
|
1506
2694
|
document.body.innerHTML = '<div class="empty">' + escapeHtml(error.message) + '</div>';
|
|
1507
2695
|
});
|
|
1508
2696
|
</script>
|