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 +36 -0
- package/bin/agentgate.js +95 -3
- package/package.json +1 -1
- package/src/mcp-gateway.js +23 -4
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
|
-
|
|
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));
|
|
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 {
|
|
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
package/src/mcp-gateway.js
CHANGED
|
@@ -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) => {
|
|
56
|
-
|
|
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 };
|