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 +95 -3
- package/package.json +1 -1
- package/src/approval.js +18 -1
- package/src/runtime.js +23 -5
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/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:
|
|
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
|
-
|
|
69
|
-
|
|
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);
|