airlock-bot 0.2.36 → 0.2.38

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.
Files changed (243) hide show
  1. package/README.md +34 -7
  2. package/dist/activity/stream.d.ts +29 -0
  3. package/dist/activity/stream.d.ts.map +1 -0
  4. package/dist/activity/stream.js +36 -0
  5. package/dist/activity/stream.js.map +1 -0
  6. package/dist/airlock/context.d.ts +12 -0
  7. package/dist/airlock/context.d.ts.map +1 -0
  8. package/dist/airlock/context.js +32 -0
  9. package/dist/airlock/context.js.map +1 -0
  10. package/dist/airlock/tools.d.ts +7 -0
  11. package/dist/airlock/tools.d.ts.map +1 -0
  12. package/dist/airlock/tools.js +13 -0
  13. package/dist/airlock/tools.js.map +1 -0
  14. package/dist/allowlist/engine.d.ts +12 -0
  15. package/dist/allowlist/engine.d.ts.map +1 -1
  16. package/dist/allowlist/engine.js +40 -15
  17. package/dist/allowlist/engine.js.map +1 -1
  18. package/dist/audit/api.d.ts +2 -0
  19. package/dist/audit/api.d.ts.map +1 -1
  20. package/dist/audit/api.js.map +1 -1
  21. package/dist/audit/db.d.ts +16 -0
  22. package/dist/audit/db.d.ts.map +1 -1
  23. package/dist/audit/db.js +73 -0
  24. package/dist/audit/db.js.map +1 -1
  25. package/dist/audit/logger.d.ts +8 -1
  26. package/dist/audit/logger.d.ts.map +1 -1
  27. package/dist/audit/logger.js +21 -0
  28. package/dist/audit/logger.js.map +1 -1
  29. package/dist/backend/airlock-adapter.d.ts +22 -0
  30. package/dist/backend/airlock-adapter.d.ts.map +1 -0
  31. package/dist/backend/airlock-adapter.js +136 -0
  32. package/dist/backend/airlock-adapter.js.map +1 -0
  33. package/dist/backend/factory.d.ts +5 -1
  34. package/dist/backend/factory.d.ts.map +1 -1
  35. package/dist/backend/factory.js +5 -5
  36. package/dist/backend/factory.js.map +1 -1
  37. package/dist/backend/mcp-adapter.d.ts.map +1 -1
  38. package/dist/backend/mcp-adapter.js +28 -4
  39. package/dist/backend/mcp-adapter.js.map +1 -1
  40. package/dist/backend/openapi/adapter.d.ts.map +1 -1
  41. package/dist/backend/openapi/adapter.js +33 -13
  42. package/dist/backend/openapi/adapter.js.map +1 -1
  43. package/dist/config/loader.d.ts +12 -1
  44. package/dist/config/loader.d.ts.map +1 -1
  45. package/dist/config/loader.js +799 -23
  46. package/dist/config/loader.js.map +1 -1
  47. package/dist/config/profiles.d.ts +46 -8
  48. package/dist/config/profiles.d.ts.map +1 -1
  49. package/dist/config/profiles.js +367 -23
  50. package/dist/config/profiles.js.map +1 -1
  51. package/dist/config/schema.d.ts +3542 -174
  52. package/dist/config/schema.d.ts.map +1 -1
  53. package/dist/config/schema.js +292 -63
  54. package/dist/config/schema.js.map +1 -1
  55. package/dist/configure-web/cli.d.ts +13 -0
  56. package/dist/configure-web/cli.d.ts.map +1 -1
  57. package/dist/configure-web/cli.js +570 -80
  58. package/dist/configure-web/cli.js.map +1 -1
  59. package/dist/gateway.d.ts +16 -1
  60. package/dist/gateway.d.ts.map +1 -1
  61. package/dist/gateway.js +263 -57
  62. package/dist/gateway.js.map +1 -1
  63. package/dist/hitl/api.d.ts +2 -0
  64. package/dist/hitl/api.d.ts.map +1 -1
  65. package/dist/hitl/api.js +8 -0
  66. package/dist/hitl/api.js.map +1 -1
  67. package/dist/hitl/approval-dashboard.d.ts +5 -9
  68. package/dist/hitl/approval-dashboard.d.ts.map +1 -1
  69. package/dist/hitl/approval-dashboard.js +8 -55
  70. package/dist/hitl/approval-dashboard.js.map +1 -1
  71. package/dist/hitl/approval-stream.d.ts +34 -0
  72. package/dist/hitl/approval-stream.d.ts.map +1 -0
  73. package/dist/hitl/approval-stream.js +111 -0
  74. package/dist/hitl/approval-stream.js.map +1 -0
  75. package/dist/hitl/engine.d.ts +16 -7
  76. package/dist/hitl/engine.d.ts.map +1 -1
  77. package/dist/hitl/engine.js +109 -35
  78. package/dist/hitl/engine.js.map +1 -1
  79. package/dist/hitl/formatter.d.ts.map +1 -1
  80. package/dist/hitl/formatter.js +2 -0
  81. package/dist/hitl/formatter.js.map +1 -1
  82. package/dist/hitl/provider-factory.d.ts +8 -2
  83. package/dist/hitl/provider-factory.d.ts.map +1 -1
  84. package/dist/hitl/provider-factory.js +23 -2
  85. package/dist/hitl/provider-factory.js.map +1 -1
  86. package/dist/hitl/providers/composite.d.ts +9 -0
  87. package/dist/hitl/providers/composite.d.ts.map +1 -1
  88. package/dist/hitl/providers/composite.js +14 -0
  89. package/dist/hitl/providers/composite.js.map +1 -1
  90. package/dist/hitl/providers/dashboard.d.ts +12 -2
  91. package/dist/hitl/providers/dashboard.d.ts.map +1 -1
  92. package/dist/hitl/providers/dashboard.js +11 -5
  93. package/dist/hitl/providers/dashboard.js.map +1 -1
  94. package/dist/hitl/providers/ios.d.ts +23 -0
  95. package/dist/hitl/providers/ios.d.ts.map +1 -0
  96. package/dist/hitl/providers/ios.js +183 -0
  97. package/dist/hitl/providers/ios.js.map +1 -0
  98. package/dist/hitl/providers/macos.d.ts.map +1 -1
  99. package/dist/hitl/providers/macos.js +8 -4
  100. package/dist/hitl/providers/macos.js.map +1 -1
  101. package/dist/hitl/providers/openclaw.js +2 -2
  102. package/dist/hitl/providers/openclaw.js.map +1 -1
  103. package/dist/hitl/providers/stdio.js +2 -2
  104. package/dist/hitl/providers/stdio.js.map +1 -1
  105. package/dist/hitl/providers/telegram.js +2 -2
  106. package/dist/hitl/providers/telegram.js.map +1 -1
  107. package/dist/hitl/providers/tui.d.ts.map +1 -1
  108. package/dist/hitl/providers/tui.js +8 -2
  109. package/dist/hitl/providers/tui.js.map +1 -1
  110. package/dist/hitl/providers/types.d.ts +14 -0
  111. package/dist/hitl/providers/types.d.ts.map +1 -1
  112. package/dist/hook/api.d.ts +2 -0
  113. package/dist/hook/api.d.ts.map +1 -1
  114. package/dist/hook/api.js.map +1 -1
  115. package/dist/hook/normalizer.js +2 -2
  116. package/dist/hook/normalizer.js.map +1 -1
  117. package/dist/index.js +28 -1
  118. package/dist/index.js.map +1 -1
  119. package/dist/introspect/cli.d.ts +31 -0
  120. package/dist/introspect/cli.d.ts.map +1 -0
  121. package/dist/introspect/cli.js +756 -0
  122. package/dist/introspect/cli.js.map +1 -0
  123. package/dist/middleware/chain-builder.d.ts +1 -1
  124. package/dist/middleware/chain-builder.d.ts.map +1 -1
  125. package/dist/middleware/chain-builder.js +8 -4
  126. package/dist/middleware/chain-builder.js.map +1 -1
  127. package/dist/middleware/core/allowlist.d.ts.map +1 -1
  128. package/dist/middleware/core/allowlist.js +14 -0
  129. package/dist/middleware/core/allowlist.js.map +1 -1
  130. package/dist/middleware/core/arg-policy.d.ts +13 -0
  131. package/dist/middleware/core/arg-policy.d.ts.map +1 -0
  132. package/dist/middleware/core/arg-policy.js +201 -0
  133. package/dist/middleware/core/arg-policy.js.map +1 -0
  134. package/dist/middleware/core/exec-policy.d.ts.map +1 -1
  135. package/dist/middleware/core/exec-policy.js +16 -1
  136. package/dist/middleware/core/exec-policy.js.map +1 -1
  137. package/dist/middleware/core/execute.d.ts.map +1 -1
  138. package/dist/middleware/core/execute.js +6 -2
  139. package/dist/middleware/core/execute.js.map +1 -1
  140. package/dist/middleware/core/hitl-gate.d.ts.map +1 -1
  141. package/dist/middleware/core/hitl-gate.js +15 -4
  142. package/dist/middleware/core/hitl-gate.js.map +1 -1
  143. package/dist/middleware/core/schema-validator.d.ts.map +1 -1
  144. package/dist/middleware/core/schema-validator.js +7 -3
  145. package/dist/middleware/core/schema-validator.js.map +1 -1
  146. package/dist/middleware/post/canary-token-injector.d.ts +4 -1
  147. package/dist/middleware/post/canary-token-injector.d.ts.map +1 -1
  148. package/dist/middleware/post/canary-token-injector.js +5 -1
  149. package/dist/middleware/post/canary-token-injector.js.map +1 -1
  150. package/dist/middleware/post/untrusted-envelope.d.ts.map +1 -1
  151. package/dist/middleware/post/untrusted-envelope.js +7 -1
  152. package/dist/middleware/post/untrusted-envelope.js.map +1 -1
  153. package/dist/mobile/api.d.ts +20 -0
  154. package/dist/mobile/api.d.ts.map +1 -0
  155. package/dist/mobile/api.js +255 -0
  156. package/dist/mobile/api.js.map +1 -0
  157. package/dist/mobile/apns.d.ts +68 -0
  158. package/dist/mobile/apns.d.ts.map +1 -0
  159. package/dist/mobile/apns.js +321 -0
  160. package/dist/mobile/apns.js.map +1 -0
  161. package/dist/pool/http-client.d.ts +3 -1
  162. package/dist/pool/http-client.d.ts.map +1 -1
  163. package/dist/pool/http-client.js +5 -2
  164. package/dist/pool/http-client.js.map +1 -1
  165. package/dist/pool/oauth-provider.d.ts +2 -0
  166. package/dist/pool/oauth-provider.d.ts.map +1 -1
  167. package/dist/pool/oauth-provider.js +23 -4
  168. package/dist/pool/oauth-provider.js.map +1 -1
  169. package/dist/pool/pool.d.ts +2 -1
  170. package/dist/pool/pool.d.ts.map +1 -1
  171. package/dist/pool/pool.js +2 -2
  172. package/dist/pool/pool.js.map +1 -1
  173. package/dist/pool/sse-client.d.ts +3 -1
  174. package/dist/pool/sse-client.d.ts.map +1 -1
  175. package/dist/pool/sse-client.js +5 -2
  176. package/dist/pool/sse-client.js.map +1 -1
  177. package/dist/pool/stdio-client.d.ts +3 -1
  178. package/dist/pool/stdio-client.d.ts.map +1 -1
  179. package/dist/pool/stdio-client.js +5 -2
  180. package/dist/pool/stdio-client.js.map +1 -1
  181. package/dist/registry/airlock-policy.d.ts +3 -0
  182. package/dist/registry/airlock-policy.d.ts.map +1 -0
  183. package/dist/registry/airlock-policy.js +50 -0
  184. package/dist/registry/airlock-policy.js.map +1 -0
  185. package/dist/registry/registry.d.ts +2 -0
  186. package/dist/registry/registry.d.ts.map +1 -1
  187. package/dist/registry/registry.js +19 -13
  188. package/dist/registry/registry.js.map +1 -1
  189. package/dist/security/blocked-hosts.d.ts +2 -0
  190. package/dist/security/blocked-hosts.d.ts.map +1 -1
  191. package/dist/security/blocked-hosts.js +61 -7
  192. package/dist/security/blocked-hosts.js.map +1 -1
  193. package/dist/security/request.d.ts +2 -0
  194. package/dist/security/request.d.ts.map +1 -1
  195. package/dist/security/request.js +6 -2
  196. package/dist/security/request.js.map +1 -1
  197. package/dist/security/secrets.d.ts +12 -0
  198. package/dist/security/secrets.d.ts.map +1 -0
  199. package/dist/security/secrets.js +138 -0
  200. package/dist/security/secrets.js.map +1 -0
  201. package/dist/setup-openclaw/cli.js +1 -1
  202. package/dist/stdio-mode.d.ts.map +1 -1
  203. package/dist/stdio-mode.js +125 -24
  204. package/dist/stdio-mode.js.map +1 -1
  205. package/dist/tools/api.d.ts +4 -1
  206. package/dist/tools/api.d.ts.map +1 -1
  207. package/dist/tools/api.js +58 -7
  208. package/dist/tools/api.js.map +1 -1
  209. package/dist/tools/exec.d.ts +2 -1
  210. package/dist/tools/exec.d.ts.map +1 -1
  211. package/dist/tools/exec.js +4 -3
  212. package/dist/tools/exec.js.map +1 -1
  213. package/dist/tools/http.d.ts.map +1 -1
  214. package/dist/tools/http.js +4 -7
  215. package/dist/tools/http.js.map +1 -1
  216. package/dist/transport/agent-server.d.ts +4 -0
  217. package/dist/transport/agent-server.d.ts.map +1 -1
  218. package/dist/transport/agent-server.js +23 -11
  219. package/dist/transport/agent-server.js.map +1 -1
  220. package/dist/transport/http-server.d.ts +2 -0
  221. package/dist/transport/http-server.d.ts.map +1 -1
  222. package/dist/transport/http-server.js +10 -6
  223. package/dist/transport/http-server.js.map +1 -1
  224. package/dist/transport/sse-server.d.ts +2 -0
  225. package/dist/transport/sse-server.d.ts.map +1 -1
  226. package/dist/transport/sse-server.js +10 -6
  227. package/dist/transport/sse-server.js.map +1 -1
  228. package/dist/transport/stdio-server.d.ts.map +1 -1
  229. package/dist/transport/stdio-server.js +4 -0
  230. package/dist/transport/stdio-server.js.map +1 -1
  231. package/dist/util/id.d.ts +1 -1
  232. package/dist/util/id.d.ts.map +1 -1
  233. package/dist/util/id.js +3 -3
  234. package/dist/util/id.js.map +1 -1
  235. package/examples/claude-code-setup.md +0 -2
  236. package/examples/docker-compose.yaml +4 -3
  237. package/examples/docker-gateway.yaml +8 -3
  238. package/examples/gateway.yaml +8 -5
  239. package/examples/profiles.yaml +4 -4
  240. package/extensions/openclaw/index.ts +9 -6
  241. package/extensions/openclaw/openclaw.plugin.json +5 -5
  242. package/package.json +3 -1
  243. package/schema.json +658 -8
@@ -1,4 +1,6 @@
1
+ import { randomBytes } from 'crypto';
1
2
  import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
3
+ import { isIP } from 'net';
2
4
  import { parseArgs } from 'util';
3
5
  import Fastify from 'fastify';
4
6
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
@@ -7,8 +9,10 @@ import { buildAdapters } from '../backend/factory.js';
7
9
  import { AuditConfig, GatewayConfig, McpServerConfig, ProviderConfig, getMcpConfigs, } from '../config/schema.js';
8
10
  import { rememberAllow } from '../config/mutator.js';
9
11
  import { validateConfig } from '../config/loader.js';
12
+ import { applyProfiles } from '../config/profiles.js';
10
13
  import { ClientPool } from '../pool/pool.js';
11
14
  import { checkSuspiciousPatterns } from '../registry/sanitizer.js';
15
+ import { bestSpecificity } from '../allowlist/pattern.js';
12
16
  import { VERSION } from '../version.js';
13
17
  const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1000;
14
18
  let latestVersionCache = null;
@@ -22,6 +26,8 @@ Options:
22
26
  -c, --config <path> Airlock config file (default: ./airlock.yaml)
23
27
  -p, --port <port> Web UI port (default: 4177)
24
28
  --host <host> Bind host (default: 127.0.0.1)
29
+ --insecure-remote-bind
30
+ Allow binding the unauthenticated UI beyond loopback
25
31
  -h, --help Show this help
26
32
  `;
27
33
  const RUN_HELP = `
@@ -34,6 +40,8 @@ Options:
34
40
  -c, --config <path> Airlock config file (default: ./airlock.yaml)
35
41
  -p, --port <port> Web UI port (default: 4177)
36
42
  --host <host> Bind host (default: 127.0.0.1)
43
+ --insecure-remote-bind
44
+ Allow binding the unauthenticated UI beyond loopback
37
45
  -h, --help Show this help
38
46
  `;
39
47
  const DASHBOARD_HELP = `
@@ -46,7 +54,8 @@ Options:
46
54
  -c, --config <path> Airlock config file (default: ./airlock.yaml)
47
55
  -p, --port <port> Web UI port (default: 4177)
48
56
  --host <host> Bind host (default: 127.0.0.1)
49
- --gateway-url <url> Gateway URL (default: http://127.0.0.1:4111)
57
+ --insecure-remote-bind Allow binding the unauthenticated dashboard beyond loopback
58
+ --gateway-url <url> Gateway management API URL (default: http://127.0.0.1:4113)
50
59
  --gateway-secret <secret> Gateway admin bearer token (default: AIRLOCK_GATEWAY_SECRET or AIRLOCK_API_SECRET)
51
60
  -h, --help Show this help
52
61
  `;
@@ -60,8 +69,9 @@ export async function runDashboard(argv) {
60
69
  config: { type: 'string', short: 'c', default: './airlock.yaml' },
61
70
  port: { type: 'string', short: 'p', default: '4177' },
62
71
  host: { type: 'string', default: '127.0.0.1' },
63
- 'gateway-url': { type: 'string', default: 'http://127.0.0.1:4111' },
72
+ 'gateway-url': { type: 'string', default: 'http://127.0.0.1:4113' },
64
73
  'gateway-secret': { type: 'string' },
74
+ 'insecure-remote-bind': { type: 'boolean', default: false },
65
75
  help: { type: 'boolean', short: 'h', default: false },
66
76
  },
67
77
  allowPositionals: false,
@@ -73,11 +83,14 @@ export async function runDashboard(argv) {
73
83
  const configPath = values.config ?? './airlock.yaml';
74
84
  const port = Number(values.port ?? '4177');
75
85
  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'];
86
+ const gatewayUrl = values['gateway-url'] ?? 'http://127.0.0.1:4113';
87
+ const gatewaySecret = values['gateway-secret'] ??
88
+ process.env['AIRLOCK_GATEWAY_SECRET'] ??
89
+ process.env['AIRLOCK_API_SECRET'];
78
90
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
79
91
  throw new Error(`Invalid --port: ${values.port}`);
80
92
  }
93
+ assertSafeWebBindHost(host, values['insecure-remote-bind'] === true);
81
94
  const app = createConfigureWebApp(configPath, {
82
95
  remoteGateway: { url: gatewayUrl, secret: gatewaySecret },
83
96
  });
@@ -93,6 +106,7 @@ export async function runConfigureWeb(argv, entrypoint = 'configure-web') {
93
106
  config: { type: 'string', short: 'c', default: './airlock.yaml' },
94
107
  port: { type: 'string', short: 'p', default: '4177' },
95
108
  host: { type: 'string', default: '127.0.0.1' },
109
+ 'insecure-remote-bind': { type: 'boolean', default: false },
96
110
  help: { type: 'boolean', short: 'h', default: false },
97
111
  },
98
112
  allowPositionals: false,
@@ -107,11 +121,28 @@ export async function runConfigureWeb(argv, entrypoint = 'configure-web') {
107
121
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
108
122
  throw new Error(`Invalid --port: ${values.port}`);
109
123
  }
124
+ assertSafeWebBindHost(host, values['insecure-remote-bind'] === true);
110
125
  const app = createConfigureWebApp(configPath);
111
126
  await app.listen({ host, port });
112
127
  console.log(`Airlock command center running at http://${host}:${port}`);
113
128
  console.log(`Editing ${configPath}`);
114
129
  }
130
+ export function assertSafeWebBindHost(host, insecureRemoteBind) {
131
+ if (insecureRemoteBind || isLoopbackBindHost(host))
132
+ return;
133
+ throw new Error('--host binds the unauthenticated Airlock web UI beyond loopback. Use --insecure-remote-bind only when the port is protected by a private network, tunnel, or authenticated reverse proxy.');
134
+ }
135
+ function isLoopbackBindHost(host) {
136
+ const normalized = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
137
+ if (normalized === 'localhost')
138
+ return true;
139
+ const ipVersion = isIP(normalized);
140
+ if (ipVersion === 6)
141
+ return normalized === '::1';
142
+ if (ipVersion !== 4)
143
+ return false;
144
+ return Number(normalized.split('.')[0]) === 127;
145
+ }
115
146
  export function createConfigureWebApp(configPath, options = {}) {
116
147
  const app = Fastify({ logger: false });
117
148
  app.get('/', async (_request, reply) => {
@@ -147,6 +178,18 @@ export function createConfigureWebApp(configPath, options = {}) {
147
178
  };
148
179
  }
149
180
  });
181
+ app.get('/api/activity', async (_request, reply) => {
182
+ try {
183
+ if (options.remoteGateway) {
184
+ return await remoteGatewayJson(options.remoteGateway, '/activity');
185
+ }
186
+ return { events: [] };
187
+ }
188
+ catch (err) {
189
+ reply.code(500);
190
+ return { events: [], error: err instanceof Error ? err.message : String(err) };
191
+ }
192
+ });
150
193
  app.get('/api/tools', async (_request, reply) => {
151
194
  try {
152
195
  if (options.remoteGateway) {
@@ -362,7 +405,7 @@ async function readRemoteCommandCenterStatus(configPath, remoteGateway) {
362
405
  id,
363
406
  type,
364
407
  enabled,
365
- status: enabled ? runtime ?? 'ok' : 'disabled',
408
+ status: enabled ? (runtime ?? 'ok') : 'disabled',
366
409
  toolCount: 0,
367
410
  toolFingerprint: '',
368
411
  ...(runtime === 'down' ? { error: 'Provider is down on the gateway' } : {}),
@@ -430,10 +473,11 @@ function registerRemoteGatewayRoutes(app, configPath, remoteGateway) {
430
473
  const reader = response.body.getReader();
431
474
  try {
432
475
  while (true) {
433
- const { done, value } = await reader.read();
434
- if (done)
476
+ const result = await reader.read();
477
+ if (result.done)
435
478
  break;
436
- reply.raw.write(Buffer.from(value));
479
+ if (result.value)
480
+ reply.raw.write(Buffer.from(result.value));
437
481
  }
438
482
  }
439
483
  catch {
@@ -477,7 +521,7 @@ async function rememberRemoteDecision(configPath, remoteGateway, code, remember,
477
521
  return { status: 400, error: 'Invalid remember mode' };
478
522
  }
479
523
  const pending = await remoteGatewayJson(remoteGateway, '/hitl/pending');
480
- const request = pending.find((entry) => entry.code === code || entry.id === code);
524
+ const request = pending.find((entry) => entry.code === code);
481
525
  if (!request)
482
526
  return { status: 404, error: 'No pending request found for code' };
483
527
  const durationMs = typeof durationMsValue === 'string' && durationMsValue.trim()
@@ -504,7 +548,8 @@ async function rememberRemoteDecision(configPath, remoteGateway, code, remember,
504
548
  }
505
549
  }
506
550
  async function forwardRemoteApproval(remoteGateway, action, code) {
507
- const response = await remoteGatewayFetch(remoteGateway, `/${action}?${new URLSearchParams({ code })}`, {
551
+ const params = new URLSearchParams({ code });
552
+ const response = await remoteGatewayFetch(remoteGateway, `/${action}?${params.toString()}`, {
508
553
  method: 'POST',
509
554
  });
510
555
  if (!response.ok)
@@ -564,6 +609,7 @@ export function saveRules(configPath, body) {
564
609
  existing.deny = parseStringArray(body.deny, 'deny');
565
610
  }
566
611
  else {
612
+ existing.extends = parseStringArray(body.extends, 'extends');
567
613
  existing.allow = parseStringArray(body.allow, 'allow');
568
614
  existing.ask = parseStringArray(body.ask, 'ask');
569
615
  existing.deny = parseStringArray(body.deny, 'deny');
@@ -584,15 +630,32 @@ export function createEntity(configPath, body) {
584
630
  const section = asMutableRecord(doc[sectionName]);
585
631
  if (section[id])
586
632
  throw new Error(`${kind} "${id}" already exists`);
587
- if (body.baseId && section[body.baseId]) {
588
- section[id] = structuredClone(section[body.baseId]);
633
+ const baseId = parseOptionalId(body.baseId);
634
+ const base = baseId && section[baseId] ? structuredClone(section[baseId]) : undefined;
635
+ const createdToken = kind === 'agent' ? createAgentToken() : undefined;
636
+ if (kind === 'agent') {
637
+ const agent = asMutableRecord(base);
638
+ const profileIds = parseOptionalStringArray(body.profileIds, 'profileIds');
639
+ delete agent.token;
640
+ agent.extends = profileIds ?? stringArray(agent.extends);
641
+ agent.allow = stringArray(agent.allow);
642
+ agent.ask = stringArray(agent.ask);
643
+ agent.deny = stringArray(agent.deny);
644
+ agent.token = createdToken;
645
+ section[id] = agent;
646
+ }
647
+ else if (base) {
648
+ section[id] = structuredClone(base);
589
649
  }
590
650
  else {
591
- section[id] = { allow: [], ask: [], deny: [] };
651
+ section[id] = { extends: [], allow: [], ask: [], deny: [] };
592
652
  }
593
653
  doc[sectionName] = section;
594
654
  writeValidatedConfig(configPath, doc);
595
- return readState(configPath);
655
+ return {
656
+ ...readState(configPath),
657
+ ...(createdToken ? { createdToken: { agentId: id, token: createdToken } } : {}),
658
+ };
596
659
  }
597
660
  export function deleteEntity(configPath, kind, id) {
598
661
  const parsedKind = parseKind(kind);
@@ -685,6 +748,44 @@ export function annotationTags(annotations, suspiciousPatterns = []) {
685
748
  tags.push('injection');
686
749
  return tags;
687
750
  }
751
+ export function resolveEditableRuleDecision(rules, toolName) {
752
+ return resolveEditableRuleMatch(rules, toolName).decision;
753
+ }
754
+ export function resolveEditableRuleMatch(rules, toolName) {
755
+ const deny = bestRuleMatch(rules.deny, toolName);
756
+ const ask = bestRuleMatch(rules.ask, toolName);
757
+ const allow = bestRuleMatch(rules.allow, toolName);
758
+ const denySpec = deny?.specificity ?? -1;
759
+ const askSpec = ask?.specificity ?? -1;
760
+ const allowSpec = allow?.specificity ?? -1;
761
+ const best = Math.max(denySpec, askSpec, allowSpec);
762
+ if (best < 0)
763
+ return { decision: 'unset', source: 'none' };
764
+ if (denySpec === best)
765
+ return editableRuleMatch('deny', deny, toolName);
766
+ if (askSpec === best)
767
+ return editableRuleMatch('ask', ask, toolName);
768
+ return editableRuleMatch('allow', allow, toolName);
769
+ }
770
+ function editableRuleMatch(decision, match, toolName) {
771
+ if (!match)
772
+ return { decision, source: 'none' };
773
+ return {
774
+ decision,
775
+ source: match.pattern === toolName ? 'exact' : 'pattern',
776
+ pattern: match.pattern,
777
+ };
778
+ }
779
+ function bestRuleMatch(patterns, toolName) {
780
+ let best;
781
+ for (const pattern of patterns) {
782
+ const specificity = bestSpecificity([pattern], toolName);
783
+ if (specificity >= 0 && (!best || specificity > best.specificity)) {
784
+ best = { pattern, specificity };
785
+ }
786
+ }
787
+ return best;
788
+ }
688
789
  export async function discoverTools(configPath) {
689
790
  const raw = readConfigObject(configPath);
690
791
  const parsed = GatewayConfig.parse(withoutDisabledProviders(raw));
@@ -820,6 +921,7 @@ function parseGatewayConfig(doc) {
820
921
  ],
821
922
  };
822
923
  }
924
+ applyProfiles(result.data);
823
925
  return { config: result.data, diagnostics: validateConfig(result.data) };
824
926
  }
825
927
  catch (err) {
@@ -838,6 +940,7 @@ function toEditableAgents(input) {
838
940
  allow: stringArray(agent.allow),
839
941
  ask: stringArray(agent.ask),
840
942
  deny: stringArray(agent.deny),
943
+ hasToken: typeof agent.token === 'string' && agent.token.trim().length > 0,
841
944
  },
842
945
  ];
843
946
  }));
@@ -868,6 +971,7 @@ function toEditableProfiles(input) {
868
971
  return [
869
972
  id,
870
973
  {
974
+ extends: stringArray(profile.extends),
871
975
  allow: stringArray(profile.allow),
872
976
  ask: stringArray(profile.ask),
873
977
  deny: stringArray(profile.deny),
@@ -895,6 +999,14 @@ function parseId(value) {
895
999
  }
896
1000
  return id;
897
1001
  }
1002
+ function parseOptionalId(value) {
1003
+ if (value === undefined || value === null || value === '')
1004
+ return undefined;
1005
+ return parseId(value);
1006
+ }
1007
+ function createAgentToken() {
1008
+ return `airlock_agent_${randomBytes(32).toString('base64url')}`;
1009
+ }
898
1010
  function parseBoolean(value) {
899
1011
  if (typeof value === 'boolean')
900
1012
  return value;
@@ -1236,6 +1348,17 @@ const INDEX_HTML = `<!doctype html>
1236
1348
  border-color: #f1d99a;
1237
1349
  background: #fffaf0;
1238
1350
  }
1351
+ .log-row.question {
1352
+ border-color: #c8d7ef;
1353
+ background: #f3f7ff;
1354
+ }
1355
+ .log-row.notification {
1356
+ border-color: #c8d7ef;
1357
+ background: #f7fbff;
1358
+ }
1359
+ .log-row.log {
1360
+ background: #fbfcfe;
1361
+ }
1239
1362
  .log-cell {
1240
1363
  min-width: 0;
1241
1364
  overflow-wrap: anywhere;
@@ -1479,6 +1602,25 @@ const INDEX_HTML = `<!doctype html>
1479
1602
  color: var(--red);
1480
1603
  font-weight: 700;
1481
1604
  }
1605
+ .rule-actions button.active.pattern-match {
1606
+ border-style: dashed;
1607
+ font-weight: 650;
1608
+ }
1609
+ .rule-actions button.active.pattern-match[data-decision="allow"] {
1610
+ border-color: #b8dfca;
1611
+ background: #f6fbf8;
1612
+ color: #4d7f63;
1613
+ }
1614
+ .rule-actions button.active.pattern-match[data-decision="ask"] {
1615
+ border-color: #f1d99a;
1616
+ background: #fffaf0;
1617
+ color: #8a6a19;
1618
+ }
1619
+ .rule-actions button.active.pattern-match[data-decision="deny"] {
1620
+ border-color: #f2c0bb;
1621
+ background: #fff8f7;
1622
+ color: #9b4a43;
1623
+ }
1482
1624
  .pill {
1483
1625
  display: inline-flex;
1484
1626
  align-items: center;
@@ -1571,6 +1713,43 @@ const INDEX_HTML = `<!doctype html>
1571
1713
  overflow: auto;
1572
1714
  padding: 14px;
1573
1715
  }
1716
+ .form-grid {
1717
+ display: grid;
1718
+ gap: 12px;
1719
+ }
1720
+ .field {
1721
+ display: grid;
1722
+ gap: 6px;
1723
+ }
1724
+ .field label {
1725
+ color: var(--muted);
1726
+ font-size: 12px;
1727
+ font-weight: 700;
1728
+ text-transform: uppercase;
1729
+ letter-spacing: 0.08em;
1730
+ }
1731
+ .field input, .field select {
1732
+ min-height: 36px;
1733
+ min-width: 0;
1734
+ border: 1px solid var(--line);
1735
+ border-radius: 7px;
1736
+ padding: 0 10px;
1737
+ background: #fff;
1738
+ color: var(--ink);
1739
+ }
1740
+ .profile-select-list {
1741
+ display: grid;
1742
+ gap: 6px;
1743
+ padding: 8px;
1744
+ border: 1px solid var(--line);
1745
+ border-radius: 7px;
1746
+ background: #fbfcfe;
1747
+ }
1748
+ .token-value {
1749
+ width: 100%;
1750
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1751
+ font-size: 12px;
1752
+ }
1574
1753
  .modal-title {
1575
1754
  min-width: 0;
1576
1755
  overflow-wrap: anywhere;
@@ -1725,13 +1904,27 @@ const INDEX_HTML = `<!doctype html>
1725
1904
  <div id="approvalModalFooter" class="modal-footer"></div>
1726
1905
  </div>
1727
1906
  </div>
1907
+ <div id="entityModal" class="modal-backdrop">
1908
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="entityModalTitle">
1909
+ <div class="modal-header">
1910
+ <div>
1911
+ <div id="entityModalTitle" class="modal-title"></div>
1912
+ <div id="entityModalMeta" class="subtle"></div>
1913
+ </div>
1914
+ <button id="entityModalClose" title="Close">Close</button>
1915
+ </div>
1916
+ <div id="entityModalBody" class="modal-body"></div>
1917
+ <div id="entityModalFooter" class="modal-footer"></div>
1918
+ </div>
1919
+ </div>
1728
1920
  <script>
1729
1921
  const state = {
1730
1922
  config: null,
1731
1923
  status: null,
1732
1924
  audit: { dbPath: '', entries: [], pending: [] },
1733
- livePending: {},
1734
- currentApprovalCode: '',
1925
+ activityEvents: [],
1926
+ livePendingById: {},
1927
+ currentApprovalId: '',
1735
1928
  activityAgentId: '',
1736
1929
  auditFilters: { agent: '', tool: '', since: '', limit: '50' },
1737
1930
  tools: [],
@@ -1739,6 +1932,7 @@ const INDEX_HTML = `<!doctype html>
1739
1932
  errors: [],
1740
1933
  activeKind: 'agent',
1741
1934
  activeId: '',
1935
+ createKind: '',
1742
1936
  drafts: {},
1743
1937
  descriptionExpanded: {},
1744
1938
  search: '',
@@ -1815,7 +2009,13 @@ const INDEX_HTML = `<!doctype html>
1815
2009
  for (const [key, value] of Object.entries(state.auditFilters)) {
1816
2010
  if (String(value || '').trim()) params.set(key, String(value).trim());
1817
2011
  }
1818
- state.audit = await api('/api/logs' + (params.toString() ? '?' + params.toString() : ''));
2012
+ const [audit, activity] = await Promise.all([
2013
+ api('/api/logs' + (params.toString() ? '?' + params.toString() : '')),
2014
+ api('/api/activity')
2015
+ ]);
2016
+ state.audit = audit;
2017
+ state.activityEvents = Array.isArray(activity.events) ? activity.events : [];
2018
+ reconcileLivePendingWithSnapshot();
1819
2019
  render();
1820
2020
  } catch (error) {
1821
2021
  alert(error.message);
@@ -1902,26 +2102,41 @@ const INDEX_HTML = `<!doctype html>
1902
2102
  }
1903
2103
 
1904
2104
  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
- }
2105
+ const byId = new Map();
1910
2106
  for (const request of state.audit.pending || []) {
1911
2107
  const pending = normalizePending(request, false);
1912
- if (pending.code && (!agentId || pending.agentId === agentId) && !byCode.has(pending.code)) byCode.set(pending.code, pending);
2108
+ if (pending.id && (!agentId || pending.agentId === agentId)) byId.set(pending.id, pending);
2109
+ }
2110
+ for (const request of Object.values(state.livePendingById || {})) {
2111
+ const pending = normalizePending(request, true);
2112
+ if (pending.id && (!agentId || pending.agentId === agentId) && !byId.has(pending.id)) {
2113
+ byId.set(pending.id, pending);
2114
+ }
2115
+ }
2116
+ return Array.from(byId.values());
2117
+ }
2118
+
2119
+ function reconcileLivePendingWithSnapshot() {
2120
+ const snapshotIds = new Set(
2121
+ (state.audit.pending || [])
2122
+ .map((request) => normalizePending(request, false).id)
2123
+ .filter(Boolean)
2124
+ );
2125
+ for (const id of Object.keys(state.livePendingById || {})) {
2126
+ if (!snapshotIds.has(id)) delete state.livePendingById[id];
1913
2127
  }
1914
- return Array.from(byCode.values());
1915
2128
  }
1916
2129
 
1917
2130
  function normalizePending(request, live) {
1918
2131
  const args = live ? request.args : parseArgs(request.args);
2132
+ const context = request.context || airlockContext(args);
1919
2133
  return {
1920
2134
  id: request.id || '',
1921
2135
  code: request.code || '',
1922
2136
  agentId: request.agentId || request.agent_id || '',
1923
2137
  tool: request.tool || '',
1924
- args,
2138
+ args: stripAirlockContext(args),
2139
+ context,
1925
2140
  status: request.status || 'pending',
1926
2141
  createdAt: request.createdAt || request.created_at || '',
1927
2142
  timeoutMs: request.timeoutMs || 0,
@@ -1940,19 +2155,37 @@ const INDEX_HTML = `<!doctype html>
1940
2155
  }
1941
2156
  }
1942
2157
 
2158
+ function airlockContext(args) {
2159
+ const raw = args && typeof args === 'object' ? args._airlock : undefined;
2160
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
2161
+ const context = {};
2162
+ if (typeof raw.reason === 'string' && raw.reason.trim()) context.reason = raw.reason.trim();
2163
+ if (typeof raw.note === 'string' && raw.note.trim()) context.note = raw.note.trim();
2164
+ return context.reason || context.note ? context : undefined;
2165
+ }
2166
+
2167
+ function stripAirlockContext(args) {
2168
+ if (!args || typeof args !== 'object' || Array.isArray(args) || !('_airlock' in args)) return args;
2169
+ const clean = { ...args };
2170
+ delete clean._airlock;
2171
+ return clean;
2172
+ }
2173
+
1943
2174
  function renderActivity() {
1944
2175
  el('activitySurface').hidden = state.activeKind !== 'activity';
1945
2176
  renderActivityAgentFilter();
1946
2177
  const agentId = state.auditFilters.agent || state.activityAgentId || '';
1947
2178
  const pending = pendingApprovals(agentId);
1948
2179
  const entries = state.audit.entries || [];
2180
+ const activity = activityEvents(agentId);
1949
2181
  const rows = [
1950
2182
  ...pending.map(pendingRow),
2183
+ ...activity.map(activityRow),
1951
2184
  ...entries.map(auditRow)
1952
2185
  ];
1953
2186
  el('pendingBadge').textContent = 'pending ' + pending.length;
1954
2187
  el('activityMeta').textContent = state.audit.dbPath
1955
- ? 'Audit log: ' + state.audit.dbPath + (agentId ? ' · agent ' + agentId : '')
2188
+ ? 'Activity events ' + activity.length + ' · Audit log: ' + state.audit.dbPath + (agentId ? ' · agent ' + agentId : '')
1956
2189
  : 'Audit log not loaded yet';
1957
2190
  el('activityList').innerHTML = rows.length
1958
2191
  ? rows.join('')
@@ -1960,7 +2193,7 @@ const INDEX_HTML = `<!doctype html>
1960
2193
  document.querySelectorAll('[data-pending-row]').forEach((row) => {
1961
2194
  row.addEventListener('click', (event) => {
1962
2195
  if (event.target.closest('[data-approval-action]')) return;
1963
- openApprovalModal(row.dataset.code);
2196
+ openApprovalModal(row.dataset.id);
1964
2197
  });
1965
2198
  });
1966
2199
  document.querySelectorAll('[data-approval-action]').forEach((button) => {
@@ -1976,6 +2209,10 @@ const INDEX_HTML = `<!doctype html>
1976
2209
  });
1977
2210
  }
1978
2211
 
2212
+ function activityEvents(agentId = '') {
2213
+ return (state.activityEvents || []).filter((event) => !agentId || event.agentId === agentId);
2214
+ }
2215
+
1979
2216
  function renderActivityAgentFilter() {
1980
2217
  const agents = Object.keys(state.config.agents);
1981
2218
  if (!state.activityAgentId) state.activityAgentId = agents[0] || '';
@@ -1987,25 +2224,29 @@ const INDEX_HTML = `<!doctype html>
1987
2224
  }
1988
2225
 
1989
2226
  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>' +
2227
+ const question = isUserQuestion(entry);
2228
+ return '<div class="log-row pending' + (question ? ' question' : '') + '" data-pending-row data-id="' + escapeHtml(entry.id) + '" data-code="' + escapeHtml(entry.code) + '">' +
2229
+ '<div class="log-cell"><strong>' + escapeHtml(question ? 'pending question' : 'pending approval') + '</strong><br>' + escapeHtml(formatTime(entry.createdAt)) + '</div>' +
1992
2230
  '<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>' +
2231
+ '<div class="log-cell">' + escapeHtml(question ? questionText(entry) : entry.tool) + '<br><span class="subtle">' + escapeHtml(question ? entry.tool : 'code ' + entry.code) + '</span></div>' +
2232
+ '<div class="log-cell"><span class="tag tag-warn">' + escapeHtml(question ? 'question' : entry.status) + '</span></div>' +
2233
+ '<pre class="log-args">' + escapeHtml(question ? questionPreview(entry) : approvalPreview(entry)) + '</pre>' +
2234
+ '<div class="approval-actions">' + approvalButtons(entry.code, { remember: !question, approveLabel: question ? 'Confirm' : 'Approve' }) + '</div>' +
1997
2235
  '</div>';
1998
2236
  }
1999
2237
 
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>' +
2238
+ function approvalButtons(code, options = {}) {
2239
+ const approveLabel = options.approveLabel || 'Approve';
2240
+ const remember = options.remember !== false;
2241
+ return '<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '">' + escapeHtml(approveLabel) + '</button>' +
2242
+ (remember ? '<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '" data-remember="temporary" data-duration="3600000">Allow 1h</button>' : '') +
2243
+ (remember ? '<button class="approve" data-approval-action="approve" data-code="' + escapeHtml(code) + '" data-remember="always">Always Allow</button>' : '') +
2004
2244
  '<button class="deny" data-approval-action="deny" data-code="' + escapeHtml(code) + '">Deny</button>';
2005
2245
  }
2006
2246
 
2007
2247
  async function actApproval(action, code, remember, durationMs) {
2008
2248
  if (!code) return;
2249
+ const pending = findPendingApproval(code);
2009
2250
  const params = new URLSearchParams({ code });
2010
2251
  if (remember) params.set('remember', remember);
2011
2252
  if (durationMs) params.set('duration_ms', durationMs);
@@ -2020,7 +2261,7 @@ const INDEX_HTML = `<!doctype html>
2020
2261
  }
2021
2262
  throw new Error(message);
2022
2263
  }
2023
- delete state.livePending[code];
2264
+ if (pending?.id) delete state.livePendingById[pending.id];
2024
2265
  closeApprovalModal();
2025
2266
  await Promise.all([refreshLogs(), refreshStatus()]);
2026
2267
  render();
@@ -2028,21 +2269,26 @@ const INDEX_HTML = `<!doctype html>
2028
2269
 
2029
2270
  function findPendingApproval(code) {
2030
2271
  const agentId = state.activeKind === 'activity' ? state.auditFilters.agent || '' : '';
2031
- return pendingApprovals(agentId).find((entry) => entry.code === code);
2272
+ return pendingApprovals(agentId).find((entry) => entry.id === code || entry.code === code);
2032
2273
  }
2033
2274
 
2034
- function openApprovalModal(code) {
2035
- const pending = findPendingApproval(code);
2275
+ function openApprovalModal(id) {
2276
+ const pending = findPendingApproval(id);
2036
2277
  if (!pending) return;
2037
- state.currentApprovalCode = code;
2038
- el('approvalModalTitle').textContent = pending.tool;
2278
+ state.currentApprovalId = pending.id;
2279
+ const question = isUserQuestion(pending);
2280
+ el('approvalModalTitle').textContent = question ? questionText(pending) : pending.tool;
2039
2281
  el('approvalModalMeta').textContent = 'agent ' + pending.agentId + ' · code ' + pending.code;
2040
2282
  el('approvalModalBody').innerHTML =
2041
2283
  '<div class="detail-label">Status</div><div>' + escapeHtml(pending.status) + '</div>' +
2042
2284
  '<div class="detail-label">Created</div><div>' + escapeHtml(formatTime(pending.createdAt)) + '</div>' +
2043
2285
  (pending.timeoutMs ? '<div class="detail-label">Timeout</div><div>' + Math.round(pending.timeoutMs / 1000) + 's</div>' : '') +
2286
+ (requestReason(pending) ? '<div class="detail-label">Request reason</div><div>' + escapeHtml(requestReason(pending)) + '</div>' : '') +
2287
+ (requestNote(pending) ? '<div class="detail-label">Request note</div><div>' + escapeHtml(requestNote(pending)) + '</div>' : '') +
2288
+ (question ? '<div class="detail-label">Question</div><div>' + escapeHtml(questionText(pending)) + '</div>' : '') +
2289
+ (question && questionContext(pending) ? '<div class="detail-label">Context</div><div>' + escapeHtml(questionContext(pending)) + '</div>' : '') +
2044
2290
  '<div class="detail-label">Arguments</div><pre class="log-args">' + escapeHtml(prettyJson(pending.args)) + '</pre>';
2045
- el('approvalModalFooter').innerHTML = approvalButtons(pending.code);
2291
+ el('approvalModalFooter').innerHTML = approvalButtons(pending.code, { remember: !question, approveLabel: question ? 'Confirm' : 'Approve' });
2046
2292
  el('approvalModal').classList.add('open');
2047
2293
  el('approvalModalFooter').querySelectorAll('[data-approval-action]').forEach((button) => {
2048
2294
  button.addEventListener('click', () => {
@@ -2058,7 +2304,58 @@ const INDEX_HTML = `<!doctype html>
2058
2304
 
2059
2305
  function closeApprovalModal() {
2060
2306
  el('approvalModal').classList.remove('open');
2061
- state.currentApprovalCode = '';
2307
+ state.currentApprovalId = '';
2308
+ }
2309
+
2310
+ function isUserQuestion(entry) {
2311
+ return entry.tool === 'airlock/ask_user';
2312
+ }
2313
+
2314
+ function questionText(entry) {
2315
+ const value = entry.args?.question ?? entry.args?.title ?? entry.args?.message;
2316
+ return typeof value === 'string' && value.trim() ? value.trim() : 'Question from agent';
2317
+ }
2318
+
2319
+ function questionContext(entry) {
2320
+ const value = entry.args?.context ?? entry.args?.reason;
2321
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
2322
+ }
2323
+
2324
+ function questionPreview(entry) {
2325
+ const lines = ['question: ' + questionText(entry)];
2326
+ const context = questionContext(entry);
2327
+ if (context) lines.push('context: ' + context);
2328
+ return lines.join('\\n');
2329
+ }
2330
+
2331
+ function requestReason(entry) {
2332
+ const value = entry.context?.reason;
2333
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
2334
+ }
2335
+
2336
+ function requestNote(entry) {
2337
+ const value = entry.context?.note;
2338
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
2339
+ }
2340
+
2341
+ function approvalPreview(entry) {
2342
+ const lines = [];
2343
+ const reason = requestReason(entry);
2344
+ const note = requestNote(entry);
2345
+ if (reason) lines.push('request reason: ' + reason);
2346
+ if (note) lines.push('request note: ' + note);
2347
+ lines.push(prettyJson(entry.args));
2348
+ return lines.join('\\n');
2349
+ }
2350
+
2351
+ function activityRow(event) {
2352
+ const tag = event.kind === 'notification' ? 'tag-warn' : resultTagClass(event.severity || 'info');
2353
+ return '<div class="log-row ' + escapeHtml(event.kind) + '">' +
2354
+ '<div class="log-cell"><strong>' + escapeHtml(event.kind) + '</strong><br>' + escapeHtml(formatTime(event.createdAt)) + '</div>' +
2355
+ '<div class="log-cell">' + escapeHtml(event.agentId || '') + '</div>' +
2356
+ '<div class="log-cell">' + escapeHtml(event.title || '') + '<br><span class="subtle">' + escapeHtml(event.body || '') + '</span></div>' +
2357
+ '<div class="log-cell"><span class="tag ' + tag + '">' + escapeHtml(event.severity || 'info') + '</span></div>' +
2358
+ '</div>';
2062
2359
  }
2063
2360
 
2064
2361
  function auditRow(entry) {
@@ -2208,7 +2505,8 @@ const INDEX_HTML = `<!doctype html>
2208
2505
  el('addProvider').style.display = state.activeKind === 'providers' ? 'inline-flex' : 'none';
2209
2506
  el('deleteEntity').style.display = state.activeKind === 'activity' ? 'none' : el('deleteEntity').style.display;
2210
2507
  el('deleteEntity').disabled = !state.activeId || state.activeKind === 'activity';
2211
- el('profilePanel').style.display = state.activeKind === 'agent' ? 'block' : 'none';
2508
+ el('profilePanel').style.display =
2509
+ state.activeKind === 'agent' || state.activeKind === 'profile' ? 'block' : 'none';
2212
2510
 
2213
2511
  if (!draft) {
2214
2512
  if (state.activeKind === 'providers') {
@@ -2236,12 +2534,18 @@ const INDEX_HTML = `<!doctype html>
2236
2534
  }
2237
2535
 
2238
2536
  const denyCount = draft.deny.length;
2537
+ const tokenPill = state.activeKind === 'agent'
2538
+ ? '<span class="pill ' + (state.config.agents[state.activeId]?.hasToken ? 'tag-good' : 'tag-danger') + '">' + (state.config.agents[state.activeId]?.hasToken ? 'token set' : 'token missing') + '</span>'
2539
+ : '';
2239
2540
  el('summary').innerHTML =
2240
2541
  '<span class="pill">allow ' + draft.allow.length + '</span>' +
2241
2542
  '<span class="pill">ask ' + draft.ask.length + '</span>' +
2242
- '<span class="pill">deny ' + denyCount + '</span>';
2543
+ '<span class="pill">deny ' + denyCount + '</span>' +
2544
+ tokenPill;
2243
2545
 
2244
- const profiles = Object.keys(state.config.profiles);
2546
+ const profiles = Object.keys(state.config.profiles).filter(
2547
+ (id) => state.activeKind !== 'profile' || id !== state.activeId
2548
+ );
2245
2549
  el('profileChecks').innerHTML = profiles.length
2246
2550
  ? profiles.map((id) => {
2247
2551
  const checked = (draft.extends || []).includes(id) ? ' checked' : '';
@@ -2292,7 +2596,7 @@ const INDEX_HTML = `<!doctype html>
2292
2596
 
2293
2597
  const visible = filteredTools();
2294
2598
  el('tools').innerHTML = visible.length
2295
- ? visible.map((tool) => toolRow(tool, getDecision(draft, tool.name))).join('')
2599
+ ? visible.map((tool) => toolRow(tool, getDecisionMatch(draft, tool.name))).join('')
2296
2600
  : '<div class="empty">No tools match the current filters.</div>';
2297
2601
 
2298
2602
  document.querySelectorAll('[data-rule]').forEach((button) => {
@@ -2321,7 +2625,8 @@ const INDEX_HTML = `<!doctype html>
2321
2625
  });
2322
2626
  }
2323
2627
 
2324
- function toolRow(tool, decision) {
2628
+ function toolRow(tool, match) {
2629
+ const decision = match.decision;
2325
2630
  const tags = tool.tags && tool.tags.length ? tool.tags : ['untagged'];
2326
2631
  const tagHtml = tags.map((tag) => '<span class="tag ' + tagClass(tag) + '">' + escapeHtml(tag) + '</span>').join('');
2327
2632
  const recommended = '<span class="tag tag-recommended ' + recommendedClass(tool.recommended) + '">recommended ' + escapeHtml(tool.recommended) + '</span>';
@@ -2336,10 +2641,10 @@ const INDEX_HTML = `<!doctype html>
2336
2641
  '<div class="tool-name"><span class="provider">' + escapeHtml(tool.provider) + '</span><br>' + escapeHtml(tool.shortName || tool.name) + '</div>' +
2337
2642
  '<div class="description"><div class="description-content' + descriptionClass + '">' + formatDescription(description) + '</div>' + toggle + '<div class="tag-list">' + tagHtml + recommended + '</div></div>' +
2338
2643
  '<div class="rule-actions">' +
2339
- ruleButton(tool.name, 'allow', decision) +
2340
- ruleButton(tool.name, 'ask', decision) +
2341
- ruleButton(tool.name, 'deny', decision) +
2342
- ruleButton(tool.name, 'unset', decision) +
2644
+ ruleButton(tool.name, 'allow', match) +
2645
+ ruleButton(tool.name, 'ask', match) +
2646
+ ruleButton(tool.name, 'deny', match) +
2647
+ ruleButton(tool.name, 'unset', match) +
2343
2648
  '</div>' +
2344
2649
  '</div>';
2345
2650
  }
@@ -2394,17 +2699,67 @@ const INDEX_HTML = `<!doctype html>
2394
2699
  return 'tag-good';
2395
2700
  }
2396
2701
 
2397
- function ruleButton(tool, decision, activeDecision) {
2398
- const active = decision === activeDecision ? ' active' : '';
2702
+ function ruleButton(tool, decision, activeMatch) {
2703
+ const active = decision === activeMatch.decision ? ' active' : '';
2704
+ const patternMatch = active && activeMatch.source === 'pattern' ? ' pattern-match' : '';
2399
2705
  const label = decision === 'unset' ? 'Clear' : decision;
2400
- return '<button class="' + active + '" data-rule data-tool="' + escapeHtml(tool) + '" data-decision="' + decision + '">' + label + '</button>';
2706
+ const title = activeMatch.source === 'pattern' && decision === activeMatch.decision
2707
+ ? decision + ' by ' + activeMatch.pattern + '. Click to add an exact rule.'
2708
+ : decision === activeMatch.decision && activeMatch.source === 'exact'
2709
+ ? decision + ' by exact rule.'
2710
+ : decision === 'unset'
2711
+ ? 'Clear exact rule for this tool.'
2712
+ : 'Set exact ' + decision + ' rule for this tool.';
2713
+ return '<button class="' + active + patternMatch + '" title="' + escapeHtml(title) + '" data-rule data-tool="' + escapeHtml(tool) + '" data-decision="' + decision + '">' + label + '</button>';
2401
2714
  }
2402
2715
 
2403
2716
  function getDecision(draft, tool) {
2404
- if (draft.deny.includes(tool)) return 'deny';
2405
- if (draft.ask.includes(tool)) return 'ask';
2406
- if (draft.allow.includes(tool)) return 'allow';
2407
- return 'unset';
2717
+ return getDecisionMatch(draft, tool).decision;
2718
+ }
2719
+
2720
+ function getDecisionMatch(draft, tool) {
2721
+ const deny = bestRuleMatch(draft.deny, tool);
2722
+ const ask = bestRuleMatch(draft.ask, tool);
2723
+ const allow = bestRuleMatch(draft.allow, tool);
2724
+ const denySpec = deny ? deny.specificity : -1;
2725
+ const askSpec = ask ? ask.specificity : -1;
2726
+ const allowSpec = allow ? allow.specificity : -1;
2727
+ const best = Math.max(denySpec, askSpec, allowSpec);
2728
+ if (best < 0) return { decision: 'unset', source: 'none' };
2729
+ if (denySpec === best) return decisionMatch('deny', deny, tool);
2730
+ if (askSpec === best) return decisionMatch('ask', ask, tool);
2731
+ return decisionMatch('allow', allow, tool);
2732
+ }
2733
+
2734
+ function decisionMatch(decision, match, tool) {
2735
+ if (!match) return { decision, source: 'none' };
2736
+ return {
2737
+ decision,
2738
+ source: match.pattern === tool ? 'exact' : 'pattern',
2739
+ pattern: match.pattern
2740
+ };
2741
+ }
2742
+
2743
+ function bestRuleMatch(patterns, tool) {
2744
+ let best = null;
2745
+ for (const pattern of patterns || []) {
2746
+ const score = ruleSpecificity(pattern, tool);
2747
+ if (score >= 0 && (!best || score > best.specificity)) best = { pattern, specificity: score };
2748
+ }
2749
+ return best;
2750
+ }
2751
+
2752
+ function ruleSpecificity(pattern, tool) {
2753
+ if (pattern === tool) return pattern.length + 1;
2754
+ if (pattern.endsWith('/*')) {
2755
+ const prefix = pattern.slice(0, -1);
2756
+ return tool.startsWith(prefix) && !tool.slice(prefix.length).includes('/') ? prefix.length : -1;
2757
+ }
2758
+ if (pattern.endsWith('*')) {
2759
+ const prefix = pattern.slice(0, -1);
2760
+ return tool.startsWith(prefix) ? prefix.length : -1;
2761
+ }
2762
+ return -1;
2408
2763
  }
2409
2764
 
2410
2765
  function setDecision(draft, tool, decision) {
@@ -2473,12 +2828,104 @@ const INDEX_HTML = `<!doctype html>
2473
2828
  render();
2474
2829
  }
2475
2830
 
2476
- async function addEntity(kind) {
2477
- const id = prompt('New ' + kind + ' id');
2478
- if (!id) return;
2479
- state.config = await api('/api/entities', { method: 'POST', body: JSON.stringify({ kind, id }) });
2831
+ function openCreateModal(kind) {
2832
+ state.createKind = kind;
2833
+ renderCreateModal();
2834
+ el('entityModal').classList.add('open');
2835
+ setTimeout(() => el('entityId')?.focus(), 0);
2836
+ }
2837
+
2838
+ function closeEntityModal() {
2839
+ el('entityModal').classList.remove('open');
2840
+ state.createKind = '';
2841
+ }
2842
+
2843
+ function renderCreateModal() {
2844
+ const kind = state.createKind;
2845
+ const isAgent = kind === 'agent';
2846
+ const section = isAgent ? state.config.agents : state.config.profiles;
2847
+ const baseOptions = ['<option value="">Blank</option>'].concat(
2848
+ Object.keys(section).map((id) => '<option value="' + escapeHtml(id) + '">' + escapeHtml(id) + '</option>')
2849
+ ).join('');
2850
+ el('entityModalTitle').textContent = isAgent ? 'New agent' : 'New profile';
2851
+ el('entityModalMeta').textContent = isAgent ? 'Creates a bearer token and permission policy' : 'Creates a reusable permission policy';
2852
+ el('entityModalBody').innerHTML =
2853
+ '<div class="form-grid">' +
2854
+ '<div class="field"><label for="entityId">Id</label><input id="entityId" autocomplete="off" placeholder="' + (isAgent ? 'claude-code' : 'readonly') + '"></div>' +
2855
+ '<div class="field"><label for="entityBase">Start from</label><select id="entityBase">' + baseOptions + '</select></div>' +
2856
+ (isAgent ? profilePicker() : '') +
2857
+ '</div>';
2858
+ el('entityModalFooter').innerHTML =
2859
+ '<button id="entityCreate" class="primary">Create</button>' +
2860
+ '<button id="entityCancel">Cancel</button>';
2861
+ el('entityCreate').addEventListener('click', () => submitCreateEntity().catch((error) => alert(error.message)));
2862
+ el('entityCancel').addEventListener('click', closeEntityModal);
2863
+ el('entityId').addEventListener('keydown', (event) => {
2864
+ if (event.key === 'Enter') submitCreateEntity().catch((error) => alert(error.message));
2865
+ });
2866
+ }
2867
+
2868
+ function profilePicker() {
2869
+ const profiles = Object.keys(state.config.profiles);
2870
+ if (profiles.length === 0) {
2871
+ return '<div class="field"><label>Profiles</label><div class="subtle">No profiles yet.</div></div>';
2872
+ }
2873
+ return '<div class="field"><label>Profiles</label><div class="profile-select-list">' +
2874
+ profiles.map((id) => '<label class="check"><input type="checkbox" data-create-profile="' + escapeHtml(id) + '"> ' + escapeHtml(id) + '</label>').join('') +
2875
+ '</div></div>';
2876
+ }
2877
+
2878
+ async function submitCreateEntity() {
2879
+ const kind = state.createKind;
2880
+ const id = el('entityId').value.trim();
2881
+ if (!id) throw new Error('Id is required');
2882
+ const profileIds = Array.from(document.querySelectorAll('[data-create-profile]:checked')).map((box) => box.dataset.createProfile);
2883
+ const body = {
2884
+ kind,
2885
+ id,
2886
+ baseId: el('entityBase').value,
2887
+ profileIds
2888
+ };
2889
+ const result = await api('/api/entities', { method: 'POST', body: JSON.stringify(body) });
2890
+ state.config = result;
2480
2891
  state.drafts = {};
2481
- setActive(kind, id.trim());
2892
+ setActive(kind, id);
2893
+ if (result.createdToken?.token) {
2894
+ showCreatedToken(result.createdToken.agentId, result.createdToken.token);
2895
+ } else {
2896
+ closeEntityModal();
2897
+ }
2898
+ }
2899
+
2900
+ function showCreatedToken(agentId, token) {
2901
+ el('entityModalTitle').textContent = 'Agent token';
2902
+ el('entityModalMeta').textContent = agentId;
2903
+ el('entityModalBody').innerHTML =
2904
+ '<div class="form-grid">' +
2905
+ '<div class="field"><label for="createdToken">Bearer token</label><input id="createdToken" class="token-value" readonly value="' + escapeHtml(token) + '"></div>' +
2906
+ '<div class="field"><label>Header</label><input class="token-value" readonly value="Authorization: Bearer ' + escapeHtml(token) + '"></div>' +
2907
+ '</div>';
2908
+ el('entityModalFooter').innerHTML =
2909
+ '<button id="copyCreatedToken" class="primary">Copy Token</button>' +
2910
+ '<button id="copyCreatedHeader">Copy Header</button>' +
2911
+ '<button id="entityDone">Done</button>';
2912
+ el('copyCreatedToken').addEventListener('click', () => copyText(token));
2913
+ el('copyCreatedHeader').addEventListener('click', () => copyText('Authorization: Bearer ' + token));
2914
+ el('entityDone').addEventListener('click', closeEntityModal);
2915
+ el('createdToken').select();
2916
+ }
2917
+
2918
+ async function copyText(value) {
2919
+ if (navigator.clipboard?.writeText) {
2920
+ await navigator.clipboard.writeText(value);
2921
+ return;
2922
+ }
2923
+ const textarea = document.createElement('textarea');
2924
+ textarea.value = value;
2925
+ document.body.appendChild(textarea);
2926
+ textarea.select();
2927
+ document.execCommand('copy');
2928
+ textarea.remove();
2482
2929
  }
2483
2930
 
2484
2931
  async function addProvider() {
@@ -2580,17 +3027,31 @@ const INDEX_HTML = `<!doctype html>
2580
3027
  events.onmessage = (event) => {
2581
3028
  const message = JSON.parse(event.data);
2582
3029
  if (message.type === 'new') {
2583
- state.livePending[message.request.code] = message.request;
2584
- notifyApproval(message.request);
2585
- refreshLogs().catch(() => {});
2586
- render();
3030
+ const request = message.request || {};
3031
+ if (request.id) {
3032
+ state.livePendingById[request.id] = request;
3033
+ notifyApproval(request);
3034
+ refreshLogs().catch(() => {});
3035
+ render();
3036
+ }
2587
3037
  }
2588
3038
  if (message.type === 'resolved') {
2589
- delete state.livePending[message.code];
3039
+ if (message.id) {
3040
+ delete state.livePendingById[message.id];
3041
+ } else if (message.code) {
3042
+ for (const [id, request] of Object.entries(state.livePendingById || {})) {
3043
+ if (request && request.code === message.code) delete state.livePendingById[id];
3044
+ }
3045
+ }
2590
3046
  refreshLogs().catch(() => {});
2591
3047
  refreshStatus().catch(() => {});
2592
3048
  render();
2593
3049
  }
3050
+ if (message.type === 'activity') {
3051
+ upsertActivityEvent(message.event);
3052
+ notifyActivity(message.event);
3053
+ render();
3054
+ }
2594
3055
  };
2595
3056
  events.onerror = () => {
2596
3057
  events.close();
@@ -2600,13 +3061,36 @@ const INDEX_HTML = `<!doctype html>
2600
3061
  function notifyApproval(request) {
2601
3062
  if (!el('approvalNotifs').checked) return;
2602
3063
  if (!('Notification' in window) || Notification.permission !== 'granted') return;
3064
+ const pending = normalizePending(request, true);
3065
+ const lines = ['agent: ' + pending.agentId, pending.code];
3066
+ const reason = requestReason(pending);
3067
+ if (reason) lines.push('request reason: ' + reason);
2603
3068
  new Notification('Airlock: ' + request.tool, {
2604
- body: 'agent: ' + request.agentId + '\\n' + request.code,
2605
- tag: request.code,
3069
+ body: lines.join('\\n'),
3070
+ tag: request.id || request.code,
2606
3071
  silent: !el('approvalSound').checked
2607
3072
  });
2608
3073
  }
2609
3074
 
3075
+ function notifyActivity(event) {
3076
+ if (!event || event.kind !== 'notification') return;
3077
+ if (!el('approvalNotifs').checked) return;
3078
+ if (!('Notification' in window) || Notification.permission !== 'granted') return;
3079
+ new Notification(event.title || 'Airlock notification', {
3080
+ body: event.body || '',
3081
+ tag: event.id,
3082
+ silent: !el('approvalSound').checked
3083
+ });
3084
+ }
3085
+
3086
+ function upsertActivityEvent(event) {
3087
+ if (!event || !event.id) return;
3088
+ state.activityEvents = [
3089
+ event,
3090
+ ...(state.activityEvents || []).filter((existing) => existing.id !== event.id)
3091
+ ].slice(0, 200);
3092
+ }
3093
+
2610
3094
  async function checkVersion() {
2611
3095
  try {
2612
3096
  const current = await fetch('/version').then((response) => response.json());
@@ -2629,10 +3113,10 @@ const INDEX_HTML = `<!doctype html>
2629
3113
  return false;
2630
3114
  }
2631
3115
 
2632
- function firstPendingCode() {
3116
+ function firstPendingApprovalId() {
2633
3117
  const agentId = state.activeKind === 'activity' ? state.auditFilters.agent || '' : '';
2634
3118
  const first = pendingApprovals(agentId)[0];
2635
- return first ? first.code : '';
3119
+ return first ? first.id : '';
2636
3120
  }
2637
3121
 
2638
3122
  el('refreshTools').addEventListener('click', refreshTools);
@@ -2642,8 +3126,8 @@ const INDEX_HTML = `<!doctype html>
2642
3126
  el('manageProviders').addEventListener('click', () => setActive('providers'));
2643
3127
  el('viewActivity').addEventListener('click', () => setActive('activity'));
2644
3128
  el('addProvider').addEventListener('click', () => addProvider().catch((error) => alert(error.message)));
2645
- el('addAgent').addEventListener('click', () => addEntity('agent').catch((error) => alert(error.message)));
2646
- el('addProfile').addEventListener('click', () => addEntity('profile').catch((error) => alert(error.message)));
3129
+ el('addAgent').addEventListener('click', () => openCreateModal('agent'));
3130
+ el('addProfile').addEventListener('click', () => openCreateModal('profile'));
2647
3131
  el('deleteEntity').addEventListener('click', () => deleteCurrent().catch((error) => alert(error.message)));
2648
3132
  el('search').addEventListener('input', (event) => { state.search = event.target.value; renderTools(); });
2649
3133
  el('providerFilter').addEventListener('change', (event) => { state.provider = event.target.value; renderTools(); });
@@ -2669,11 +3153,16 @@ const INDEX_HTML = `<!doctype html>
2669
3153
  el('resetAllRules').addEventListener('click', resetAllToCurrentConfig);
2670
3154
  el('recommendedRules').addEventListener('click', setRecommended);
2671
3155
  el('approvalModalClose').addEventListener('click', closeApprovalModal);
3156
+ el('entityModalClose').addEventListener('click', closeEntityModal);
2672
3157
  el('approvalModal').addEventListener('click', (event) => {
2673
3158
  if (event.target === el('approvalModal')) closeApprovalModal();
2674
3159
  });
3160
+ el('entityModal').addEventListener('click', (event) => {
3161
+ if (event.target === el('entityModal')) closeEntityModal();
3162
+ });
2675
3163
  document.addEventListener('keydown', (event) => {
2676
3164
  if (event.key === 'Escape') {
3165
+ closeEntityModal();
2677
3166
  closeApprovalModal();
2678
3167
  return;
2679
3168
  }
@@ -2682,9 +3171,10 @@ const INDEX_HTML = `<!doctype html>
2682
3171
  if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
2683
3172
  const key = event.key.toLowerCase();
2684
3173
  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));
3174
+ const id = state.currentApprovalId || firstPendingApprovalId();
3175
+ const pending = findPendingApproval(id);
3176
+ if (!pending?.code) return;
3177
+ actApproval(key === 'a' ? 'approve' : 'deny', pending.code).catch((error) => alert(error.message));
2688
3178
  });
2689
3179
 
2690
3180
  initApprovalSettings();