agentgate-runtime-control 2.13.9 → 2.13.11

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
@@ -52,9 +52,20 @@ docker run --rm -p 8787:8787 agentgate
52
52
 
53
53
  ### Examples
54
54
 
55
+ - `examples/protect-first-tool.mjs` — protect four real tools in one file (`read_customer` → ALLOW, `delete_customer` → ASK, `refund` → ASK/BLOCK by amount, `export_all` → BLOCK). Start here.
55
56
  - `examples/refund-agent.mjs` — protect a real side-effecting refund tool.
56
57
  - `examples/policy-bundle.mjs` — test and activate a versioned policy bundle.
57
58
 
59
+ ### Test your own tools against Attack Lab
60
+
61
+ By default, `agentgate attack` runs the built-in attack scenarios against AgentGate's default policies. To test them against **your own** `agentgate.config.mjs` (the policies you actually ship with), pass `--config`:
62
+
63
+ ```bash
64
+ agentgate attack --config ./agentgate.config.mjs
65
+ ```
66
+
67
+ The config file must export an `agentgate` object created with `createAgentGate()` (exactly what `agentgate init` generates).
68
+
58
69
  ## Production readiness
59
70
 
60
71
  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.
@@ -0,0 +1,83 @@
1
+ // Protect your first real tool with AgentGate.
2
+ //
3
+ // The flow AgentGate sits in:
4
+ //
5
+ // AI Agent ─────▶ AgentGate ─────▶ Tool / API ─────▶ External system
6
+ // (ALLOW/ASK/BLOCK
7
+ // decided here,
8
+ // BEFORE the tool
9
+ // handler runs)
10
+ //
11
+ // This example wraps four tools an agent might call and shows the
12
+ // decision each one gets under a simple, realistic policy:
13
+ //
14
+ // read_customer -> ALLOW (read-only, no side effects)
15
+ // delete_customer -> ASK (destructive, always needs a human to approve)
16
+ // refund -> ASK below $5,000, BLOCK above $5,000
17
+ // export_all -> BLOCK (bulk data export, always blocked)
18
+ //
19
+ // Note that "destructive" tools (delete, refund, publish, export_all,
20
+ // update_production, deploy) always require at least ASK — there is no
21
+ // amount small enough to skip approval entirely for them. That is
22
+ // deliberate: this policy engine treats "asks for approval" as the safe
23
+ // default for anything with a side effect, not just anything expensive.
24
+ //
25
+ // Run it with:
26
+ // node examples/protect-first-tool.mjs
27
+
28
+ import { createAgentGate } from 'agentgate-runtime-control';
29
+
30
+ const gate = createAgentGate({
31
+ agent: 'SupportAgent',
32
+ mode: 'enforce',
33
+ policies: {
34
+ productionBlock: true, // in production, destructive actions are blocked outright (not even ASK)
35
+ approvalAmount: 5000, // refunds above this amount are always BLOCKed, no approval possible
36
+ blockActions: ['export_all'] // export_all is blocked regardless of amount/environment
37
+ }
38
+ });
39
+
40
+ // --- The real tool handlers your agent would otherwise call directly ---
41
+ async function readCustomer({ customerId }) {
42
+ return { customerId, name: 'Jane Doe', plan: 'pro' };
43
+ }
44
+ async function deleteCustomer({ customerId }) {
45
+ return { deleted: customerId };
46
+ }
47
+ async function refund({ amount, customerId }) {
48
+ return { refunded: amount, customerId };
49
+ }
50
+ async function exportAll() {
51
+ return { exportedRows: 1_000_000 };
52
+ }
53
+
54
+ // --- Wrap each one with agentgate.protect() before the agent ever sees it ---
55
+ const protectedReadCustomer = gate.protect(readCustomer, { tool: 'read_customer', action: 'read' });
56
+ const protectedDeleteCustomer = gate.protect(deleteCustomer, { tool: 'delete_customer', action: 'delete' });
57
+ const protectedRefund = gate.protect(refund, { tool: 'refund', action: 'refund' });
58
+ const protectedExportAll = gate.protect(exportAll, { tool: 'export_all', action: 'export_all' });
59
+
60
+ async function tryCall(label, fn, input) {
61
+ try {
62
+ const result = await fn(input, input);
63
+ console.log(`${label.padEnd(24)} -> ${result.status.toUpperCase()} (${result.agentgate.decision}) — ${result.agentgate.reason}`);
64
+ } catch (error) {
65
+ console.log(`${label.padEnd(24)} -> BLOCKED (${error.agentgate?.decision}) — ${error.agentgate?.reason}`);
66
+ }
67
+ }
68
+
69
+ // environment: 'development' lets the amount/action-based rules below decide
70
+ // (ASK vs ALLOW vs BLOCK). In production, productionBlock:true would block
71
+ // every destructive action outright — try changing this to 'production' and
72
+ // re-running to see that stricter behavior.
73
+ const env = 'development';
74
+
75
+ console.log('AgentGate — protecting four real tools\n');
76
+ await tryCall('read_customer', protectedReadCustomer, { customerId: 'cus_123', environment: env });
77
+ await tryCall('delete_customer', protectedDeleteCustomer, { customerId: 'cus_123', environment: env });
78
+ await tryCall('refund (small)', protectedRefund, { amount: 250, customerId: 'cus_123', environment: env });
79
+ await tryCall('refund (large)', protectedRefund, { amount: 8000, customerId: 'cus_123', environment: env });
80
+ await tryCall('export_all', protectedExportAll, { environment: env });
81
+
82
+ console.log('\nNothing above BLOCK or pending ASK ever reached its real handler.');
83
+ console.log('See gate.approvals() to review and approve pending ASK requests.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgate-runtime-control",
3
- "version": "2.13.9",
3
+ "version": "2.13.11",
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);