agentgate-runtime-control 2.13.11 → 2.13.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,18 @@ Useful control-plane endpoints include `/api/observability`, `/api/trace?runId=.
24
24
 
25
25
  **Observe → Attack → Enforce → Replay → Report → Govern**
26
26
 
27
+ ### Quickstart
28
+
29
+ ```bash
30
+ npm install agentgate-runtime-control
31
+ npx agentgate init # writes agentgate.config.mjs — tells you to run doctor next
32
+ npx agentgate doctor # checks your config, warns loudly if you're still in observe mode,
33
+ # then tells you to open examples/protect-first-tool.mjs next
34
+ node examples/protect-first-tool.mjs # see a real tool protected end-to-end
35
+ npx agentgate attack --config ./agentgate.config.mjs # attack-test YOUR policy, not the defaults
36
+ ```
37
+
38
+ Each command prints what to run next, so you don't have to remember this sequence.
27
39
 
28
40
  ## Design Partner Edition
29
41
 
@@ -70,6 +82,10 @@ The config file must export an `agentgate` object created with `createAgentGate(
70
82
 
71
83
  See [`docs/production-readiness.md`](docs/production-readiness.md), [`docs/production-deployment.md`](docs/production-deployment.md), and [`docs/release-checklist.md`](docs/release-checklist.md) for deployment, operational, performance, and release gates.
72
84
 
85
+ ### About `npm test` on the installed package
86
+
87
+ Running `npm test` inside an **installed** copy of `agentgate-runtime-control` (i.e. from `node_modules`) reports `0 tests` — that's expected, not a bug: the `test/` directory is intentionally not published to npm (see `files` in `package.json`), the same way most published packages don't ship their own test suite to consumers. The real suite (150+ cases, covering policy decisions, the approval lifecycle — including concurrent approve/deny and TTL expiry — attack-lab scenarios, egress guarding, multi-tenant isolation, and more) lives in and runs from the [source repository](https://github.com/walid-agentgate/agentgate) via `node --test`.
88
+
73
89
  ## Security
74
90
 
75
91
  See [`SECURITY.md`](SECURITY.md) for the security model and vulnerability-reporting guidance. AgentGate provides a deterministic control layer; it does not replace application-level identity, secret management, network isolation, or threat-model testing.
@@ -258,6 +274,26 @@ Approval state is queryable through `gateway.approvals()` and JSON-RPC methods:
258
274
 
259
275
  The approval layer is intentionally separate from policy evaluation: policy decides `ALLOW`, `ASK`, or `BLOCK`; approval resolves only the `ASK` path.
260
276
 
277
+ ### Approval lifecycle — who, when, expiry, single-use, revocation
278
+
279
+ - **Who approved / denied, and when**: every approval record carries `createdAt`, `resolvedAt`, and (for a deny) a `resolutionReason`. The run record (`gateway.replay(runId)`) links back to the approval via `approvalId` and stores the same `approval` block for audit export (`gateway.replay()` / `/api/audit/export`). AgentGate itself doesn't have a user identity system, so "who" is whatever identity your own auth layer attaches to the request that calls `approve()`/`deny()` — log that at your call site if you need a named approver.
280
+ - **Expiry (TTL)**: a pending approval expires automatically after **15 minutes** by default (`DEFAULT_APPROVAL_TTL_MS` in `src/approval.js`). Pass `approvalTTLMs` to `createRuntime`/`createAgentGate`/`createMCPGateway` to change it, or `ttlMs: null` on a specific request to disable expiry. Once `expiresAt` passes, the approval flips to `status: 'expired'` the next time it's looked at (list/get/approve/deny), and the original tool call can never be executed late.
281
+ - **Single-use guarantee**: `approve()`/`deny()` are synchronous up to the point where they flip `status` away from `pending` — there is no `await` in between the status check and the status write. Because Node runs JS on a single thread, two calls racing to resolve the same approval (concurrent HTTP requests, a double click, a retried request) can never both see `pending`: the second call always sees the already-resolved status and is rejected with `Approval is already <status>`. There is nothing else to configure for this — it's guaranteed by construction, not by a lock.
282
+ - **Revocation**: there's no separate "revoke" verb — deny a still-pending approval with `gateway.deny(approvalId, reason)` (or `agentgate approval deny <id> <reason>` from the CLI) to take it off the table before anyone acts on it.
283
+ - **Duplicate requests**: each call to a protected tool creates its own approval with its own id — AgentGate does not de-duplicate identical-looking requests. If your agent might retry the same call, treat that as your integration's concern (e.g. an idempotency key on your own tool handler).
284
+
285
+ ### Approval CLI
286
+
287
+ Once a gateway or control plane is running (for example via `agentgate dev`), you can list and resolve approvals from the command line instead of writing HTTP calls by hand:
288
+
289
+ ```
290
+ agentgate approval list [--status pending|approved|denied|expired] [--url <url>]
291
+ agentgate approval approve <approvalId> [--url <url>] [--key <apiKey>]
292
+ agentgate approval deny <approvalId> [reason] [--url <url>] [--key <apiKey>]
293
+ ```
294
+
295
+ By default it talks to `http://localhost:8787` (what `agentgate dev` uses) and, if no `--key`/`AGENTGATE_API_KEY` is given, it automatically picks up the local dev session the same way opening the dashboard in a browser would — no extra setup needed for local testing. Point `--url` at a different host/port for a control plane running elsewhere, and pass `--key` (or set `AGENTGATE_API_KEY`) when auth is required outside local dev.
296
+
261
297
  ## v1.1 — Developer Integration
262
298
 
263
299
  AgentGate now exposes a single developer-facing runtime:
package/bin/agentgate.js CHANGED
@@ -35,6 +35,7 @@ Usage:
35
35
  agentgate egress <response.json> [--strict]
36
36
  agentgate policy <create|list|test|activate|rollback|diff> <name> ...
37
37
  agentgate tenant <create|list|key|revoke|rotate> ...
38
+ agentgate approval <list|approve|deny> [id] [reason] [--url <url>] [--key <apiKey>]
38
39
  agentgate --help
39
40
  agentgate doctor
40
41
  agentgate simulate
@@ -108,9 +109,22 @@ export { pack };
108
109
  } else if (cmd === 'doctor') {
109
110
  const configPath = path.resolve(process.cwd(), 'agentgate.config.mjs');
110
111
  let config = { mode: 'enforce', policies: { productionBlock: true, autoApproveAmount: 500, approvalAmount: 5000 }, authRequired: true };
111
- try { const mod = await import(`file://${configPath}?doctor=${Date.now()}`); const gate = mod.agentgate || {}; config = { ...config, ...(mod.config || {}), ...(gate.config || {}), ...(gate.mode ? { mode: gate.mode } : {}), ...(gate.policies ? { policies: gate.policies } : {}) }; } catch {}
112
+ let foundConfig = false;
113
+ try { const mod = await import(`file://${configPath}?doctor=${Date.now()}`); const gate = mod.agentgate || {}; config = { ...config, ...(mod.config || {}), ...(gate.config || {}), ...(gate.mode ? { mode: gate.mode } : {}), ...(gate.policies ? { policies: gate.policies } : {}) }; foundConfig = true; } catch {}
112
114
  const report = runDoctorChecks({ config });
113
- console.log(JSON.stringify(report, null, 2)); process.exitCode = report.ok ? 0 : 2;
115
+ console.log(JSON.stringify(report, null, 2));
116
+ if (!foundConfig) {
117
+ console.error('\nNo agentgate.config.mjs found here — checked built-in defaults instead. Run `agentgate init` first to create one for your own policies.');
118
+ }
119
+ if (config.mode === 'observe') {
120
+ console.error('\n⚠️ WARNING: mode is "observe". Decisions are being recorded but NOTHING is actually blocked or held for approval yet — destructive tools will still execute. Set mode: \'enforce\' in agentgate.config.mjs once you are ready to protect real tools.');
121
+ }
122
+ if (report.ok) {
123
+ console.error('\nNext: protect your first tool — see examples/protect-first-tool.mjs for a worked example (read/delete/refund/export), or run `agentgate attack --config agentgate.config.mjs` to test your policy against the built-in attack scenarios.');
124
+ } else {
125
+ console.error('\nFix the failing checks above, then run `agentgate doctor` again.');
126
+ }
127
+ process.exitCode = report.ok ? 0 : 2;
114
128
  } else if (cmd === 'simulate') {
115
129
  const policy = { productionBlock: true, autoApproveAmount: 500, approvalAmount: 5000 };
116
130
  console.log(JSON.stringify(simulatePolicyMatrix({ policies: policy }), null, 2));
@@ -287,7 +301,85 @@ export { pack };
287
301
  ? `import { createAgentGate, getPolicyPack } from 'agentgate-runtime-control';\n\nconst pack = getPolicyPack('${pack.id}');\n\nexport const agentgate = createAgentGate({\n agent: 'SupportAgent',\n mode: 'observe',\n policies: pack.policies\n});\n\nexport { pack };\n`
288
302
  : `import { createAgentGate } from 'agentgate-runtime-control';\n\nexport const agentgate = createAgentGate({\n agent: 'MyAgent',\n mode: 'enforce',\n policies: {\n productionBlock: true,\n autoApproveAmount: 500,\n approvalAmount: 5000\n }\n});\n`;
289
303
  try { await fs.access(file); console.error('agentgate.config.mjs already exists'); process.exitCode = 1; }
290
- catch { await fs.writeFile(file, content, 'utf8'); console.log(`Created ${file}${pack ? ` from ${pack.name} (enforce mode)` : ''}`); }
304
+ catch {
305
+ await fs.writeFile(file, content, 'utf8');
306
+ console.log(`Created ${file}${pack ? ` from ${pack.name} (enforce mode)` : ''}`);
307
+ console.log('\nNext: run `agentgate doctor` to check this config, then see examples/protect-first-tool.mjs to protect your first tool.');
308
+ }
309
+ }
310
+ } else if (cmd === 'approval') {
311
+ const sub = process.argv[3];
312
+ const urlIndex = process.argv.indexOf('--url');
313
+ const baseUrl = (urlIndex >= 0 ? process.argv[urlIndex + 1] : (process.env.AGENTGATE_URL || 'http://localhost:8787')).replace(/\/$/, '');
314
+ const keyIndex = process.argv.indexOf('--key');
315
+ const apiKey = keyIndex >= 0 ? process.argv[keyIndex + 1] : (process.env.AGENTGATE_API_KEY || null);
316
+
317
+ async function authHeaders() {
318
+ if (apiKey) return { 'x-agentgate-key': apiKey, 'content-type': 'application/json' };
319
+ // No API key given — try to pick up the local session cookie that `agentgate dev`
320
+ // hands out on GET '/', so this CLI can talk to a locally running dev server
321
+ // without any extra setup.
322
+ try {
323
+ const res = await fetch(`${baseUrl}/`);
324
+ const cookie = res.headers.get('set-cookie');
325
+ const match = cookie && cookie.match(/agentgate_local_session=[^;]+/);
326
+ if (match) return { cookie: match[0], 'content-type': 'application/json' };
327
+ } catch {}
328
+ return { 'content-type': 'application/json' };
329
+ }
330
+
331
+ async function call(apiPath, method = 'GET', body) {
332
+ let headers;
333
+ try { headers = await authHeaders(); }
334
+ catch (err) { return { status: 0, json: { error: `Could not reach ${baseUrl}: ${err.message}` } }; }
335
+ try {
336
+ const res = await fetch(`${baseUrl}${apiPath}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
337
+ let json;
338
+ try { json = await res.json(); } catch { json = { error: `Non-JSON response (status ${res.status})` }; }
339
+ return { status: res.status, json };
340
+ } catch (err) {
341
+ return { status: 0, json: { error: `Could not reach ${baseUrl}: ${err.message}` } };
342
+ }
343
+ }
344
+
345
+ const unreachable = (result) => {
346
+ console.error(`Could not reach AgentGate at ${baseUrl}${result.status ? ` (HTTP ${result.status})` : ''}: ${result.json.error || 'unknown error'}`);
347
+ console.error('Is `agentgate dev` running? Point at it with --url <http://host:port>, or pass --key <apiKey> if auth is required.');
348
+ process.exitCode = 1;
349
+ };
350
+
351
+ if (sub === 'list') {
352
+ const statusIndex = process.argv.indexOf('--status');
353
+ const status = statusIndex >= 0 ? process.argv[statusIndex + 1] : undefined;
354
+ const result = await call(`/api/approvals${status ? `?status=${encodeURIComponent(status)}` : ''}`);
355
+ if (result.status !== 200) unreachable(result);
356
+ else {
357
+ const approvals = result.json.approvals || [];
358
+ if (!approvals.length) console.log('No approvals found.');
359
+ else console.table(approvals.map(a => ({ id: a.id, status: a.status, action: a.metadata?.action || a.request?.action || '', createdAt: a.createdAt, expiresAt: a.expiresAt || 'never' })));
360
+ }
361
+ } else if (sub === 'approve' || sub === 'deny') {
362
+ const id = process.argv[4];
363
+ if (!id) { console.error(`Usage: agentgate approval ${sub} <approvalId>${sub === 'deny' ? ' [reason]' : ''} [--url <url>] [--key <apiKey>]`); process.exitCode = 1; }
364
+ else {
365
+ const reason = sub === 'deny' ? process.argv[5] : undefined;
366
+ const apiPath = sub === 'approve' ? '/api/approvals/approve' : '/api/approvals/deny';
367
+ const result = await call(apiPath, 'POST', sub === 'approve' ? { approvalId: id } : { approvalId: id, reason });
368
+ if (result.status !== 200 || result.json.ok === false) {
369
+ if (result.status === 0 || result.status === 401 || result.status === 403) unreachable(result);
370
+ else { console.error(`Could not ${sub} ${id}: ${result.json.error || `HTTP ${result.status}`}`); process.exitCode = 1; }
371
+ } else {
372
+ console.log(JSON.stringify(result.json, null, 2));
373
+ }
374
+ }
375
+ } else {
376
+ console.error(`Usage: agentgate approval <list|approve|deny> ...
377
+ agentgate approval list [--status pending|approved|denied|expired] [--url <url>] [--key <apiKey>]
378
+ agentgate approval approve <approvalId> [--url <url>] [--key <apiKey>]
379
+ agentgate approval deny <approvalId> [reason] [--url <url>] [--key <apiKey>]
380
+
381
+ By default this talks to a locally running \`agentgate dev\` server at http://localhost:8787.`);
382
+ process.exitCode = 1;
291
383
  }
292
384
  } else if (cmd === 'dev') {
293
385
  const htmlPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../standalone.html');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgate-runtime-control",
3
- "version": "2.13.11",
3
+ "version": "2.13.13",
4
4
  "type": "module",
5
5
  "description": "Runtime control plane and SaaS governance layer for AI agent tool execution",
6
6
  "exports": {
@@ -2,7 +2,7 @@ import http from 'node:http';
2
2
  import { createRequire } from 'node:module';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { evaluate } from './policy-engine.js';
5
- import { createApprovalStore, createApprovalRequest, APPROVAL_STATUS } from './approval.js';
5
+ import { createApprovalStore, createApprovalRequest, APPROVAL_STATUS, isApprovalExpired } from './approval.js';
6
6
  import { createPersistentRunStore, createPersistentApprovalStore } from './persistent-store.js';
7
7
  import { createEventBus } from './event-bus.js';
8
8
  import { createTelemetry } from './telemetry.js';
@@ -52,8 +52,12 @@ export function createMCPGateway(options = {}) {
52
52
  tools: () => [...tools.keys()],
53
53
  runs: (tenantId) => { const all = runStore ? runStore.list() : [...runs]; return tenantId ? all.filter(run => run.tenantId === tenantId) : all; },
54
54
  replay: (runId, tenantId) => { const run = runStore ? runStore.get(runId) : (runs.find((item) => item.id === runId) || null); return !tenantId || run?.tenantId === tenantId ? run : null; },
55
- approvals: (status, tenantId) => { const all = approvals.list(status); return tenantId ? all.filter(item => item.tenantId === tenantId) : all; },
56
- getApproval: (approvalId, tenantId) => { const item = approvals.get(approvalId); return !tenantId || item?.tenantId === tenantId ? item : null; },
55
+ approvals: (status, tenantId) => {
56
+ approvals.list(APPROVAL_STATUS.PENDING).forEach(expireIfStale);
57
+ const all = approvals.list(status);
58
+ return tenantId ? all.filter(item => item.tenantId === tenantId) : all;
59
+ },
60
+ getApproval: (approvalId, tenantId) => { const item = expireIfStale(approvals.get(approvalId)); return !tenantId || item?.tenantId === tenantId ? item : null; },
57
61
  approve: (approvalId, tenantId) => resolveApproval(approvalId, true, undefined, tenantId),
58
62
  deny: (approvalId, reason, tenantId) => resolveApproval(approvalId, false, reason, tenantId),
59
63
  events: eventBus,
@@ -74,6 +78,20 @@ export function createMCPGateway(options = {}) {
74
78
 
75
79
  for (const tool of options.tools || []) registerTool(tool);
76
80
 
81
+ // Lazily flips a PENDING-but-past-TTL approval to EXPIRED the next time it's
82
+ // looked at (list/get/approve/deny), so it can never be actioned late.
83
+ function expireIfStale(approval) {
84
+ if (!approval || approval.status !== APPROVAL_STATUS.PENDING) return approval;
85
+ if (!isApprovalExpired(approval)) return approval;
86
+ approval.status = APPROVAL_STATUS.EXPIRED;
87
+ approval.resolvedAt = new Date().toISOString();
88
+ const run = runStore ? runStore.get(approval.runId) : runs.find(item => item.id === approval.runId);
89
+ if (run) { run.status = 'expired'; run.approval = { status: approval.status, resolvedAt: approval.resolvedAt }; }
90
+ approvals.save?.();
91
+ runStore?.save?.();
92
+ return approval;
93
+ }
94
+
77
95
  function registerTool(tool) {
78
96
  if (!tool?.name || typeof tool.handler !== 'function') {
79
97
  throw new TypeError('registerTool() expects { name, handler, description?, inputSchema? }');
@@ -183,6 +201,7 @@ export function createMCPGateway(options = {}) {
183
201
  decision: decision.decision,
184
202
  risk: decision.risk,
185
203
  reason: decision.reason,
204
+ ttlMs: options.approvalTTLMs,
186
205
  metadata: { tool: name, rpcRequestId: id, arguments: clone(input) }
187
206
  }, approvals);
188
207
  run.status = 'pending_approval';
@@ -245,7 +264,7 @@ export function createMCPGateway(options = {}) {
245
264
 
246
265
 
247
266
  async function resolveApproval(approvalId, approved, denialReason, tenantId) {
248
- const approval = approvals.get(approvalId);
267
+ const approval = expireIfStale(approvals.get(approvalId));
249
268
  if (tenantId && approval?.tenantId !== tenantId) return { ok: false, error: 'Approval not found' };
250
269
  if (!approval) return { ok: false, error: 'Approval not found' };
251
270
  if (approval.status !== APPROVAL_STATUS.PENDING) return { ok: false, error: `Approval is already ${approval.status}`, approval };