airlock-bot 0.2.32 → 0.2.33
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/dist/config/profiles.d.ts +2 -0
- package/dist/config/profiles.d.ts.map +1 -1
- package/dist/config/profiles.js +8 -1
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/schema.d.ts +110 -20
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +15 -3
- package/dist/config/schema.js.map +1 -1
- package/dist/configure-web/cli.d.ts +81 -0
- package/dist/configure-web/cli.d.ts.map +1 -0
- package/dist/configure-web/cli.js +1511 -0
- package/dist/configure-web/cli.js.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/pool/pool.d.ts +7 -3
- package/dist/pool/pool.d.ts.map +1 -1
- package/dist/pool/pool.js +9 -2
- package/dist/pool/pool.js.map +1 -1
- package/package.json +2 -1
- package/schema.json +36 -0
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
|
|
2
|
+
import { parseArgs } from 'util';
|
|
3
|
+
import Fastify from 'fastify';
|
|
4
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
5
|
+
import { buildAdapters } from '../backend/factory.js';
|
|
6
|
+
import { GatewayConfig, getMcpConfigs, } from '../config/schema.js';
|
|
7
|
+
import { validateConfig } from '../config/loader.js';
|
|
8
|
+
import { ClientPool } from '../pool/pool.js';
|
|
9
|
+
import { checkSuspiciousPatterns } from '../registry/sanitizer.js';
|
|
10
|
+
const HELP = `
|
|
11
|
+
airlock configure-web - browser UI for profiles, agents, and allow/ask/deny lists
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
airlock configure-web [options]
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
-c, --config <path> Airlock config file (default: ./airlock.yaml)
|
|
18
|
+
-p, --port <port> Web UI port (default: 4177)
|
|
19
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
20
|
+
-h, --help Show this help
|
|
21
|
+
`;
|
|
22
|
+
export async function runConfigureWeb(argv) {
|
|
23
|
+
const { values } = parseArgs({
|
|
24
|
+
args: argv,
|
|
25
|
+
options: {
|
|
26
|
+
config: { type: 'string', short: 'c', default: './airlock.yaml' },
|
|
27
|
+
port: { type: 'string', short: 'p', default: '4177' },
|
|
28
|
+
host: { type: 'string', default: '127.0.0.1' },
|
|
29
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
30
|
+
},
|
|
31
|
+
allowPositionals: false,
|
|
32
|
+
});
|
|
33
|
+
if (values.help) {
|
|
34
|
+
console.log(HELP);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
const configPath = values.config ?? './airlock.yaml';
|
|
38
|
+
const port = Number(values.port ?? '4177');
|
|
39
|
+
const host = values.host ?? '127.0.0.1';
|
|
40
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
41
|
+
throw new Error(`Invalid --port: ${values.port}`);
|
|
42
|
+
}
|
|
43
|
+
const app = createConfigureWebApp(configPath);
|
|
44
|
+
await app.listen({ host, port });
|
|
45
|
+
console.log(`Airlock configure-web running at http://${host}:${port}`);
|
|
46
|
+
console.log(`Editing ${configPath}`);
|
|
47
|
+
}
|
|
48
|
+
export function createConfigureWebApp(configPath) {
|
|
49
|
+
const app = Fastify({ logger: false });
|
|
50
|
+
app.get('/', async (_request, reply) => {
|
|
51
|
+
reply.type('text/html; charset=utf-8').send(INDEX_HTML);
|
|
52
|
+
});
|
|
53
|
+
app.get('/api/state', () => readState(configPath));
|
|
54
|
+
app.get('/api/tools', async (_request, reply) => {
|
|
55
|
+
try {
|
|
56
|
+
return await discoverTools(configPath);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
reply.code(500);
|
|
60
|
+
return { error: err instanceof Error ? err.message : String(err), tools: [] };
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
app.post('/api/rules', async (request, reply) => {
|
|
64
|
+
try {
|
|
65
|
+
const body = request.body;
|
|
66
|
+
return saveRules(configPath, body);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
reply.code(400);
|
|
70
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
app.post('/api/entities', async (request, reply) => {
|
|
74
|
+
try {
|
|
75
|
+
const body = request.body;
|
|
76
|
+
return createEntity(configPath, body);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
reply.code(400);
|
|
80
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
app.post('/api/providers', async (request, reply) => {
|
|
84
|
+
try {
|
|
85
|
+
const body = request.body;
|
|
86
|
+
return upsertProvider(configPath, body);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
reply.code(400);
|
|
90
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
app.delete('/api/providers/:id', async (request, reply) => {
|
|
94
|
+
try {
|
|
95
|
+
const { id } = request.params;
|
|
96
|
+
return deleteProvider(configPath, id);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
reply.code(400);
|
|
100
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
app.delete('/api/entities/:kind/:id', async (request, reply) => {
|
|
104
|
+
try {
|
|
105
|
+
const { kind, id } = request.params;
|
|
106
|
+
return deleteEntity(configPath, kind, id);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
reply.code(400);
|
|
110
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return app;
|
|
114
|
+
}
|
|
115
|
+
export function readState(configPath) {
|
|
116
|
+
const parsed = readConfigObject(configPath);
|
|
117
|
+
const validation = parseGatewayConfig(parsed);
|
|
118
|
+
const config = validation.config;
|
|
119
|
+
return {
|
|
120
|
+
configPath,
|
|
121
|
+
providers: toEditableProviders(asRecord(parsed.providers)),
|
|
122
|
+
agents: toEditableAgents(asRecord(parsed.agents)),
|
|
123
|
+
profiles: toEditableProfiles(asRecord(parsed.profiles)),
|
|
124
|
+
diagnostics: config ? validateConfig(config) : validation.diagnostics,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function saveRules(configPath, body) {
|
|
128
|
+
const kind = parseKind(body.kind);
|
|
129
|
+
const id = parseId(body.id);
|
|
130
|
+
const doc = readConfigObject(configPath);
|
|
131
|
+
const sectionName = kind === 'agent' ? 'agents' : 'profiles';
|
|
132
|
+
const section = asMutableRecord(doc[sectionName]);
|
|
133
|
+
const existing = asMutableRecord(section[id]);
|
|
134
|
+
if (kind === 'agent') {
|
|
135
|
+
existing.extends = parseStringArray(body.extends, 'extends');
|
|
136
|
+
existing.allow = parseStringArray(body.allow, 'allow');
|
|
137
|
+
existing.ask = parseStringArray(body.ask, 'ask');
|
|
138
|
+
existing.deny = parseStringArray(body.deny, 'deny');
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
existing.allow = parseStringArray(body.allow, 'allow');
|
|
142
|
+
existing.ask = parseStringArray(body.ask, 'ask');
|
|
143
|
+
existing.deny = parseStringArray(body.deny, 'deny');
|
|
144
|
+
}
|
|
145
|
+
section[id] = existing;
|
|
146
|
+
doc[sectionName] = section;
|
|
147
|
+
writeValidatedConfig(configPath, doc);
|
|
148
|
+
return readState(configPath);
|
|
149
|
+
}
|
|
150
|
+
export function createEntity(configPath, body) {
|
|
151
|
+
const kind = parseKind(body.kind);
|
|
152
|
+
const id = parseId(body.id);
|
|
153
|
+
const doc = readConfigObject(configPath);
|
|
154
|
+
const sectionName = kind === 'agent' ? 'agents' : 'profiles';
|
|
155
|
+
const section = asMutableRecord(doc[sectionName]);
|
|
156
|
+
if (section[id])
|
|
157
|
+
throw new Error(`${kind} "${id}" already exists`);
|
|
158
|
+
if (body.baseId && section[body.baseId]) {
|
|
159
|
+
section[id] = structuredClone(section[body.baseId]);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
section[id] = { allow: [], ask: [], deny: [] };
|
|
163
|
+
}
|
|
164
|
+
doc[sectionName] = section;
|
|
165
|
+
writeValidatedConfig(configPath, doc);
|
|
166
|
+
return readState(configPath);
|
|
167
|
+
}
|
|
168
|
+
export function deleteEntity(configPath, kind, id) {
|
|
169
|
+
const parsedKind = parseKind(kind);
|
|
170
|
+
const parsedId = parseId(id);
|
|
171
|
+
const doc = readConfigObject(configPath);
|
|
172
|
+
const sectionName = parsedKind === 'agent' ? 'agents' : 'profiles';
|
|
173
|
+
const section = asMutableRecord(doc[sectionName]);
|
|
174
|
+
if (!section[parsedId])
|
|
175
|
+
throw new Error(`${parsedKind} "${parsedId}" does not exist`);
|
|
176
|
+
delete section[parsedId];
|
|
177
|
+
doc[sectionName] = section;
|
|
178
|
+
writeValidatedConfig(configPath, doc);
|
|
179
|
+
return readState(configPath);
|
|
180
|
+
}
|
|
181
|
+
export function upsertProvider(configPath, body) {
|
|
182
|
+
const id = parseId(body.id);
|
|
183
|
+
const type = parseProviderType(body.type);
|
|
184
|
+
const doc = readConfigObject(configPath);
|
|
185
|
+
const providers = asMutableRecord(doc.providers);
|
|
186
|
+
const existing = asMutableRecord(providers[id]);
|
|
187
|
+
const enabled = body.enabled === undefined ? providerEnabled(existing) : parseBoolean(body.enabled);
|
|
188
|
+
if (type === 'builtin') {
|
|
189
|
+
providers[id] = enabled ? 'builtin' : { type: 'builtin', enabled: false };
|
|
190
|
+
}
|
|
191
|
+
else if (type === 'stdio') {
|
|
192
|
+
const command = typeof body.command === 'string' && body.command.trim()
|
|
193
|
+
? body.command.trim()
|
|
194
|
+
: typeof existing.command === 'string'
|
|
195
|
+
? existing.command
|
|
196
|
+
: '';
|
|
197
|
+
if (!command)
|
|
198
|
+
throw new Error('stdio providers require command');
|
|
199
|
+
providers[id] = removeUndefined({
|
|
200
|
+
...existing,
|
|
201
|
+
type,
|
|
202
|
+
enabled: enabled ? undefined : false,
|
|
203
|
+
command,
|
|
204
|
+
args: parseOptionalStringArray(body.args, 'args') ?? stringArray(existing.args),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const url = typeof body.url === 'string' && body.url.trim()
|
|
209
|
+
? body.url.trim()
|
|
210
|
+
: typeof existing.url === 'string'
|
|
211
|
+
? existing.url
|
|
212
|
+
: '';
|
|
213
|
+
if (!url)
|
|
214
|
+
throw new Error(`${type} providers require url`);
|
|
215
|
+
providers[id] = removeUndefined({
|
|
216
|
+
...existing,
|
|
217
|
+
type,
|
|
218
|
+
enabled: enabled ? undefined : false,
|
|
219
|
+
url,
|
|
220
|
+
oauth: type === 'http' ? (parseOptionalBoolean(body.oauth) ?? Boolean(existing.oauth)) : undefined,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
doc.providers = providers;
|
|
224
|
+
writeValidatedConfig(configPath, doc);
|
|
225
|
+
return readState(configPath);
|
|
226
|
+
}
|
|
227
|
+
export function deleteProvider(configPath, id) {
|
|
228
|
+
const parsedId = parseId(id);
|
|
229
|
+
const doc = readConfigObject(configPath);
|
|
230
|
+
const providers = asMutableRecord(doc.providers);
|
|
231
|
+
if (!providers[parsedId])
|
|
232
|
+
throw new Error(`provider "${parsedId}" does not exist`);
|
|
233
|
+
delete providers[parsedId];
|
|
234
|
+
doc.providers = providers;
|
|
235
|
+
writeValidatedConfig(configPath, doc);
|
|
236
|
+
return readState(configPath);
|
|
237
|
+
}
|
|
238
|
+
export function recommendedDecision(annotations, suspiciousPatterns = []) {
|
|
239
|
+
if (suspiciousPatterns.length > 0)
|
|
240
|
+
return 'deny';
|
|
241
|
+
if (annotations?.destructiveHint || annotations?.openWorldHint)
|
|
242
|
+
return 'ask';
|
|
243
|
+
return 'allow';
|
|
244
|
+
}
|
|
245
|
+
export function annotationTags(annotations, suspiciousPatterns = []) {
|
|
246
|
+
const tags = [];
|
|
247
|
+
if (annotations?.readOnlyHint)
|
|
248
|
+
tags.push('readonly');
|
|
249
|
+
if (annotations?.destructiveHint)
|
|
250
|
+
tags.push('destructive');
|
|
251
|
+
if (annotations?.idempotentHint)
|
|
252
|
+
tags.push('idempotent');
|
|
253
|
+
if (annotations?.openWorldHint)
|
|
254
|
+
tags.push('open-world');
|
|
255
|
+
if (suspiciousPatterns.length > 0)
|
|
256
|
+
tags.push('injection');
|
|
257
|
+
return tags;
|
|
258
|
+
}
|
|
259
|
+
export async function discoverTools(configPath) {
|
|
260
|
+
const raw = readConfigObject(configPath);
|
|
261
|
+
const parsed = GatewayConfig.parse(withoutDisabledProviders(raw));
|
|
262
|
+
const mcpConfigs = getMcpConfigs(parsed.providers);
|
|
263
|
+
const pool = new ClientPool(mcpConfigs, {
|
|
264
|
+
stdioStderr: 'ignore',
|
|
265
|
+
healthCheck: false,
|
|
266
|
+
retryFailedConnections: false,
|
|
267
|
+
});
|
|
268
|
+
const errors = [];
|
|
269
|
+
try {
|
|
270
|
+
await Promise.race([
|
|
271
|
+
pool.initialize(),
|
|
272
|
+
new Promise((resolve) => setTimeout(resolve, 6_000)),
|
|
273
|
+
]);
|
|
274
|
+
const adapters = buildAdapters(parsed, pool);
|
|
275
|
+
const settled = await Promise.allSettled(adapters.map((adapter) => withTimeout(adapter.listTools(), 8_000, `${adapter.id}: timed out listing tools`)));
|
|
276
|
+
const tools = [];
|
|
277
|
+
for (let i = 0; i < settled.length; i++) {
|
|
278
|
+
const result = settled[i];
|
|
279
|
+
const adapter = adapters[i];
|
|
280
|
+
if (result.status === 'rejected') {
|
|
281
|
+
errors.push(`${adapter.id}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
for (const tool of result.value) {
|
|
285
|
+
const provider = tool.name.split('/')[0] ?? '';
|
|
286
|
+
const suspiciousPatterns = tool.description
|
|
287
|
+
? checkSuspiciousPatterns(tool.description)
|
|
288
|
+
: [];
|
|
289
|
+
tools.push({
|
|
290
|
+
name: tool.name,
|
|
291
|
+
provider,
|
|
292
|
+
shortName: tool.name.split('/').slice(1).join('/') || tool.name,
|
|
293
|
+
description: tool.description ?? '',
|
|
294
|
+
annotations: tool.annotations,
|
|
295
|
+
tags: annotationTags(tool.annotations, suspiciousPatterns),
|
|
296
|
+
suspiciousPatterns,
|
|
297
|
+
recommended: recommendedDecision(tool.annotations, suspiciousPatterns),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
tools.sort((a, b) => a.name.localeCompare(b.name));
|
|
302
|
+
return { tools, errors };
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
await pool.stop().catch(() => { });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function withoutDisabledProviders(doc) {
|
|
309
|
+
const providers = asRecord(doc.providers);
|
|
310
|
+
const enabledProviders = Object.fromEntries(Object.entries(providers).filter(([, provider]) => !rawProviderDisabled(provider)));
|
|
311
|
+
return { ...doc, providers: enabledProviders };
|
|
312
|
+
}
|
|
313
|
+
function rawProviderDisabled(provider) {
|
|
314
|
+
return asRecord(provider).enabled === false;
|
|
315
|
+
}
|
|
316
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
317
|
+
let timer;
|
|
318
|
+
const timeout = new Promise((_, reject) => {
|
|
319
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
320
|
+
});
|
|
321
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
322
|
+
if (timer)
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
function readConfigObject(configPath) {
|
|
327
|
+
if (!existsSync(configPath))
|
|
328
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
329
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
330
|
+
const parsed = parseYaml(raw);
|
|
331
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
332
|
+
throw new Error(`Config must be a YAML object: ${configPath}`);
|
|
333
|
+
}
|
|
334
|
+
return parsed;
|
|
335
|
+
}
|
|
336
|
+
function writeValidatedConfig(configPath, doc) {
|
|
337
|
+
const validation = parseGatewayConfig(doc);
|
|
338
|
+
const errors = validation.diagnostics.filter((d) => d.level === 'error');
|
|
339
|
+
if (!validation.config || errors.length > 0) {
|
|
340
|
+
const message = errors.map((d) => `${d.agent ? `[${d.agent}] ` : ''}${d.message}`).join('\n') ||
|
|
341
|
+
'Config did not pass schema validation';
|
|
342
|
+
throw new Error(message);
|
|
343
|
+
}
|
|
344
|
+
const backupPath = configPath.replace(/\.ya?ml$/i, '') + '.bak';
|
|
345
|
+
copyFileSync(configPath, backupPath);
|
|
346
|
+
writeFileSync(configPath, stringifyYaml(doc));
|
|
347
|
+
}
|
|
348
|
+
function parseGatewayConfig(doc) {
|
|
349
|
+
try {
|
|
350
|
+
const result = GatewayConfig.safeParse(doc);
|
|
351
|
+
if (!result.success) {
|
|
352
|
+
return {
|
|
353
|
+
diagnostics: [
|
|
354
|
+
{
|
|
355
|
+
level: 'error',
|
|
356
|
+
message: result.error.issues.map((issue) => issue.message).join('; '),
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return { config: result.data, diagnostics: validateConfig(result.data) };
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
return {
|
|
365
|
+
diagnostics: [{ level: 'error', message: err instanceof Error ? err.message : String(err) }],
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function toEditableAgents(input) {
|
|
370
|
+
return Object.fromEntries(Object.entries(input).map(([id, value]) => {
|
|
371
|
+
const agent = asRecord(value);
|
|
372
|
+
return [
|
|
373
|
+
id,
|
|
374
|
+
{
|
|
375
|
+
extends: stringArray(agent.extends),
|
|
376
|
+
allow: stringArray(agent.allow),
|
|
377
|
+
ask: stringArray(agent.ask),
|
|
378
|
+
deny: stringArray(agent.deny),
|
|
379
|
+
},
|
|
380
|
+
];
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
function toEditableProviders(input) {
|
|
384
|
+
const providers = {};
|
|
385
|
+
for (const [id, value] of Object.entries(input)) {
|
|
386
|
+
if (value === 'builtin') {
|
|
387
|
+
providers[id] = { type: 'builtin', enabled: true };
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const provider = asRecord(value);
|
|
391
|
+
const type = parseProviderType(provider.type);
|
|
392
|
+
providers[id] = {
|
|
393
|
+
type,
|
|
394
|
+
enabled: providerEnabled(provider),
|
|
395
|
+
command: typeof provider.command === 'string' ? provider.command : undefined,
|
|
396
|
+
args: stringArray(provider.args),
|
|
397
|
+
url: typeof provider.url === 'string' ? provider.url : undefined,
|
|
398
|
+
oauth: typeof provider.oauth === 'boolean' ? provider.oauth : undefined,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return providers;
|
|
402
|
+
}
|
|
403
|
+
function toEditableProfiles(input) {
|
|
404
|
+
return Object.fromEntries(Object.entries(input).map(([id, value]) => {
|
|
405
|
+
const profile = asRecord(value);
|
|
406
|
+
return [
|
|
407
|
+
id,
|
|
408
|
+
{
|
|
409
|
+
allow: stringArray(profile.allow),
|
|
410
|
+
ask: stringArray(profile.ask),
|
|
411
|
+
deny: stringArray(profile.deny),
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
function parseProviderType(value) {
|
|
417
|
+
if (value === 'builtin' || value === 'stdio' || value === 'sse' || value === 'http') {
|
|
418
|
+
return value;
|
|
419
|
+
}
|
|
420
|
+
throw new Error('provider type must be "builtin", "stdio", "sse", or "http"');
|
|
421
|
+
}
|
|
422
|
+
function parseKind(value) {
|
|
423
|
+
if (value === 'agent' || value === 'profile')
|
|
424
|
+
return value;
|
|
425
|
+
throw new Error('kind must be "agent" or "profile"');
|
|
426
|
+
}
|
|
427
|
+
function parseId(value) {
|
|
428
|
+
if (typeof value !== 'string')
|
|
429
|
+
throw new Error('id must be a string');
|
|
430
|
+
const id = value.trim();
|
|
431
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(id)) {
|
|
432
|
+
throw new Error('id may only contain letters, numbers, dots, underscores, and dashes');
|
|
433
|
+
}
|
|
434
|
+
return id;
|
|
435
|
+
}
|
|
436
|
+
function parseBoolean(value) {
|
|
437
|
+
if (typeof value === 'boolean')
|
|
438
|
+
return value;
|
|
439
|
+
throw new Error('enabled must be a boolean');
|
|
440
|
+
}
|
|
441
|
+
function parseOptionalBoolean(value) {
|
|
442
|
+
return value === undefined ? undefined : parseBoolean(value);
|
|
443
|
+
}
|
|
444
|
+
function parseStringArray(value, name) {
|
|
445
|
+
if (!Array.isArray(value)) {
|
|
446
|
+
throw new Error(`${name} must be an array of strings`);
|
|
447
|
+
}
|
|
448
|
+
const result = [];
|
|
449
|
+
for (const item of value) {
|
|
450
|
+
if (typeof item !== 'string') {
|
|
451
|
+
throw new Error(`${name} must be an array of strings`);
|
|
452
|
+
}
|
|
453
|
+
const trimmed = item.trim();
|
|
454
|
+
if (trimmed)
|
|
455
|
+
result.push(trimmed);
|
|
456
|
+
}
|
|
457
|
+
return dedupe(result);
|
|
458
|
+
}
|
|
459
|
+
function parseOptionalStringArray(value, name) {
|
|
460
|
+
return value === undefined ? undefined : parseStringArray(value, name);
|
|
461
|
+
}
|
|
462
|
+
function stringArray(value) {
|
|
463
|
+
return Array.isArray(value)
|
|
464
|
+
? value.filter((item) => typeof item === 'string')
|
|
465
|
+
: [];
|
|
466
|
+
}
|
|
467
|
+
function dedupe(values) {
|
|
468
|
+
return Array.from(new Set(values));
|
|
469
|
+
}
|
|
470
|
+
function asRecord(value) {
|
|
471
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
472
|
+
? value
|
|
473
|
+
: {};
|
|
474
|
+
}
|
|
475
|
+
function asMutableRecord(value) {
|
|
476
|
+
return { ...asRecord(value) };
|
|
477
|
+
}
|
|
478
|
+
function providerEnabled(provider) {
|
|
479
|
+
return provider.enabled !== false;
|
|
480
|
+
}
|
|
481
|
+
function removeUndefined(value) {
|
|
482
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
483
|
+
}
|
|
484
|
+
const INDEX_HTML = `<!doctype html>
|
|
485
|
+
<html lang="en">
|
|
486
|
+
<head>
|
|
487
|
+
<meta charset="utf-8">
|
|
488
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
489
|
+
<title>Airlock Configure</title>
|
|
490
|
+
<style>
|
|
491
|
+
:root {
|
|
492
|
+
color-scheme: light;
|
|
493
|
+
--bg: #f7f8fa;
|
|
494
|
+
--panel: #ffffff;
|
|
495
|
+
--ink: #1d2530;
|
|
496
|
+
--muted: #647184;
|
|
497
|
+
--line: #dce2ea;
|
|
498
|
+
--blue: #2457d6;
|
|
499
|
+
--green: #15804d;
|
|
500
|
+
--amber: #946000;
|
|
501
|
+
--red: #b42318;
|
|
502
|
+
--shadow: 0 16px 42px rgba(30, 41, 59, 0.10);
|
|
503
|
+
}
|
|
504
|
+
* { box-sizing: border-box; }
|
|
505
|
+
html {
|
|
506
|
+
height: 100%;
|
|
507
|
+
overflow: hidden;
|
|
508
|
+
}
|
|
509
|
+
body {
|
|
510
|
+
margin: 0;
|
|
511
|
+
height: 100%;
|
|
512
|
+
overflow: hidden;
|
|
513
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
514
|
+
background: var(--bg);
|
|
515
|
+
color: var(--ink);
|
|
516
|
+
}
|
|
517
|
+
button, input, select {
|
|
518
|
+
box-sizing: border-box;
|
|
519
|
+
font: inherit;
|
|
520
|
+
}
|
|
521
|
+
button {
|
|
522
|
+
display: inline-flex;
|
|
523
|
+
align-items: center;
|
|
524
|
+
justify-content: center;
|
|
525
|
+
height: 34px;
|
|
526
|
+
border: 1px solid var(--line);
|
|
527
|
+
border-radius: 7px;
|
|
528
|
+
background: #fff;
|
|
529
|
+
color: var(--ink);
|
|
530
|
+
cursor: pointer;
|
|
531
|
+
line-height: 1;
|
|
532
|
+
padding: 0 10px;
|
|
533
|
+
vertical-align: middle;
|
|
534
|
+
}
|
|
535
|
+
button:hover { border-color: #aeb9c8; }
|
|
536
|
+
button.primary {
|
|
537
|
+
background: var(--blue);
|
|
538
|
+
color: #fff;
|
|
539
|
+
border-color: var(--blue);
|
|
540
|
+
}
|
|
541
|
+
.app {
|
|
542
|
+
height: 100vh;
|
|
543
|
+
overflow: hidden;
|
|
544
|
+
display: grid;
|
|
545
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
546
|
+
}
|
|
547
|
+
header {
|
|
548
|
+
height: 64px;
|
|
549
|
+
display: flex;
|
|
550
|
+
align-items: center;
|
|
551
|
+
justify-content: space-between;
|
|
552
|
+
gap: 16px;
|
|
553
|
+
padding: 0 20px;
|
|
554
|
+
background: #fff;
|
|
555
|
+
border-bottom: 1px solid var(--line);
|
|
556
|
+
}
|
|
557
|
+
h1 {
|
|
558
|
+
margin: 0;
|
|
559
|
+
font-size: 18px;
|
|
560
|
+
letter-spacing: 0;
|
|
561
|
+
}
|
|
562
|
+
.subtle { color: var(--muted); }
|
|
563
|
+
.top-actions, .row, .entity-actions, .toolbar, .rule-actions {
|
|
564
|
+
display: flex;
|
|
565
|
+
align-items: center;
|
|
566
|
+
gap: 8px;
|
|
567
|
+
}
|
|
568
|
+
.layout {
|
|
569
|
+
min-height: 0;
|
|
570
|
+
overflow: hidden;
|
|
571
|
+
display: grid;
|
|
572
|
+
grid-template-columns: 260px minmax(0, 1fr) 300px;
|
|
573
|
+
}
|
|
574
|
+
aside, .details {
|
|
575
|
+
min-height: 0;
|
|
576
|
+
padding: 16px;
|
|
577
|
+
border-right: 1px solid var(--line);
|
|
578
|
+
overflow: auto;
|
|
579
|
+
background: #fbfcfe;
|
|
580
|
+
}
|
|
581
|
+
.details {
|
|
582
|
+
border-right: 0;
|
|
583
|
+
border-left: 1px solid var(--line);
|
|
584
|
+
}
|
|
585
|
+
main {
|
|
586
|
+
min-width: 0;
|
|
587
|
+
min-height: 0;
|
|
588
|
+
padding: 18px;
|
|
589
|
+
overflow: auto;
|
|
590
|
+
}
|
|
591
|
+
.section-title {
|
|
592
|
+
margin: 18px 0 8px;
|
|
593
|
+
font-size: 12px;
|
|
594
|
+
text-transform: uppercase;
|
|
595
|
+
color: var(--muted);
|
|
596
|
+
letter-spacing: 0.08em;
|
|
597
|
+
}
|
|
598
|
+
.entity {
|
|
599
|
+
width: 100%;
|
|
600
|
+
justify-content: flex-start;
|
|
601
|
+
margin-bottom: 6px;
|
|
602
|
+
background: transparent;
|
|
603
|
+
}
|
|
604
|
+
.entity.active {
|
|
605
|
+
background: #eaf0ff;
|
|
606
|
+
border-color: #b9c8ff;
|
|
607
|
+
color: #173c9c;
|
|
608
|
+
}
|
|
609
|
+
.provider-item {
|
|
610
|
+
display: grid;
|
|
611
|
+
grid-template-columns: minmax(190px, 1fr) 90px 64px 64px 64px;
|
|
612
|
+
align-items: center;
|
|
613
|
+
gap: 10px;
|
|
614
|
+
padding: 12px;
|
|
615
|
+
border-bottom: 1px solid var(--line);
|
|
616
|
+
}
|
|
617
|
+
.provider-item:last-child {
|
|
618
|
+
border-bottom: 0;
|
|
619
|
+
}
|
|
620
|
+
.provider-name {
|
|
621
|
+
min-width: 0;
|
|
622
|
+
overflow-wrap: anywhere;
|
|
623
|
+
font-size: 13px;
|
|
624
|
+
font-weight: 650;
|
|
625
|
+
}
|
|
626
|
+
.provider-name span {
|
|
627
|
+
display: block;
|
|
628
|
+
color: var(--muted);
|
|
629
|
+
font-size: 12px;
|
|
630
|
+
font-weight: 500;
|
|
631
|
+
}
|
|
632
|
+
.provider-item.disabled .provider-name {
|
|
633
|
+
color: var(--muted);
|
|
634
|
+
text-decoration: line-through;
|
|
635
|
+
}
|
|
636
|
+
.provider-item button {
|
|
637
|
+
min-width: 34px;
|
|
638
|
+
padding: 0 8px;
|
|
639
|
+
}
|
|
640
|
+
.pill.provider-status-enabled {
|
|
641
|
+
border-color: #b8dfca;
|
|
642
|
+
background: #e8f6ef;
|
|
643
|
+
color: var(--green);
|
|
644
|
+
font-weight: 700;
|
|
645
|
+
}
|
|
646
|
+
.pill.provider-status-disabled {
|
|
647
|
+
border-color: #f2c0bb;
|
|
648
|
+
background: #fff0ee;
|
|
649
|
+
color: var(--red);
|
|
650
|
+
font-weight: 700;
|
|
651
|
+
}
|
|
652
|
+
.provider-list {
|
|
653
|
+
background: var(--panel);
|
|
654
|
+
border: 1px solid var(--line);
|
|
655
|
+
border-radius: 8px;
|
|
656
|
+
overflow: hidden;
|
|
657
|
+
box-shadow: var(--shadow);
|
|
658
|
+
}
|
|
659
|
+
.workspace {
|
|
660
|
+
display: grid;
|
|
661
|
+
gap: 14px;
|
|
662
|
+
}
|
|
663
|
+
.hero {
|
|
664
|
+
display: flex;
|
|
665
|
+
justify-content: space-between;
|
|
666
|
+
align-items: flex-start;
|
|
667
|
+
gap: 12px;
|
|
668
|
+
}
|
|
669
|
+
.hero h2 {
|
|
670
|
+
margin: 0 0 4px;
|
|
671
|
+
font-size: 24px;
|
|
672
|
+
letter-spacing: 0;
|
|
673
|
+
}
|
|
674
|
+
.toolbar {
|
|
675
|
+
flex-wrap: wrap;
|
|
676
|
+
padding: 12px;
|
|
677
|
+
background: var(--panel);
|
|
678
|
+
border: 1px solid var(--line);
|
|
679
|
+
border-radius: 8px;
|
|
680
|
+
box-shadow: var(--shadow);
|
|
681
|
+
}
|
|
682
|
+
.toolbar input, .toolbar select, .details input {
|
|
683
|
+
min-height: 34px;
|
|
684
|
+
border: 1px solid var(--line);
|
|
685
|
+
border-radius: 7px;
|
|
686
|
+
padding: 0 10px;
|
|
687
|
+
background: #fff;
|
|
688
|
+
color: var(--ink);
|
|
689
|
+
}
|
|
690
|
+
.toolbar input { flex: 1 1 260px; min-width: 180px; }
|
|
691
|
+
.table {
|
|
692
|
+
background: var(--panel);
|
|
693
|
+
border: 1px solid var(--line);
|
|
694
|
+
border-radius: 8px;
|
|
695
|
+
overflow: hidden;
|
|
696
|
+
box-shadow: var(--shadow);
|
|
697
|
+
}
|
|
698
|
+
.tool-row {
|
|
699
|
+
display: grid;
|
|
700
|
+
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) 236px;
|
|
701
|
+
gap: 12px;
|
|
702
|
+
align-items: start;
|
|
703
|
+
padding: 12px;
|
|
704
|
+
border-bottom: 1px solid var(--line);
|
|
705
|
+
}
|
|
706
|
+
.tool-row:last-child { border-bottom: 0; }
|
|
707
|
+
.tool-name {
|
|
708
|
+
min-width: 0;
|
|
709
|
+
font-weight: 650;
|
|
710
|
+
overflow-wrap: anywhere;
|
|
711
|
+
}
|
|
712
|
+
.provider {
|
|
713
|
+
display: inline-block;
|
|
714
|
+
margin-bottom: 4px;
|
|
715
|
+
color: var(--muted);
|
|
716
|
+
font-size: 12px;
|
|
717
|
+
}
|
|
718
|
+
.description {
|
|
719
|
+
color: var(--muted);
|
|
720
|
+
font-size: 13px;
|
|
721
|
+
line-height: 1.35;
|
|
722
|
+
}
|
|
723
|
+
.description-content {
|
|
724
|
+
position: relative;
|
|
725
|
+
display: grid;
|
|
726
|
+
gap: 7px;
|
|
727
|
+
max-width: 78ch;
|
|
728
|
+
overflow-wrap: anywhere;
|
|
729
|
+
}
|
|
730
|
+
.description-content.collapsed {
|
|
731
|
+
max-height: 4.25rem;
|
|
732
|
+
overflow: hidden;
|
|
733
|
+
}
|
|
734
|
+
.description-content.collapsed::after {
|
|
735
|
+
position: absolute;
|
|
736
|
+
right: 0;
|
|
737
|
+
bottom: 0;
|
|
738
|
+
left: 0;
|
|
739
|
+
height: 28px;
|
|
740
|
+
background: linear-gradient(180deg, rgba(255,255,255,0), var(--panel));
|
|
741
|
+
content: "";
|
|
742
|
+
pointer-events: none;
|
|
743
|
+
}
|
|
744
|
+
.description-content p,
|
|
745
|
+
.description-content h4 {
|
|
746
|
+
margin: 0;
|
|
747
|
+
}
|
|
748
|
+
.description-content h4 {
|
|
749
|
+
color: var(--ink);
|
|
750
|
+
font-size: 13px;
|
|
751
|
+
font-weight: 750;
|
|
752
|
+
}
|
|
753
|
+
.description-content code {
|
|
754
|
+
padding: 1px 4px;
|
|
755
|
+
border: 1px solid var(--line);
|
|
756
|
+
border-radius: 4px;
|
|
757
|
+
background: #f5f7fb;
|
|
758
|
+
color: var(--ink);
|
|
759
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
760
|
+
font-size: 12px;
|
|
761
|
+
}
|
|
762
|
+
.description-content strong {
|
|
763
|
+
color: var(--ink);
|
|
764
|
+
font-weight: 700;
|
|
765
|
+
}
|
|
766
|
+
.description-step {
|
|
767
|
+
padding-left: 10px;
|
|
768
|
+
border-left: 2px solid var(--line);
|
|
769
|
+
}
|
|
770
|
+
.description-toggle {
|
|
771
|
+
height: 28px;
|
|
772
|
+
margin-top: 8px;
|
|
773
|
+
padding: 0 8px;
|
|
774
|
+
color: #173c9c;
|
|
775
|
+
font-size: 12px;
|
|
776
|
+
font-weight: 650;
|
|
777
|
+
background: #f7f9fd;
|
|
778
|
+
}
|
|
779
|
+
.tag-list {
|
|
780
|
+
display: flex;
|
|
781
|
+
flex-wrap: wrap;
|
|
782
|
+
gap: 6px;
|
|
783
|
+
margin-top: 8px;
|
|
784
|
+
}
|
|
785
|
+
.tag {
|
|
786
|
+
display: inline-flex;
|
|
787
|
+
align-items: center;
|
|
788
|
+
min-height: 22px;
|
|
789
|
+
padding: 0 7px;
|
|
790
|
+
border: 1px solid var(--line);
|
|
791
|
+
border-radius: 999px;
|
|
792
|
+
background: #fff;
|
|
793
|
+
color: var(--muted);
|
|
794
|
+
font-size: 11px;
|
|
795
|
+
line-height: 1;
|
|
796
|
+
}
|
|
797
|
+
.tag-good {
|
|
798
|
+
border-color: #b8dfca;
|
|
799
|
+
background: #e8f6ef;
|
|
800
|
+
color: var(--green);
|
|
801
|
+
}
|
|
802
|
+
.tag-warn {
|
|
803
|
+
border-color: #f1d99a;
|
|
804
|
+
background: #fff4d6;
|
|
805
|
+
color: var(--amber);
|
|
806
|
+
}
|
|
807
|
+
.tag-danger {
|
|
808
|
+
border-color: #f2c0bb;
|
|
809
|
+
background: #fff0ee;
|
|
810
|
+
color: var(--red);
|
|
811
|
+
}
|
|
812
|
+
.tag-recommended {
|
|
813
|
+
border-style: dashed;
|
|
814
|
+
font-weight: 650;
|
|
815
|
+
}
|
|
816
|
+
.rule-actions {
|
|
817
|
+
justify-content: flex-end;
|
|
818
|
+
}
|
|
819
|
+
.rule-actions button {
|
|
820
|
+
width: 52px;
|
|
821
|
+
padding: 0;
|
|
822
|
+
}
|
|
823
|
+
.rule-actions button.active[data-decision="allow"] {
|
|
824
|
+
border-color: var(--green);
|
|
825
|
+
background: #e8f6ef;
|
|
826
|
+
color: var(--green);
|
|
827
|
+
font-weight: 700;
|
|
828
|
+
}
|
|
829
|
+
.rule-actions button.active[data-decision="ask"] {
|
|
830
|
+
border-color: var(--amber);
|
|
831
|
+
background: #fff4d6;
|
|
832
|
+
color: var(--amber);
|
|
833
|
+
font-weight: 700;
|
|
834
|
+
}
|
|
835
|
+
.rule-actions button.active[data-decision="deny"] {
|
|
836
|
+
border-color: var(--red);
|
|
837
|
+
background: #fff0ee;
|
|
838
|
+
color: var(--red);
|
|
839
|
+
font-weight: 700;
|
|
840
|
+
}
|
|
841
|
+
.pill {
|
|
842
|
+
display: inline-flex;
|
|
843
|
+
align-items: center;
|
|
844
|
+
min-height: 24px;
|
|
845
|
+
padding: 0 8px;
|
|
846
|
+
border: 1px solid var(--line);
|
|
847
|
+
border-radius: 999px;
|
|
848
|
+
color: var(--muted);
|
|
849
|
+
background: #fff;
|
|
850
|
+
font-size: 12px;
|
|
851
|
+
}
|
|
852
|
+
.stack {
|
|
853
|
+
display: grid;
|
|
854
|
+
gap: 10px;
|
|
855
|
+
}
|
|
856
|
+
.panel {
|
|
857
|
+
background: #fff;
|
|
858
|
+
border: 1px solid var(--line);
|
|
859
|
+
border-radius: 8px;
|
|
860
|
+
padding: 12px;
|
|
861
|
+
}
|
|
862
|
+
.panel h3 {
|
|
863
|
+
margin: 0 0 10px;
|
|
864
|
+
font-size: 14px;
|
|
865
|
+
}
|
|
866
|
+
.check {
|
|
867
|
+
display: flex;
|
|
868
|
+
gap: 8px;
|
|
869
|
+
align-items: center;
|
|
870
|
+
margin: 8px 0;
|
|
871
|
+
color: var(--ink);
|
|
872
|
+
}
|
|
873
|
+
.check input { width: 16px; height: 16px; }
|
|
874
|
+
.diagnostic {
|
|
875
|
+
padding: 10px;
|
|
876
|
+
border-radius: 7px;
|
|
877
|
+
background: #fff7e6;
|
|
878
|
+
color: #6a4400;
|
|
879
|
+
font-size: 13px;
|
|
880
|
+
line-height: 1.35;
|
|
881
|
+
}
|
|
882
|
+
.diagnostic.error {
|
|
883
|
+
background: #fff0ee;
|
|
884
|
+
color: var(--red);
|
|
885
|
+
}
|
|
886
|
+
.empty {
|
|
887
|
+
padding: 30px;
|
|
888
|
+
text-align: center;
|
|
889
|
+
color: var(--muted);
|
|
890
|
+
}
|
|
891
|
+
@media (max-width: 1080px) {
|
|
892
|
+
.layout { grid-template-columns: 220px minmax(0, 1fr); }
|
|
893
|
+
.details { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
|
|
894
|
+
.tool-row { grid-template-columns: minmax(160px, 240px) minmax(0, 1fr); }
|
|
895
|
+
.rule-actions { grid-column: 1 / -1; justify-content: flex-start; }
|
|
896
|
+
}
|
|
897
|
+
@media (max-width: 720px) {
|
|
898
|
+
header { height: auto; min-height: 64px; align-items: flex-start; flex-direction: column; padding: 12px; }
|
|
899
|
+
.layout { grid-template-columns: 1fr; }
|
|
900
|
+
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
901
|
+
.tool-row { grid-template-columns: 1fr; }
|
|
902
|
+
.hero { flex-direction: column; }
|
|
903
|
+
}
|
|
904
|
+
</style>
|
|
905
|
+
</head>
|
|
906
|
+
<body>
|
|
907
|
+
<div class="app">
|
|
908
|
+
<header>
|
|
909
|
+
<div>
|
|
910
|
+
<h1>Airlock Configure</h1>
|
|
911
|
+
<div id="configPath" class="subtle"></div>
|
|
912
|
+
</div>
|
|
913
|
+
<div class="top-actions">
|
|
914
|
+
<button id="refreshTools">Refresh Endpoints</button>
|
|
915
|
+
<button id="saveRules" class="primary">Save</button>
|
|
916
|
+
</div>
|
|
917
|
+
</header>
|
|
918
|
+
<div class="layout">
|
|
919
|
+
<aside>
|
|
920
|
+
<button id="manageProviders" class="entity" style="margin-bottom:14px">Manage Providers</button>
|
|
921
|
+
<div class="row">
|
|
922
|
+
<div class="section-title" style="margin-top:0">Agents</div>
|
|
923
|
+
<button id="addAgent" title="Add agent">+</button>
|
|
924
|
+
</div>
|
|
925
|
+
<div id="agents"></div>
|
|
926
|
+
<div class="row">
|
|
927
|
+
<div class="section-title">Profiles</div>
|
|
928
|
+
<button id="addProfile" title="Add profile">+</button>
|
|
929
|
+
</div>
|
|
930
|
+
<div id="profiles"></div>
|
|
931
|
+
</aside>
|
|
932
|
+
<main>
|
|
933
|
+
<div class="workspace">
|
|
934
|
+
<div class="hero">
|
|
935
|
+
<div>
|
|
936
|
+
<h2 id="activeTitle">Select an agent or profile</h2>
|
|
937
|
+
<div id="activeMeta" class="subtle"></div>
|
|
938
|
+
</div>
|
|
939
|
+
<div class="entity-actions">
|
|
940
|
+
<button id="addProvider" style="display:none">Add Provider</button>
|
|
941
|
+
<button id="deleteEntity">Delete</button>
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
<div class="toolbar">
|
|
945
|
+
<input id="search" type="search" placeholder="Search tools, endpoints, providers">
|
|
946
|
+
<select id="providerFilter"></select>
|
|
947
|
+
<select id="decisionFilter">
|
|
948
|
+
<option value="all">All rules</option>
|
|
949
|
+
<option value="allow">Allow</option>
|
|
950
|
+
<option value="ask">Ask</option>
|
|
951
|
+
<option value="deny">Deny</option>
|
|
952
|
+
<option value="unset">Unset</option>
|
|
953
|
+
</select>
|
|
954
|
+
<button id="bulkAllow">Allow Visible</button>
|
|
955
|
+
<button id="bulkAsk">Ask Visible</button>
|
|
956
|
+
<button id="bulkDeny">Deny Visible</button>
|
|
957
|
+
<button id="bulkClear">Clear Visible</button>
|
|
958
|
+
<button id="resetRules">Reset Visible</button>
|
|
959
|
+
<button id="recommendedRules">Set Visible Recommended</button>
|
|
960
|
+
<button id="resetAllRules">Reset All to Config</button>
|
|
961
|
+
</div>
|
|
962
|
+
<div id="tools" class="table"></div>
|
|
963
|
+
</div>
|
|
964
|
+
</main>
|
|
965
|
+
<section class="details">
|
|
966
|
+
<div class="stack">
|
|
967
|
+
<div class="panel">
|
|
968
|
+
<h3>Summary</h3>
|
|
969
|
+
<div id="summary" class="stack"></div>
|
|
970
|
+
</div>
|
|
971
|
+
<div class="panel" id="profilePanel">
|
|
972
|
+
<h3>Inherited Profiles</h3>
|
|
973
|
+
<div id="profileChecks"></div>
|
|
974
|
+
</div>
|
|
975
|
+
<div class="panel">
|
|
976
|
+
<h3>Diagnostics</h3>
|
|
977
|
+
<div id="diagnostics" class="stack"></div>
|
|
978
|
+
</div>
|
|
979
|
+
</div>
|
|
980
|
+
</section>
|
|
981
|
+
</div>
|
|
982
|
+
</div>
|
|
983
|
+
<script>
|
|
984
|
+
const state = {
|
|
985
|
+
config: null,
|
|
986
|
+
tools: [],
|
|
987
|
+
errors: [],
|
|
988
|
+
activeKind: 'agent',
|
|
989
|
+
activeId: '',
|
|
990
|
+
drafts: {},
|
|
991
|
+
descriptionExpanded: {},
|
|
992
|
+
search: '',
|
|
993
|
+
provider: 'all',
|
|
994
|
+
decision: 'all'
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const el = (id) => document.getElementById(id);
|
|
998
|
+
const keyFor = (kind, id) => kind + ':' + id;
|
|
999
|
+
|
|
1000
|
+
async function api(path, options) {
|
|
1001
|
+
const response = await fetch(path, {
|
|
1002
|
+
headers: { 'content-type': 'application/json' },
|
|
1003
|
+
...options
|
|
1004
|
+
});
|
|
1005
|
+
const body = await response.json();
|
|
1006
|
+
if (!response.ok) throw new Error(body.error || 'Request failed');
|
|
1007
|
+
return body;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function loadState() {
|
|
1011
|
+
state.config = await api('/api/state');
|
|
1012
|
+
el('configPath').textContent = state.config.configPath;
|
|
1013
|
+
if (!state.activeId) {
|
|
1014
|
+
const firstAgent = Object.keys(state.config.agents)[0];
|
|
1015
|
+
const firstProfile = Object.keys(state.config.profiles)[0];
|
|
1016
|
+
if (firstAgent) setActive('agent', firstAgent);
|
|
1017
|
+
else if (firstProfile) setActive('profile', firstProfile);
|
|
1018
|
+
}
|
|
1019
|
+
hydrateDrafts();
|
|
1020
|
+
render();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function refreshTools() {
|
|
1024
|
+
el('refreshTools').disabled = true;
|
|
1025
|
+
el('refreshTools').textContent = 'Refreshing...';
|
|
1026
|
+
try {
|
|
1027
|
+
const result = await api('/api/tools');
|
|
1028
|
+
state.tools = result.tools || [];
|
|
1029
|
+
state.errors = result.errors || [];
|
|
1030
|
+
render();
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
alert(error.message);
|
|
1033
|
+
} finally {
|
|
1034
|
+
el('refreshTools').disabled = false;
|
|
1035
|
+
el('refreshTools').textContent = 'Refresh Endpoints';
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function hydrateDrafts() {
|
|
1040
|
+
for (const [id, agent] of Object.entries(state.config.agents)) {
|
|
1041
|
+
const key = keyFor('agent', id);
|
|
1042
|
+
if (!state.drafts[key]) state.drafts[key] = clone(agent);
|
|
1043
|
+
}
|
|
1044
|
+
for (const [id, profile] of Object.entries(state.config.profiles)) {
|
|
1045
|
+
const key = keyFor('profile', id);
|
|
1046
|
+
if (!state.drafts[key]) state.drafts[key] = clone(profile);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function clone(value) {
|
|
1051
|
+
return JSON.parse(JSON.stringify(value));
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function currentDraft() {
|
|
1055
|
+
if (state.activeKind === 'providers') return null;
|
|
1056
|
+
if (!state.activeId) return null;
|
|
1057
|
+
return state.drafts[keyFor(state.activeKind, state.activeId)];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function setActive(kind, id) {
|
|
1061
|
+
state.activeKind = kind;
|
|
1062
|
+
state.activeId = id || '';
|
|
1063
|
+
hydrateDrafts();
|
|
1064
|
+
render();
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function render() {
|
|
1068
|
+
hydrateDrafts();
|
|
1069
|
+
renderProviderNav();
|
|
1070
|
+
renderEntities();
|
|
1071
|
+
renderProviderFilter();
|
|
1072
|
+
renderDetails();
|
|
1073
|
+
renderTools();
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function renderProviderNav() {
|
|
1077
|
+
el('manageProviders').classList.toggle('active', state.activeKind === 'providers');
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function renderProvidersManager() {
|
|
1081
|
+
el('tools').innerHTML =
|
|
1082
|
+
'<div class="provider-list">' +
|
|
1083
|
+
Object.entries(state.config.providers).map(([id, provider]) => providerRow(id, provider)).join('') +
|
|
1084
|
+
'</div>';
|
|
1085
|
+
document.querySelectorAll('[data-provider-toggle]').forEach((button) => {
|
|
1086
|
+
button.addEventListener('click', () => toggleProvider(button.dataset.provider).catch((error) => alert(error.message)));
|
|
1087
|
+
});
|
|
1088
|
+
document.querySelectorAll('[data-provider-edit]').forEach((button) => {
|
|
1089
|
+
button.addEventListener('click', () => editProvider(button.dataset.provider).catch((error) => alert(error.message)));
|
|
1090
|
+
});
|
|
1091
|
+
document.querySelectorAll('[data-provider-delete]').forEach((button) => {
|
|
1092
|
+
button.addEventListener('click', () => deleteProvider(button.dataset.provider).catch((error) => alert(error.message)));
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function providerRow(id, provider) {
|
|
1097
|
+
const disabled = provider.enabled ? '' : ' disabled';
|
|
1098
|
+
const toggleLabel = provider.enabled ? 'Disable' : 'Enable';
|
|
1099
|
+
const statusClass = provider.enabled ? 'provider-status-enabled' : 'provider-status-disabled';
|
|
1100
|
+
return '<div class="provider-item' + disabled + '">' +
|
|
1101
|
+
'<div class="provider-name">' + escapeHtml(id) + '<span>' + escapeHtml(provider.type) + '</span></div>' +
|
|
1102
|
+
'<span class="pill ' + statusClass + '">' + (provider.enabled ? 'enabled' : 'disabled') + '</span>' +
|
|
1103
|
+
'<button data-provider-toggle data-provider="' + escapeHtml(id) + '">' + toggleLabel + '</button>' +
|
|
1104
|
+
'<button data-provider-edit data-provider="' + escapeHtml(id) + '">Edit</button>' +
|
|
1105
|
+
'<button data-provider-delete data-provider="' + escapeHtml(id) + '">Del</button>' +
|
|
1106
|
+
'</div>';
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function renderEntities() {
|
|
1110
|
+
el('agents').innerHTML = Object.keys(state.config.agents).map((id) => entityButton('agent', id)).join('');
|
|
1111
|
+
el('profiles').innerHTML = Object.keys(state.config.profiles).map((id) => entityButton('profile', id)).join('');
|
|
1112
|
+
document.querySelectorAll('[data-entity]').forEach((button) => {
|
|
1113
|
+
button.addEventListener('click', () => setActive(button.dataset.kind, button.dataset.id));
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function entityButton(kind, id) {
|
|
1118
|
+
const active = state.activeKind === kind && state.activeId === id ? ' active' : '';
|
|
1119
|
+
return '<button class="entity' + active + '" data-entity data-kind="' + kind + '" data-id="' + escapeHtml(id) + '">' + escapeHtml(id) + '</button>';
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function renderProviderFilter() {
|
|
1123
|
+
const providers = Array.from(new Set(state.tools.map((tool) => tool.provider))).sort();
|
|
1124
|
+
el('providerFilter').innerHTML = '<option value="all">All providers</option>' + providers.map((provider) => '<option value="' + escapeHtml(provider) + '">' + escapeHtml(provider) + '</option>').join('');
|
|
1125
|
+
el('providerFilter').value = state.provider;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function renderDetails() {
|
|
1129
|
+
const draft = currentDraft();
|
|
1130
|
+
el('activeTitle').textContent = state.activeKind === 'providers' ? 'Providers' : state.activeId || 'Select an agent or profile';
|
|
1131
|
+
el('activeMeta').textContent =
|
|
1132
|
+
state.activeKind === 'providers'
|
|
1133
|
+
? 'Top-level tool sources'
|
|
1134
|
+
: state.activeKind === 'agent'
|
|
1135
|
+
? 'Agent allow/ask/deny policy'
|
|
1136
|
+
: 'Reusable profile allow/ask/deny policy';
|
|
1137
|
+
el('deleteEntity').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
1138
|
+
el('addProvider').style.display = state.activeKind === 'providers' ? 'inline-flex' : 'none';
|
|
1139
|
+
el('deleteEntity').disabled = !state.activeId;
|
|
1140
|
+
el('profilePanel').style.display = state.activeKind === 'agent' ? 'block' : 'none';
|
|
1141
|
+
|
|
1142
|
+
if (!draft) {
|
|
1143
|
+
if (state.activeKind === 'providers') {
|
|
1144
|
+
const providers = Object.values(state.config.providers);
|
|
1145
|
+
const enabled = providers.filter((provider) => provider.enabled).length;
|
|
1146
|
+
el('summary').innerHTML =
|
|
1147
|
+
'<span class="pill">enabled ' + enabled + '</span>' +
|
|
1148
|
+
'<span class="pill">disabled ' + (providers.length - enabled) + '</span>' +
|
|
1149
|
+
'<span class="pill">total ' + providers.length + '</span>';
|
|
1150
|
+
} else {
|
|
1151
|
+
el('summary').innerHTML = '<div class="empty">Nothing selected.</div>';
|
|
1152
|
+
}
|
|
1153
|
+
renderDiagnostics();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const denyCount = draft.deny.length;
|
|
1158
|
+
el('summary').innerHTML =
|
|
1159
|
+
'<span class="pill">allow ' + draft.allow.length + '</span>' +
|
|
1160
|
+
'<span class="pill">ask ' + draft.ask.length + '</span>' +
|
|
1161
|
+
'<span class="pill">deny ' + denyCount + '</span>';
|
|
1162
|
+
|
|
1163
|
+
const profiles = Object.keys(state.config.profiles);
|
|
1164
|
+
el('profileChecks').innerHTML = profiles.length
|
|
1165
|
+
? profiles.map((id) => {
|
|
1166
|
+
const checked = (draft.extends || []).includes(id) ? ' checked' : '';
|
|
1167
|
+
return '<label class="check"><input type="checkbox" data-profile="' + escapeHtml(id) + '"' + checked + '> ' + escapeHtml(id) + '</label>';
|
|
1168
|
+
}).join('')
|
|
1169
|
+
: '<div class="subtle">No profiles yet.</div>';
|
|
1170
|
+
document.querySelectorAll('[data-profile]').forEach((box) => {
|
|
1171
|
+
box.addEventListener('change', () => {
|
|
1172
|
+
const next = new Set(draft.extends || []);
|
|
1173
|
+
if (box.checked) next.add(box.dataset.profile);
|
|
1174
|
+
else next.delete(box.dataset.profile);
|
|
1175
|
+
draft.extends = Array.from(next);
|
|
1176
|
+
renderDetails();
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
renderDiagnostics();
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function renderDiagnostics() {
|
|
1184
|
+
const diagnostics = [...(state.config.diagnostics || [])];
|
|
1185
|
+
for (const error of state.errors) diagnostics.push({ level: 'warn', message: error });
|
|
1186
|
+
el('diagnostics').innerHTML = diagnostics.length
|
|
1187
|
+
? diagnostics.map((d) => '<div class="diagnostic ' + escapeHtml(d.level) + '">' + escapeHtml((d.agent ? '[' + d.agent + '] ' : '') + d.message) + '</div>').join('')
|
|
1188
|
+
: '<div class="subtle">No diagnostics.</div>';
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function renderTools() {
|
|
1192
|
+
el('search').disabled = state.activeKind === 'providers';
|
|
1193
|
+
el('providerFilter').disabled = state.activeKind === 'providers';
|
|
1194
|
+
el('decisionFilter').disabled = state.activeKind === 'providers';
|
|
1195
|
+
el('bulkAllow').style.display = state.activeKind === 'providers' ? 'none' : 'inline-flex';
|
|
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';
|
|
1202
|
+
if (state.activeKind === 'providers') {
|
|
1203
|
+
renderProvidersManager();
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
const draft = currentDraft();
|
|
1207
|
+
if (!draft) {
|
|
1208
|
+
el('tools').innerHTML = '<div class="empty">Create or select an agent/profile to begin.</div>';
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (state.tools.length === 0) {
|
|
1212
|
+
el('tools').innerHTML = '<div class="empty">Refresh endpoints to load configured tools.</div>';
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const visible = filteredTools();
|
|
1217
|
+
el('tools').innerHTML = visible.length
|
|
1218
|
+
? visible.map((tool) => toolRow(tool, getDecision(draft, tool.name))).join('')
|
|
1219
|
+
: '<div class="empty">No tools match the current filters.</div>';
|
|
1220
|
+
|
|
1221
|
+
document.querySelectorAll('[data-rule]').forEach((button) => {
|
|
1222
|
+
button.addEventListener('click', () => {
|
|
1223
|
+
setDecision(draft, button.dataset.tool, button.dataset.decision);
|
|
1224
|
+
render();
|
|
1225
|
+
});
|
|
1226
|
+
});
|
|
1227
|
+
document.querySelectorAll('[data-description-toggle]').forEach((button) => {
|
|
1228
|
+
button.addEventListener('click', () => {
|
|
1229
|
+
state.descriptionExpanded[button.dataset.tool] = !state.descriptionExpanded[button.dataset.tool];
|
|
1230
|
+
renderTools();
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function filteredTools() {
|
|
1236
|
+
const draft = currentDraft();
|
|
1237
|
+
const query = state.search.trim().toLowerCase();
|
|
1238
|
+
return state.tools.filter((tool) => {
|
|
1239
|
+
const text = (tool.name + ' ' + (tool.description || '') + ' ' + (tool.tags || []).join(' ')).toLowerCase();
|
|
1240
|
+
if (query && !text.includes(query)) return false;
|
|
1241
|
+
if (state.provider !== 'all' && tool.provider !== state.provider) return false;
|
|
1242
|
+
if (state.decision !== 'all' && getDecision(draft, tool.name) !== state.decision) return false;
|
|
1243
|
+
return true;
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function toolRow(tool, decision) {
|
|
1248
|
+
const tags = tool.tags && tool.tags.length ? tool.tags : ['untagged'];
|
|
1249
|
+
const tagHtml = tags.map((tag) => '<span class="tag ' + tagClass(tag) + '">' + escapeHtml(tag) + '</span>').join('');
|
|
1250
|
+
const recommended = '<span class="tag tag-recommended ' + recommendedClass(tool.recommended) + '">recommended ' + escapeHtml(tool.recommended) + '</span>';
|
|
1251
|
+
const description = tool.description || 'No description';
|
|
1252
|
+
const expandable = descriptionIsExpandable(description);
|
|
1253
|
+
const expanded = Boolean(state.descriptionExpanded[tool.name]);
|
|
1254
|
+
const descriptionClass = expandable && !expanded ? ' collapsed' : '';
|
|
1255
|
+
const toggle = expandable
|
|
1256
|
+
? '<button class="description-toggle" data-description-toggle data-tool="' + escapeHtml(tool.name) + '">' + (expanded ? 'Show less' : 'Show more') + '</button>'
|
|
1257
|
+
: '';
|
|
1258
|
+
return '<div class="tool-row">' +
|
|
1259
|
+
'<div class="tool-name"><span class="provider">' + escapeHtml(tool.provider) + '</span><br>' + escapeHtml(tool.shortName || tool.name) + '</div>' +
|
|
1260
|
+
'<div class="description"><div class="description-content' + descriptionClass + '">' + formatDescription(description) + '</div>' + toggle + '<div class="tag-list">' + tagHtml + recommended + '</div></div>' +
|
|
1261
|
+
'<div class="rule-actions">' +
|
|
1262
|
+
ruleButton(tool.name, 'allow', decision) +
|
|
1263
|
+
ruleButton(tool.name, 'ask', decision) +
|
|
1264
|
+
ruleButton(tool.name, 'deny', decision) +
|
|
1265
|
+
ruleButton(tool.name, 'unset', decision) +
|
|
1266
|
+
'</div>' +
|
|
1267
|
+
'</div>';
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function descriptionIsExpandable(description) {
|
|
1271
|
+
return description.length > 260 || description.split(/\\r?\\n/).length > 3;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function formatDescription(description) {
|
|
1275
|
+
const text = String(description || '').trim();
|
|
1276
|
+
if (!text) return '<p>No description</p>';
|
|
1277
|
+
const normalized = normalizeDescription(text);
|
|
1278
|
+
return normalized.split(/\\n+/).map((line) => line.trim()).filter(Boolean).map(formatDescriptionLine).join('');
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function normalizeDescription(text) {
|
|
1282
|
+
return text
|
|
1283
|
+
.replace(/\\r\\n/g, '\\n')
|
|
1284
|
+
.replace(/[ \\t]+\\n/g, '\\n')
|
|
1285
|
+
.replace(/\\n[ \\t]+/g, '\\n')
|
|
1286
|
+
.replace(/\\s+(#{1,4}\\s+)/g, '\\n$1')
|
|
1287
|
+
.replace(/(#{1,4}\\s+[A-Za-z][^#\\n]{0,80}?)\\s+(\\d+\\.\\s+)/g, '$1\\n$2')
|
|
1288
|
+
.replace(/([.!?])\\s+(\\d+\\.\\s+)/g, '$1\\n$2');
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function formatDescriptionLine(line) {
|
|
1292
|
+
const heading = line.match(/^#{1,4}\\s+(.+)$/);
|
|
1293
|
+
if (heading) return '<h4>' + formatInline(heading[1]) + '</h4>';
|
|
1294
|
+
const step = line.match(/^\\d+\\.\\s+(.+)$/);
|
|
1295
|
+
if (step) return '<p class="description-step">' + formatInline(line) + '</p>';
|
|
1296
|
+
const bullet = line.match(/^[-*]\\s+(.+)$/);
|
|
1297
|
+
if (bullet) return '<p class="description-step">' + formatInline(line) + '</p>';
|
|
1298
|
+
return '<p>' + formatInline(line) + '</p>';
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function formatInline(value) {
|
|
1302
|
+
return escapeHtml(value)
|
|
1303
|
+
.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')
|
|
1304
|
+
.replace(/\\x60([^\\x60]+)\\x60/g, '<code>$1</code>');
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function tagClass(tag) {
|
|
1308
|
+
if (tag === 'destructive' || tag === 'injection') return 'tag-danger';
|
|
1309
|
+
if (tag === 'open-world') return 'tag-warn';
|
|
1310
|
+
if (tag === 'readonly' || tag === 'idempotent') return 'tag-good';
|
|
1311
|
+
return '';
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function recommendedClass(decision) {
|
|
1315
|
+
if (decision === 'deny') return 'tag-danger';
|
|
1316
|
+
if (decision === 'ask') return 'tag-warn';
|
|
1317
|
+
return 'tag-good';
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function ruleButton(tool, decision, activeDecision) {
|
|
1321
|
+
const active = decision === activeDecision ? ' active' : '';
|
|
1322
|
+
const label = decision === 'unset' ? 'Clear' : decision;
|
|
1323
|
+
return '<button class="' + active + '" data-rule data-tool="' + escapeHtml(tool) + '" data-decision="' + decision + '">' + label + '</button>';
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function getDecision(draft, tool) {
|
|
1327
|
+
if (draft.deny.includes(tool)) return 'deny';
|
|
1328
|
+
if (draft.ask.includes(tool)) return 'ask';
|
|
1329
|
+
if (draft.allow.includes(tool)) return 'allow';
|
|
1330
|
+
return 'unset';
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function setDecision(draft, tool, decision) {
|
|
1334
|
+
draft.allow = draft.allow.filter((item) => item !== tool);
|
|
1335
|
+
draft.ask = draft.ask.filter((item) => item !== tool);
|
|
1336
|
+
draft.deny = draft.deny.filter((item) => item !== tool);
|
|
1337
|
+
if (decision === 'allow') draft.allow.push(tool);
|
|
1338
|
+
if (decision === 'ask') draft.ask.push(tool);
|
|
1339
|
+
if (decision === 'deny') draft.deny.push(tool);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function setVisible(decision) {
|
|
1343
|
+
const draft = currentDraft();
|
|
1344
|
+
if (!draft) return;
|
|
1345
|
+
for (const tool of filteredTools()) {
|
|
1346
|
+
setDecision(draft, tool.name, decision);
|
|
1347
|
+
}
|
|
1348
|
+
render();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function resetVisibleToCurrentConfig() {
|
|
1352
|
+
const draft = currentDraft();
|
|
1353
|
+
if (!draft) return;
|
|
1354
|
+
if (state.activeKind === 'providers' || !state.activeId) return;
|
|
1355
|
+
const section = state.activeKind === 'agent' ? state.config.agents : state.config.profiles;
|
|
1356
|
+
const source = section[state.activeId];
|
|
1357
|
+
if (!source) return;
|
|
1358
|
+
for (const tool of filteredTools()) {
|
|
1359
|
+
setDecision(draft, tool.name, getDecision(source, tool.name));
|
|
1360
|
+
}
|
|
1361
|
+
render();
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function resetAllToCurrentConfig() {
|
|
1365
|
+
if (state.activeKind === 'providers' || !state.activeId) return;
|
|
1366
|
+
const section = state.activeKind === 'agent' ? state.config.agents : state.config.profiles;
|
|
1367
|
+
const source = section[state.activeId];
|
|
1368
|
+
if (!source) return;
|
|
1369
|
+
state.drafts[keyFor(state.activeKind, state.activeId)] = clone(source);
|
|
1370
|
+
render();
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function setRecommended() {
|
|
1374
|
+
const draft = currentDraft();
|
|
1375
|
+
if (!draft) return;
|
|
1376
|
+
for (const tool of filteredTools()) {
|
|
1377
|
+
setDecision(draft, tool.name, tool.recommended);
|
|
1378
|
+
}
|
|
1379
|
+
render();
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
async function saveCurrent() {
|
|
1383
|
+
const draft = currentDraft();
|
|
1384
|
+
if (!draft) return;
|
|
1385
|
+
const body = {
|
|
1386
|
+
kind: state.activeKind,
|
|
1387
|
+
id: state.activeId,
|
|
1388
|
+
extends: draft.extends || [],
|
|
1389
|
+
allow: draft.allow,
|
|
1390
|
+
ask: draft.ask,
|
|
1391
|
+
deny: draft.deny
|
|
1392
|
+
};
|
|
1393
|
+
state.config = await api('/api/rules', { method: 'POST', body: JSON.stringify(body) });
|
|
1394
|
+
state.drafts = {};
|
|
1395
|
+
hydrateDrafts();
|
|
1396
|
+
render();
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
async function addEntity(kind) {
|
|
1400
|
+
const id = prompt('New ' + kind + ' id');
|
|
1401
|
+
if (!id) return;
|
|
1402
|
+
state.config = await api('/api/entities', { method: 'POST', body: JSON.stringify({ kind, id }) });
|
|
1403
|
+
state.drafts = {};
|
|
1404
|
+
setActive(kind, id.trim());
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
async function addProvider() {
|
|
1408
|
+
const id = prompt('New provider id');
|
|
1409
|
+
if (!id) return;
|
|
1410
|
+
const type = prompt('Provider type: builtin, stdio, sse, or http', 'stdio');
|
|
1411
|
+
if (!type) return;
|
|
1412
|
+
const body = { id: id.trim(), type: type.trim(), enabled: true };
|
|
1413
|
+
await fillProviderFields(body);
|
|
1414
|
+
state.config = await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
|
|
1415
|
+
await refreshTools();
|
|
1416
|
+
render();
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
async function editProvider(id) {
|
|
1420
|
+
const provider = state.config.providers[id];
|
|
1421
|
+
if (!provider) return;
|
|
1422
|
+
const body = { id, type: provider.type, enabled: provider.enabled };
|
|
1423
|
+
await fillProviderFields(body, provider);
|
|
1424
|
+
state.config = await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
|
|
1425
|
+
await refreshTools();
|
|
1426
|
+
render();
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
async function toggleProvider(id) {
|
|
1430
|
+
const provider = state.config.providers[id];
|
|
1431
|
+
if (!provider) return;
|
|
1432
|
+
const body = { ...provider, id, enabled: !provider.enabled };
|
|
1433
|
+
state.config = await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
|
|
1434
|
+
await refreshTools();
|
|
1435
|
+
render();
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async function deleteProvider(id) {
|
|
1439
|
+
if (!confirm('Delete provider "' + id + '"?')) return;
|
|
1440
|
+
state.config = await api('/api/providers/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
1441
|
+
await refreshTools();
|
|
1442
|
+
render();
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
async function fillProviderFields(body, provider) {
|
|
1446
|
+
if (body.type === 'builtin') return;
|
|
1447
|
+
if (body.type === 'stdio') {
|
|
1448
|
+
const command = prompt('Command', provider?.command || '');
|
|
1449
|
+
if (command === null) throw new Error('Cancelled');
|
|
1450
|
+
const args = prompt('Args, one per line', (provider?.args || []).join('\\n'));
|
|
1451
|
+
if (args === null) throw new Error('Cancelled');
|
|
1452
|
+
body.command = command;
|
|
1453
|
+
body.args = args.split(/\\r?\\n/).map((item) => item.trim()).filter(Boolean);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
if (body.type === 'sse' || body.type === 'http') {
|
|
1457
|
+
const url = prompt('URL', provider?.url || '');
|
|
1458
|
+
if (url === null) throw new Error('Cancelled');
|
|
1459
|
+
body.url = url;
|
|
1460
|
+
if (body.type === 'http') {
|
|
1461
|
+
body.oauth = confirm('Enable OAuth for this HTTP provider?');
|
|
1462
|
+
}
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
throw new Error('Provider type must be builtin, stdio, sse, or http');
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async function deleteCurrent() {
|
|
1469
|
+
if (!state.activeId) return;
|
|
1470
|
+
if (!confirm('Delete ' + state.activeKind + ' "' + state.activeId + '"?')) return;
|
|
1471
|
+
state.config = await api('/api/entities/' + state.activeKind + '/' + encodeURIComponent(state.activeId), { method: 'DELETE' });
|
|
1472
|
+
state.drafts = {};
|
|
1473
|
+
state.activeId = '';
|
|
1474
|
+
await loadState();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function escapeHtml(value) {
|
|
1478
|
+
return String(value).replace(/[&<>"']/g, (char) => ({
|
|
1479
|
+
'&': '&',
|
|
1480
|
+
'<': '<',
|
|
1481
|
+
'>': '>',
|
|
1482
|
+
'"': '"',
|
|
1483
|
+
"'": '''
|
|
1484
|
+
}[char]));
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
el('refreshTools').addEventListener('click', refreshTools);
|
|
1488
|
+
el('saveRules').addEventListener('click', () => saveCurrent().catch((error) => alert(error.message)));
|
|
1489
|
+
el('manageProviders').addEventListener('click', () => setActive('providers'));
|
|
1490
|
+
el('addProvider').addEventListener('click', () => addProvider().catch((error) => alert(error.message)));
|
|
1491
|
+
el('addAgent').addEventListener('click', () => addEntity('agent').catch((error) => alert(error.message)));
|
|
1492
|
+
el('addProfile').addEventListener('click', () => addEntity('profile').catch((error) => alert(error.message)));
|
|
1493
|
+
el('deleteEntity').addEventListener('click', () => deleteCurrent().catch((error) => alert(error.message)));
|
|
1494
|
+
el('search').addEventListener('input', (event) => { state.search = event.target.value; renderTools(); });
|
|
1495
|
+
el('providerFilter').addEventListener('change', (event) => { state.provider = event.target.value; renderTools(); });
|
|
1496
|
+
el('decisionFilter').addEventListener('change', (event) => { state.decision = event.target.value; renderTools(); });
|
|
1497
|
+
el('bulkAllow').addEventListener('click', () => setVisible('allow'));
|
|
1498
|
+
el('bulkAsk').addEventListener('click', () => setVisible('ask'));
|
|
1499
|
+
el('bulkDeny').addEventListener('click', () => setVisible('deny'));
|
|
1500
|
+
el('bulkClear').addEventListener('click', () => setVisible('unset'));
|
|
1501
|
+
el('resetRules').addEventListener('click', resetVisibleToCurrentConfig);
|
|
1502
|
+
el('resetAllRules').addEventListener('click', resetAllToCurrentConfig);
|
|
1503
|
+
el('recommendedRules').addEventListener('click', setRecommended);
|
|
1504
|
+
|
|
1505
|
+
loadState().then(refreshTools).catch((error) => {
|
|
1506
|
+
document.body.innerHTML = '<div class="empty">' + escapeHtml(error.message) + '</div>';
|
|
1507
|
+
});
|
|
1508
|
+
</script>
|
|
1509
|
+
</body>
|
|
1510
|
+
</html>`;
|
|
1511
|
+
//# sourceMappingURL=cli.js.map
|