agentgate-runtime-control 2.13.10 → 2.13.12

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/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.10",
3
+ "version": "2.13.12",
4
4
  "type": "module",
5
5
  "description": "Runtime control plane and SaaS governance layer for AI agent tool execution",
6
6
  "exports": {
package/src/approval.js CHANGED
@@ -12,10 +12,18 @@ export class ApprovalStore {
12
12
 
13
13
  export function createApprovalStore(options = {}) { return options.store || new ApprovalStore(options.limit || 500); }
14
14
 
15
+ // Default: a pending approval expires after 15 minutes if nobody acts on it.
16
+ // Pass ttlMs: null (or 0) to disable expiry for a given request.
17
+ export const DEFAULT_APPROVAL_TTL_MS = 15 * 60 * 1000;
18
+
15
19
  export function createApprovalRequest(data = {}, store = new ApprovalStore()) {
20
+ const createdAt = new Date();
21
+ const ttlMs = data.ttlMs === undefined ? DEFAULT_APPROVAL_TTL_MS : data.ttlMs;
22
+ const expiresAt = ttlMs ? new Date(createdAt.getTime() + ttlMs).toISOString() : null;
16
23
  return store.add({
17
24
  id: data.id || `approval_${randomUUID()}`,
18
- createdAt: new Date().toISOString(),
25
+ createdAt: createdAt.toISOString(),
26
+ expiresAt,
19
27
  status: APPROVAL_STATUS.PENDING,
20
28
  runId: data.runId,
21
29
  tenantId: data.tenantId || data.request?.tenantId || null,
@@ -27,4 +35,13 @@ export function createApprovalRequest(data = {}, store = new ApprovalStore()) {
27
35
  });
28
36
  }
29
37
 
38
+ // A pending approval is expired once `expiresAt` has passed. Every approval
39
+ // is single-use by construction: approve()/deny() flip `status` away from
40
+ // PENDING synchronously (before any await), so a second call — concurrent
41
+ // or not — always sees a non-pending status and is rejected. There is no
42
+ // separate "already used" flag to track.
43
+ export function isApprovalExpired(approval, now = Date.now()) {
44
+ return Boolean(approval?.expiresAt) && new Date(approval.expiresAt).getTime() <= now;
45
+ }
46
+
30
47
  function clone(value) { try { return structuredClone(value); } catch { return JSON.parse(JSON.stringify(value)); } }
package/src/runtime.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { evaluate } from './policy-engine.js';
2
2
  import { createPersistentRunStore, createPersistentApprovalStore } from './persistent-store.js';
3
- import { createApprovalStore, createApprovalRequest, APPROVAL_STATUS } from './approval.js';
3
+ import { createApprovalStore, createApprovalRequest, APPROVAL_STATUS, isApprovalExpired } from './approval.js';
4
4
 
5
5
  export class RunStore {
6
6
  constructor(limit = 25000) { this.limit = limit; this.runs = []; }
@@ -45,6 +45,7 @@ export function createRuntime(options = {}) {
45
45
  decision: event.decision,
46
46
  risk: event.risk,
47
47
  reason: event.reason,
48
+ ttlMs: options.approvalTTLMs,
48
49
  metadata: { action: request.action || request.tool || null }
49
50
  }, approvalStore);
50
51
  event.status = 'pending_approval';
@@ -65,11 +66,28 @@ export function createRuntime(options = {}) {
65
66
  return { status: 'executed', value, agentgate: event };
66
67
  }
67
68
 
68
- function approvals(status) { return approvalStore.list(status); }
69
- function getApproval(id) { return approvalStore.get(id); }
69
+ // Lazily flips a PENDING-but-expired approval to EXPIRED and drops its
70
+ // pending execution, so a stale approval can never be actioned late.
71
+ function expireIfStale(approval) {
72
+ if (!approval || approval.status !== APPROVAL_STATUS.PENDING) return approval;
73
+ if (!isApprovalExpired(approval)) return approval;
74
+ approval.status = APPROVAL_STATUS.EXPIRED;
75
+ approval.resolvedAt = new Date().toISOString();
76
+ const item = pending.get(approval.id);
77
+ if (item) { item.event.status = 'expired'; item.event.approval = { status: approval.status, resolvedAt: approval.resolvedAt }; }
78
+ pending.delete(approval.id);
79
+ approvalStore.save?.();
80
+ return approval;
81
+ }
82
+
83
+ function approvals(status) {
84
+ approvalStore.list(APPROVAL_STATUS.PENDING).forEach(expireIfStale);
85
+ return approvalStore.list(status);
86
+ }
87
+ function getApproval(id) { return expireIfStale(approvalStore.get(id)); }
70
88
 
71
89
  async function approve(approvalId) {
72
- const approval = approvalStore.get(approvalId);
90
+ const approval = expireIfStale(approvalStore.get(approvalId));
73
91
  if (!approval) return { ok: false, error: 'Approval not found' };
74
92
  if (approval.status !== APPROVAL_STATUS.PENDING) return { ok: false, error: `Approval is already ${approval.status}`, approval };
75
93
  const item = pending.get(approvalId);
@@ -99,7 +117,7 @@ export function createRuntime(options = {}) {
99
117
  }
100
118
 
101
119
  function deny(approvalId, reason = 'Denied by approver') {
102
- const approval = approvalStore.get(approvalId);
120
+ const approval = expireIfStale(approvalStore.get(approvalId));
103
121
  if (!approval) return { ok: false, error: 'Approval not found' };
104
122
  if (approval.status !== APPROVAL_STATUS.PENDING) return { ok: false, error: `Approval is already ${approval.status}`, approval };
105
123
  const item = pending.get(approvalId);