agentgate-runtime-control 2.13.8

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +21 -0
  3. package/README.md +534 -0
  4. package/SECURITY.md +19 -0
  5. package/bin/agentgate.js +303 -0
  6. package/docs/case-study-technical-validation.md +25 -0
  7. package/docs/case-study-template.md +37 -0
  8. package/docs/data-protection.md +49 -0
  9. package/docs/design-partner-checklist.md +32 -0
  10. package/docs/design-partner-kit.md +51 -0
  11. package/docs/design-partner-rollout.md +35 -0
  12. package/docs/design-partner.md +67 -0
  13. package/docs/external-security-review-test-pack.md +132 -0
  14. package/docs/external-security-review.md +33 -0
  15. package/docs/incident-response.md +54 -0
  16. package/docs/integration-matrix.md +17 -0
  17. package/docs/managed-postgres-acceptance-test.md +138 -0
  18. package/docs/marketing-plan.md +33 -0
  19. package/docs/observability-alerting.md +44 -0
  20. package/docs/outreach.md +26 -0
  21. package/docs/partner-intake-template.md +26 -0
  22. package/docs/performance-baseline.md +23 -0
  23. package/docs/performance.md +27 -0
  24. package/docs/pricing.md +53 -0
  25. package/docs/production-deployment.md +70 -0
  26. package/docs/production-quickstart.md +58 -0
  27. package/docs/production-readiness.md +29 -0
  28. package/docs/quickstart.md +115 -0
  29. package/docs/release-checklist.md +33 -0
  30. package/docs/security-hardening-release-report.md +69 -0
  31. package/docs/threat-model.md +47 -0
  32. package/docs/website-copy.md +44 -0
  33. package/examples/basic.mjs +14 -0
  34. package/examples/control-plane.mjs +17 -0
  35. package/examples/design-partner-refund.mjs +33 -0
  36. package/examples/design-partner-shadow.mjs +27 -0
  37. package/examples/mcp-gateway.mjs +26 -0
  38. package/examples/policy-bundle.mjs +18 -0
  39. package/examples/refund-agent.mjs +20 -0
  40. package/examples/runtime.mjs +12 -0
  41. package/package.json +49 -0
  42. package/schema/postgres.sql +17 -0
  43. package/src/admin-rbac.js +3 -0
  44. package/src/agentgate.js +85 -0
  45. package/src/approval.js +30 -0
  46. package/src/attack-lab.js +94 -0
  47. package/src/auth.js +27 -0
  48. package/src/behavior.js +146 -0
  49. package/src/control-plane.js +215 -0
  50. package/src/egress-guard.js +132 -0
  51. package/src/event-bus.js +10 -0
  52. package/src/identity.js +109 -0
  53. package/src/index.js +44 -0
  54. package/src/local-experience.js +46 -0
  55. package/src/mcp-gateway.js +383 -0
  56. package/src/mcp-scanner.js +45 -0
  57. package/src/middleware.js +17 -0
  58. package/src/multi-tenant.js +29 -0
  59. package/src/observability.js +395 -0
  60. package/src/oidc.js +38 -0
  61. package/src/persistent-store.js +56 -0
  62. package/src/policy-builder.js +74 -0
  63. package/src/policy-bundles.js +17 -0
  64. package/src/policy-engine.js +67 -0
  65. package/src/policy-packs.js +115 -0
  66. package/src/policy-registry.js +58 -0
  67. package/src/postgres-adapter.js +76 -0
  68. package/src/runtime.js +138 -0
  69. package/src/saas.js +67 -0
  70. package/src/security-report.js +42 -0
  71. package/src/security-validation.js +92 -0
  72. package/src/shadow-mode.js +47 -0
  73. package/src/telemetry.js +28 -0
  74. package/src/webhook-delivery.js +70 -0
  75. package/standalone.html +86 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Curated, reviewable policy packs for common agent side-effect workflows.
3
+ * Packs are deterministic data: they never grant authorization by themselves.
4
+ */
5
+
6
+ const SUPPORT_REFUND_SAFETY = Object.freeze({
7
+ id: 'support-refund-safety',
8
+ name: 'Support Refund Safety Pack',
9
+ version: '1.0.0',
10
+ description: 'Conservative controls for customer-support agents that can issue refunds.',
11
+ useCase: 'support-financial-operations',
12
+ policies: Object.freeze({
13
+ productionBlock: false,
14
+ autoApproveAmount: 500,
15
+ approvalAmount: 5000,
16
+ blockActions: Object.freeze(['export_all'])
17
+ }),
18
+ cases: Object.freeze([
19
+ { name: 'small refund', action: 'refund', amount: 250, environment: 'production', expected: 'ASK' },
20
+ { name: 'review refund', action: 'refund', amount: 1200, environment: 'production', expected: 'ASK' },
21
+ { name: 'ceiling refund', action: 'refund', amount: 5000, environment: 'production', expected: 'ASK' },
22
+ { name: 'over-ceiling refund', action: 'refund', amount: 5000.01, environment: 'production', expected: 'BLOCK' },
23
+ { name: 'invalid refund amount', action: 'refund', amount: '5000.01', environment: 'production', expected: 'BLOCK' },
24
+ { name: 'production export', action: 'export_all', amount: 0, environment: 'production', expected: 'BLOCK' }
25
+ ]),
26
+ limitations: Object.freeze([
27
+ 'Does not prove an agent is safe or prevent every prompt-injection technique.',
28
+ 'Does not replace application authorization, IAM, network isolation, or provider controls.',
29
+ 'Review policy thresholds and tool semantics before production enforcement.'
30
+ ])
31
+ });
32
+
33
+ const PRODUCTION_DEVOPS_SAFETY = Object.freeze({
34
+ id: 'production-devops-safety',
35
+ name: 'Production DevOps Safety Pack',
36
+ version: '1.0.0',
37
+ description: 'Conservative controls for agents that can change or delete production infrastructure.',
38
+ useCase: 'devops-it-operations',
39
+ policies: Object.freeze({
40
+ productionBlock: true,
41
+ requireApprovalForDestructive: true,
42
+ blockActions: Object.freeze(['update_production', 'delete', 'deploy'])
43
+ }),
44
+ cases: Object.freeze([
45
+ { name: 'read production', action: 'read', environment: 'production', expected: 'ALLOW' },
46
+ { name: 'production delete', action: 'delete', environment: 'production', expected: 'BLOCK' },
47
+ { name: 'production config', action: 'update_production', environment: 'production', expected: 'BLOCK' },
48
+ { name: 'production deploy', action: 'deploy', environment: 'production', expected: 'BLOCK' },
49
+ { name: 'export everything', action: 'export_all', environment: 'production', expected: 'BLOCK' }
50
+ ]),
51
+ limitations: Object.freeze([
52
+ 'Does not replace cloud IAM, deployment approvals, change management, or infrastructure isolation.',
53
+ 'Review action names and production detection against the actual tool contract.',
54
+ 'This pack is intentionally conservative and may require explicit exceptions.'
55
+ ])
56
+ });
57
+
58
+ const CUSTOMER_DATA_EXPORT_SAFETY = Object.freeze({
59
+ id: 'customer-data-export-safety',
60
+ name: 'Customer Data Export Safety Pack',
61
+ version: '1.0.0',
62
+ description: 'Conservative controls for CRM/data agents handling exports and bulk customer changes.',
63
+ useCase: 'crm-customer-data',
64
+ policies: Object.freeze({
65
+ productionBlock: false,
66
+ requireApprovalForDestructive: true,
67
+ blockActions: Object.freeze(['export_all', 'bulk_update'])
68
+ }),
69
+ cases: Object.freeze([
70
+ { name: 'search customer', action: 'search', environment: 'production', expected: 'ALLOW' },
71
+ { name: 'export all customers', action: 'export_all', environment: 'production', expected: 'BLOCK' },
72
+ { name: 'bulk customer update', action: 'bulk_update', environment: 'production', expected: 'BLOCK' },
73
+ { name: 'delete customer', action: 'delete', environment: 'production', expected: 'ASK' }
74
+ ]),
75
+ limitations: Object.freeze([
76
+ 'Does not establish legal compliance or automatically satisfy privacy requirements.',
77
+ 'Tenant and identity authorization should be configured separately.',
78
+ 'Review data classification and destination controls before enforcement.'
79
+ ])
80
+ });
81
+
82
+ const PACKS = Object.freeze({
83
+ [SUPPORT_REFUND_SAFETY.id]: SUPPORT_REFUND_SAFETY,
84
+ [PRODUCTION_DEVOPS_SAFETY.id]: PRODUCTION_DEVOPS_SAFETY,
85
+ [CUSTOMER_DATA_EXPORT_SAFETY.id]: CUSTOMER_DATA_EXPORT_SAFETY
86
+ });
87
+
88
+ export function listPolicyPacks() {
89
+ return Object.values(PACKS).map(pack => ({
90
+ id: pack.id,
91
+ name: pack.name,
92
+ version: pack.version,
93
+ useCase: pack.useCase,
94
+ description: pack.description
95
+ }));
96
+ }
97
+
98
+ export function getPolicyPack(id = '') {
99
+ const pack = PACKS[String(id).trim().toLowerCase()];
100
+ if (!pack) return null;
101
+ return clonePack(pack);
102
+ }
103
+
104
+ export function getDefaultPolicyPack() {
105
+ return clonePack(SUPPORT_REFUND_SAFETY);
106
+ }
107
+
108
+ function clonePack(pack) {
109
+ return {
110
+ ...pack,
111
+ policies: { ...pack.policies, ...(pack.policies.approvalActions ? { approvalActions: [...pack.policies.approvalActions] } : {}), ...(pack.policies.blockActions ? { blockActions: [...pack.policies.blockActions] } : {}) },
112
+ cases: pack.cases.map(item => ({ ...item })),
113
+ limitations: [...pack.limitations]
114
+ };
115
+ }
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { evaluate } from './policy-engine.js';
5
+
6
+ export const POLICY_STATES = Object.freeze({ DRAFT:'draft', TESTED:'tested', ACTIVE:'active', ARCHIVED:'archived' });
7
+
8
+ const clone = (v) => structuredClone(v ?? {});
9
+ const digest = (v) => crypto.createHash('sha256').update(JSON.stringify(v, Object.keys(v).sort())).digest('hex').slice(0,16);
10
+
11
+ export class PolicyRegistry {
12
+ constructor(options = {}) {
13
+ this.filePath = path.resolve(options.filePath || '.agentgate/policies.json');
14
+ this.limit = options.limit || 500;
15
+ this.items = this.#load();
16
+ this.audit = this.#loadAudit();
17
+ }
18
+ #load() { try { const x=JSON.parse(fs.readFileSync(this.filePath,'utf8')); return Array.isArray(x)?x:[]; } catch { return []; } }
19
+ #loadAudit() { try { const p=this.filePath.replace(/\.json$/,'-audit.json'); const x=JSON.parse(fs.readFileSync(p,'utf8')); return Array.isArray(x)?x:[]; } catch { return []; } }
20
+ #save() { fs.mkdirSync(path.dirname(this.filePath),{recursive:true}); const t=this.filePath+'.tmp'; fs.writeFileSync(t,JSON.stringify(this.items,null,2)); fs.renameSync(t,this.filePath); const ap=this.filePath.replace(/\.json$/,'-audit.json'); fs.writeFileSync(ap,JSON.stringify(this.audit.slice(0,2000),null,2)); }
21
+ #versions(name, tenantId = null) { return this.items.filter(x=>x.name===name && (tenantId == null ? !x.tenantId : x.tenantId === tenantId)).sort((a,b)=>b.version-a.version); }
22
+ create(name, policy={}, metadata={}, tenantId = null) {
23
+ if (!name || typeof name!=='string') throw new TypeError('Policy name is required');
24
+ const versions=this.#versions(name, tenantId); const version=(versions[0]?.version||0)+1;
25
+ const item={id:`pol_${name.toLowerCase().replace(/[^a-z0-9]+/g,'_')}_v${version}`,name,version,state:POLICY_STATES.DRAFT,policy:clone(policy),metadata:clone(metadata),tenantId: tenantId || null,hash:digest(policy),createdAt:new Date().toISOString()};
26
+ this.items.unshift(item); this.#audit('create',item); this.#save(); return clone(item);
27
+ }
28
+ get(name, version, tenantId = null) { return clone(this.#versions(name, tenantId).find(x=>version==null||x.version===Number(version))||null); }
29
+ list(name, tenantId = null) { return this.#versions(name, tenantId).map(clone); }
30
+ active(name, tenantId = null) { return clone(this.#versions(name, tenantId).find(x=>x.state===POLICY_STATES.ACTIVE)||null); }
31
+ activeAll(tenantId = null) { return this.items.filter(x => x.state === POLICY_STATES.ACTIVE && (tenantId == null ? !x.tenantId : x.tenantId === tenantId)).map(clone); }
32
+ updateDraft(name, version, policy, metadata, tenantId = null) {
33
+ const item=this.#versions(name, tenantId).find(x=>x.version===Number(version));
34
+ if(!item) return null; if(item.state!==POLICY_STATES.DRAFT) throw new Error('Only draft policies can be updated');
35
+ item.policy=clone(policy); if(metadata) item.metadata=clone(metadata); item.hash=digest(policy); item.updatedAt=new Date().toISOString(); this.#audit('update',item); this.#save(); return clone(item);
36
+ }
37
+ test(name, version, cases=[], tenantId = null) {
38
+ const item=this.#versions(name, tenantId).find(x=>x.version===Number(version)); if(!item) throw new Error('Policy version not found');
39
+ const results=cases.map((c)=>{const r=evaluate(c.input||c.request||{},item.policy); return {name:c.name||c.id||'case',expected:c.expected,actual:r.decision,passed:c.expected?c.expected===r.decision:true,reason:r.reason,risk:r.risk};});
40
+ const passed=results.every(r=>r.passed); if(passed && item.state===POLICY_STATES.DRAFT) item.state=POLICY_STATES.TESTED; item.lastTest={at:new Date().toISOString(),passed,results}; this.#audit('test',item,{passed}); this.#save(); return clone(item.lastTest);
41
+ }
42
+ activate(name, version, tenantId = null) {
43
+ const target=this.#versions(name, tenantId).find(x=>x.version===Number(version)); if(!target) throw new Error('Policy version not found');
44
+ if(target.state!==POLICY_STATES.TESTED && target.state!==POLICY_STATES.ACTIVE) throw new Error('Policy must pass tests before activation');
45
+ for(const x of this.#versions(name, tenantId)) if(x.state===POLICY_STATES.ACTIVE) x.state=POLICY_STATES.ARCHIVED;
46
+ target.state=POLICY_STATES.ACTIVE; target.activatedAt=new Date().toISOString(); this.#audit('activate',target); this.#save(); return clone(target);
47
+ }
48
+ rollback(name, version, tenantId = null) { return this.activate(name,version,tenantId); }
49
+ diff(name, a, b, tenantId = null) {
50
+ const left=this.get(name,a,tenantId)?.policy||{}, right=this.get(name,b,tenantId)?.policy||{}; const keys=new Set([...Object.keys(left),...Object.keys(right)]); const changes=[];
51
+ for(const key of [...keys].sort()) if(JSON.stringify(left[key])!==JSON.stringify(right[key])) changes.push({key,from:clone(left[key]),to:clone(right[key])});
52
+ return {name,from:Number(a),to:Number(b),tenantId:tenantId || null,changes};
53
+ }
54
+ auditLog(name, tenantId = null) { return this.audit.filter(x=>(!name||x.name===name) && (tenantId == null ? !x.tenantId : x.tenantId === tenantId)).map(clone); }
55
+ #audit(action,item,extra={}) { this.audit.unshift({id:`pa_${crypto.randomUUID()}`,at:new Date().toISOString(),action,name:item.name,tenantId:item.tenantId || null,version:item.version,state:item.state,hash:item.hash,...extra}); }
56
+ }
57
+
58
+ export function createPolicyRegistry(options={}) { return new PolicyRegistry(options); }
@@ -0,0 +1,76 @@
1
+ export class PostgresStoreAdapter {
2
+ constructor(options = {}) {
3
+ this.client = options.client;
4
+ this.table = options.table || 'agentgate_records';
5
+ this.requireTenant = options.requireTenant !== false;
6
+ if (!this.client || typeof this.client.query !== 'function') throw new TypeError('PostgresStoreAdapter expects a client with query()');
7
+ }
8
+ async #withTenant(tenantId, fn) {
9
+ if (!this.requireTenant || !tenantId) return fn(this.client);
10
+ if (typeof this.client.connect === 'function') {
11
+ const conn = await this.client.connect();
12
+ try {
13
+ await conn.query('BEGIN');
14
+ await conn.query("select set_config('agentgate.tenant_id', $1, true)", [tenantId]);
15
+ const result = await fn(conn);
16
+ await conn.query('COMMIT');
17
+ return result;
18
+ } catch (error) {
19
+ try { await conn.query('ROLLBACK'); } catch {}
20
+ throw error;
21
+ } finally { conn.release?.(); }
22
+ }
23
+ // Minimal query-only clients (including test doubles) still receive explicit tenant predicates.
24
+ // Production pooled clients should expose connect() so RLS can be scoped with SET LOCAL.
25
+ return fn(this.client);
26
+ }
27
+ async add(record) {
28
+ const tenantId = record.tenantId || null;
29
+ if (this.requireTenant && !tenantId) throw new Error('tenantId is required for Postgres persistence');
30
+ return this.#withTenant(tenantId, async client => {
31
+ await client.query(`insert into ${this.table} (id, tenant_id, kind, data) values ($1,$2,$3,$4)`, [record.id, tenantId, record.kind || 'record', JSON.stringify(record)]);
32
+ return record;
33
+ });
34
+ }
35
+ async get(id, tenantId) {
36
+ if (this.requireTenant && !tenantId) throw new Error('tenantId is required for Postgres reads');
37
+ return this.#withTenant(tenantId, async client => {
38
+ const r = await client.query(`select data, version from ${this.table} where id=$1 and tenant_id=$2 limit 1`, [id, tenantId]);
39
+ return r.rows?.[0]?.data ? { ...r.rows[0].data, _storageVersion: r.rows[0].version } : null;
40
+ });
41
+ }
42
+ async list(tenantId, kind = null) {
43
+ if (this.requireTenant && !tenantId) throw new Error('tenantId is required for Postgres reads');
44
+ return this.#withTenant(tenantId, async client => {
45
+ const r = await client.query(`select data, version from ${this.table} where tenant_id=$1 and ($2::text is null or kind=$2) order by created_at desc`, [tenantId, kind]);
46
+ return (r.rows || []).map(x => ({ ...x.data, _storageVersion: x.version }));
47
+ });
48
+ }
49
+ async update(id, tenantId, patch = {}, expectedVersion = null) {
50
+ if (this.requireTenant && !tenantId) throw new Error('tenantId is required for Postgres updates');
51
+ return this.#withTenant(tenantId, async client => {
52
+ const r = await client.query(
53
+ `update ${this.table} set data = data || $3::jsonb, version = version + 1 where id=$1 and tenant_id=$2 and ($4::int is null or version=$4) returning data, version`,
54
+ [id, tenantId, JSON.stringify(patch), expectedVersion == null ? null : Number(expectedVersion)]
55
+ );
56
+ if (!r.rows?.[0]) throw new Error(expectedVersion == null ? 'Record not found' : 'Concurrent update detected');
57
+ return { ...r.rows[0].data, _storageVersion: r.rows[0].version };
58
+ });
59
+ }
60
+ }
61
+
62
+ export const POSTGRES_SCHEMA = `create table if not exists agentgate_records (id text primary key, tenant_id text not null, kind text not null, data jsonb not null, version integer not null default 1, created_at timestamptz not null default now());
63
+ create index if not exists agentgate_records_tenant_kind_idx on agentgate_records (tenant_id, kind);
64
+ alter table agentgate_records enable row level security;
65
+ drop policy if exists agentgate_tenant_isolation on agentgate_records;
66
+ create policy agentgate_tenant_isolation on agentgate_records using (tenant_id = current_setting('agentgate.tenant_id', true)) with check (tenant_id = current_setting('agentgate.tenant_id', true));`;
67
+
68
+ export function createSupabaseAdapter(supabase, options = {}) {
69
+ if (!supabase || typeof supabase.from !== 'function') throw new TypeError('createSupabaseAdapter expects a Supabase client');
70
+ const table = options.table || 'agentgate_records';
71
+ return {
72
+ async add(record) { if (!record.tenantId) throw new Error('tenantId is required for Supabase persistence'); const { error } = await supabase.from(table).insert({ id: record.id, tenant_id: record.tenantId, kind: record.kind || 'record', data: record }); if (error) throw error; return record; },
73
+ async get(id, tenantId = null) { if (!tenantId) throw new Error('tenantId is required for Supabase reads'); let q = supabase.from(table).select('data,version').eq('id', id).eq('tenant_id', tenantId); const { data, error } = await q.maybeSingle(); if (error) throw error; return data?.data ? { ...data.data, _storageVersion: data.version } : null; },
74
+ async list(tenantId = null, kind = null) { if (!tenantId) throw new Error('tenantId is required for Supabase reads'); let q = supabase.from(table).select('data,version').order('created_at', { ascending: false }).eq('tenant_id', tenantId); if (kind) q = q.eq('kind', kind); const { data, error } = await q; if (error) throw error; return (data || []).map(x => x.data); }
75
+ };
76
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,138 @@
1
+ import { evaluate } from './policy-engine.js';
2
+ import { createPersistentRunStore, createPersistentApprovalStore } from './persistent-store.js';
3
+ import { createApprovalStore, createApprovalRequest, APPROVAL_STATUS } from './approval.js';
4
+
5
+ export class RunStore {
6
+ constructor(limit = 25000) { this.limit = limit; this.runs = []; }
7
+ add(event) { this.runs.unshift(event); if (this.runs.length > this.limit) this.runs.pop(); return event; }
8
+ list() { return [...this.runs]; }
9
+ get(id) { return this.runs.find(r => r.id === id); }
10
+ }
11
+
12
+ export function createRuntime(options = {}) {
13
+ const policies = options.policies || {};
14
+ const store = options.store || (options.persistence ? createPersistentRunStore({ filePath: options.persistence, limit: options.maxRuns || 25000 }) : new RunStore());
15
+ const approvalStore = options.approvalStore || (options.persistence ? createPersistentApprovalStore({ filePath: options.approvalPersistence || `${options.persistence}.approvals.json`, limit: options.maxApprovals || options.maxRuns || 25000 }) : createApprovalStore({ limit: options.maxApprovals || options.maxRuns || 25000 }));
16
+ const pending = new Map();
17
+ const mode = options.mode || 'enforce'; // observe | enforce
18
+
19
+ async function check(request = {}) {
20
+ const result = evaluate(request, policies);
21
+ const event = {
22
+ id: `run_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
23
+ createdAt: new Date().toISOString(),
24
+ mode,
25
+ request: structuredCloneSafe(request),
26
+ decision: result.decision,
27
+ risk: result.risk,
28
+ reason: result.reason,
29
+ winningRule: result.winningRule || null,
30
+ ruleTrace: result.ruleTrace || [],
31
+ simulated: mode === 'observe' && result.decision !== 'ALLOW'
32
+ };
33
+ store.add(event);
34
+ return event;
35
+ }
36
+
37
+ async function execute(tool, request = {}) {
38
+ const event = await check(request);
39
+ if (mode === 'enforce' && event.decision !== 'ALLOW') {
40
+ if (event.decision === 'ASK') {
41
+ const approval = createApprovalRequest({
42
+ runId: event.id,
43
+ tenantId: request.tenantId,
44
+ request,
45
+ decision: event.decision,
46
+ risk: event.risk,
47
+ reason: event.reason,
48
+ metadata: { action: request.action || request.tool || null }
49
+ }, approvalStore);
50
+ event.status = 'pending_approval';
51
+ event.approvalId = approval.id;
52
+ pending.set(approval.id, { tool, request: structuredCloneSafe(request), event });
53
+ approvalStore.save?.();
54
+ store.save?.();
55
+ return { status: 'approval_required', approvalId: approval.id, agentgate: event };
56
+ }
57
+ const error = new Error(`AgentGate blocked action: ${event.reason}`);
58
+ error.code = 'AGENTGATE_BLOCKED'; error.agentgate = event; throw error;
59
+ }
60
+ const value = await tool(request);
61
+ event.status = 'executed';
62
+ event.executed = true;
63
+ event.valuePreview = preview(value);
64
+ store.save?.();
65
+ return { status: 'executed', value, agentgate: event };
66
+ }
67
+
68
+ function approvals(status) { return approvalStore.list(status); }
69
+ function getApproval(id) { return approvalStore.get(id); }
70
+
71
+ async function approve(approvalId) {
72
+ const approval = approvalStore.get(approvalId);
73
+ if (!approval) return { ok: false, error: 'Approval not found' };
74
+ if (approval.status !== APPROVAL_STATUS.PENDING) return { ok: false, error: `Approval is already ${approval.status}`, approval };
75
+ const item = pending.get(approvalId);
76
+ if (!item) return { ok: false, error: 'Pending approval execution is no longer available', approval };
77
+ approval.status = APPROVAL_STATUS.APPROVED;
78
+ approval.resolvedAt = new Date().toISOString();
79
+ const { tool, request, event } = item;
80
+ try {
81
+ const value = await tool(request);
82
+ event.status = 'executed';
83
+ event.executed = true;
84
+ event.approval = { status: approval.status, resolvedAt: approval.resolvedAt, approver: 'explicit' };
85
+ event.valuePreview = preview(value);
86
+ approvalStore.save?.();
87
+ store.save?.();
88
+ pending.delete(approvalId);
89
+ return { ok: true, status: 'executed', value, approval, agentgate: event };
90
+ } catch (error) {
91
+ event.status = 'execution_error';
92
+ event.executed = true;
93
+ event.error = error?.message || String(error);
94
+ approvalStore.save?.();
95
+ store.save?.();
96
+ pending.delete(approvalId);
97
+ return { ok: true, status: 'execution_error', error: event.error, approval, agentgate: event };
98
+ }
99
+ }
100
+
101
+ function deny(approvalId, reason = 'Denied by approver') {
102
+ const approval = approvalStore.get(approvalId);
103
+ if (!approval) return { ok: false, error: 'Approval not found' };
104
+ if (approval.status !== APPROVAL_STATUS.PENDING) return { ok: false, error: `Approval is already ${approval.status}`, approval };
105
+ const item = pending.get(approvalId);
106
+ approval.status = APPROVAL_STATUS.DENIED;
107
+ approval.resolvedAt = new Date().toISOString();
108
+ approval.resolutionReason = reason;
109
+ if (item) {
110
+ item.event.status = 'denied';
111
+ item.event.approval = { status: approval.status, resolvedAt: approval.resolvedAt, reason };
112
+ }
113
+ pending.delete(approvalId);
114
+ approvalStore.save?.();
115
+ store.save?.();
116
+ return { ok: true, status: 'denied', approval, agentgate: item?.event || null };
117
+ }
118
+
119
+ return {
120
+ check,
121
+ execute,
122
+ approve,
123
+ deny,
124
+ approvals,
125
+ getApproval,
126
+ runs: () => store.list(),
127
+ replay: id => store.get(id),
128
+ mode,
129
+ policies
130
+ };
131
+ }
132
+
133
+ function preview(value) {
134
+ try { return JSON.stringify(value).slice(0, 500); } catch { return '[unserializable]'; }
135
+ }
136
+ function structuredCloneSafe(value) {
137
+ try { return structuredClone(value); } catch { return JSON.parse(JSON.stringify(value)); }
138
+ }
package/src/saas.js ADDED
@@ -0,0 +1,67 @@
1
+ import crypto from 'node:crypto';
2
+ import { PersistentCollectionStore } from './persistent-store.js';
3
+
4
+ export const PLANS = Object.freeze({
5
+ free: Object.freeze({ name:'free', tenants:1, members:2, agents:3, runsPerMonth:1000, apiKeys:3, webhooks:2 }),
6
+ pro: Object.freeze({ name:'pro', tenants:10, members:10, agents:50, runsPerMonth:100000, apiKeys:25, webhooks:25 }),
7
+ enterprise: Object.freeze({ name:'enterprise', tenants:1000, members:1000, agents:10000, runsPerMonth:10000000, apiKeys:5000, webhooks:1000 })
8
+ });
9
+
10
+ const ROLES = new Set(['owner','admin','developer','viewer','billing']);
11
+ const id = prefix => `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
12
+ const now = () => new Date().toISOString();
13
+
14
+ export class OrganizationRegistry {
15
+ constructor(options={}) {
16
+ const base = options.persistence || '.agentgate';
17
+ this.orgs = options.orgStore || new PersistentCollectionStore(options.filePath || `${base}/organizations.json`, {limit: options.limit||10000});
18
+ this.members = options.memberStore || new PersistentCollectionStore(options.memberPath || `${base}/organization-members.json`, {limit: options.memberLimit||50000});
19
+ this.tenants = options.tenantStore || new PersistentCollectionStore(options.tenantPath || `${base}/organization-tenants.json`, {limit: options.tenantLimit||10000});
20
+ }
21
+ create(name, owner={}) {
22
+ const org={id:id('org'),name:String(name||'').trim(),plan:'free',status:'active',createdAt:now(),metadata:owner.metadata||{}};
23
+ if(!org.name) throw new Error('Organization name is required');
24
+ this.orgs.add(org);
25
+ this.addMember(org.id, owner.userId || owner.email || 'owner', 'owner', owner);
26
+ return org;
27
+ }
28
+ get(orgId){ return this.orgs.get(orgId); }
29
+ list(){ return this.orgs.list(); }
30
+ setPlan(orgId, plan){ if(!PLANS[plan]) throw new Error('Unknown plan'); const o=this.get(orgId); if(!o) throw new Error('Organization not found'); o.plan=plan; o.updatedAt=now(); this.orgs.save(); return o; }
31
+ addMember(orgId,userId,role='developer',metadata={}) { if(!this.get(orgId)) throw new Error('Organization not found'); if(!ROLES.has(role)) throw new Error('Invalid member role'); const existing=this.members.list(m=>m.orgId===orgId&&m.userId===String(userId))[0]; if(existing){existing.role=role; existing.updatedAt=now(); this.members.save(); return existing;} const m={id:id('mem'),orgId,userId:String(userId),role,metadata,createdAt:now()}; return this.members.add(m); }
32
+ removeMember(orgId,userId){ const m=this.members.list(x=>x.orgId===orgId&&x.userId===String(userId))[0]; if(!m) return null; this.members.remove?.(m.id); if(!this.members.remove){m.revokedAt=now(); this.members.save();} return m; }
33
+ membersFor(orgId){ return this.members.list(m=>m.orgId===orgId); }
34
+ authorize(orgId,userId,action){ const m=this.membersFor(orgId).find(x=>x.userId===String(userId)); if(!m) return {allowed:false,reason:'Member not found'}; if(action==='billing' && !['owner','billing'].includes(m.role)) return {allowed:false,reason:'Billing permission required'}; if(['admin','members:write'].includes(action) && !['owner','admin'].includes(m.role)) return {allowed:false,reason:'Admin permission required'}; return {allowed:true,role:m.role}; }
35
+ linkTenant(orgId,tenantId){ if(!this.get(orgId)) throw new Error('Organization not found'); if(this.tenants.list(x=>x.orgId===orgId&&x.tenantId===tenantId)[0]) return this.tenants.list(x=>x.orgId===orgId&&x.tenantId===tenantId)[0]; const t={id:id('ot'),orgId,tenantId,createdAt:now()}; return this.tenants.add(t); }
36
+ tenantsFor(orgId){ return this.tenants.list(x=>x.orgId===orgId); }
37
+ }
38
+
39
+ export class UsageMeter {
40
+ constructor(options={}) { this.store=options.store||new PersistentCollectionStore(options.filePath||'.agentgate/usage.json',{limit:options.limit||100000}); }
41
+ period(date=new Date()){ return date.toISOString().slice(0,7); }
42
+ get(orgId,metric='runs',period=this.period()){ return this.store.list(x=>x.orgId===orgId&&x.metric===metric&&x.period===period)[0] || {orgId,metric,period,quantity:0}; }
43
+ increment(orgId,metric='runs',quantity=1,period=this.period()){ const n=Number(quantity); if(!Number.isFinite(n)||n<0) throw new Error('Quantity must be a non-negative number'); let row=this.get(orgId,metric,period); if(!row.id){ row={...row,id:id('usage'),quantity:0,updatedAt:now()}; this.store.add(row); } row.quantity += n; row.updatedAt=now(); this.store.save(); return row; }
44
+ snapshot(orgId,period=this.period()){ return this.store.list(x=>x.orgId===orgId&&x.period===period); }
45
+ }
46
+
47
+ export class EntitlementService {
48
+ constructor({organizations,usage}={}) { this.organizations=organizations||new OrganizationRegistry(); this.usageMeter=usage||new UsageMeter(); }
49
+ plan(orgId){ const o=this.organizations.get(orgId); if(!o) throw new Error('Organization not found'); return PLANS[o.plan]||PLANS.free; }
50
+ usage(orgId,metric='runs'){ return this.usageMeter.get(orgId,metric); }
51
+ check(orgId,metric='runs',additional=1){ const p=this.plan(orgId), u=this.usage(orgId,metric).quantity||0; const limit=p[`${metric}PerMonth`] ?? p[metric]; return {allowed: limit===undefined || u+additional<=limit,metric,used:u,requested:additional,limit,plan:p.name}; }
52
+ consume(orgId,metric='runs',quantity=1){ const check=this.check(orgId,metric,quantity); if(!check.allowed){ const e=new Error(`Plan limit exceeded for ${metric}`); e.code='AGENTGATE_QUOTA_EXCEEDED'; e.entitlement=check; throw e; } return this.usageMeter.increment(orgId,metric,quantity); }
53
+ }
54
+
55
+ export class BillingAdapter {
56
+ constructor(options={}) { this.provider=options.provider||'none'; this.checkout=options.checkout|| (async ({orgId,plan})=>({provider:this.provider,orgId,plan,status:'not_configured'})); this.portal=options.portal|| (async ({orgId})=>({provider:this.provider,orgId,status:'not_configured'})); }
57
+ async createCheckout(input){ return this.checkout(input); }
58
+ async customerPortal(input){ return this.portal(input); }
59
+ }
60
+
61
+ export function createSaaSControl(options={}) {
62
+ const organizations=options.organizations||new OrganizationRegistry(options);
63
+ const usage=options.usage||new UsageMeter(options);
64
+ const entitlements=options.entitlements||new EntitlementService({organizations,usage});
65
+ const billing=options.billing||new BillingAdapter(options);
66
+ return {organizations,usage,entitlements,billing};
67
+ }
@@ -0,0 +1,42 @@
1
+ import { summarizeAttackResults } from './attack-lab.js';
2
+ import { analyzeBehavior, analyzeBlastRadius } from './behavior.js';
3
+
4
+ function safe(value) { return String(value ?? '').replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); }
5
+
6
+ export function generateSecurityReport({ attackResults = [], runs = [], policies = {}, metadata = {}, behavior, blastRadius } = {}) {
7
+ const summary = summarizeAttackResults(attackResults);
8
+ const findings = attackResults
9
+ .filter(item => !item.passed)
10
+ .map(item => ({ id: item.id, name: item.name, decision: item.decision, risk: Number(item.risk || 0), runId: item.runId || null }));
11
+ const topRisks = [...attackResults].sort((a,b) => Number(b.risk||0) - Number(a.risk||0)).slice(0,5).map(item => ({
12
+ id:item.id, name:item.name, risk:Number(item.risk||0), decision:item.decision, passed:Boolean(item.passed), runId:item.runId||null
13
+ }));
14
+ const behaviorAnalysis = behavior || analyzeBehavior(runs);
15
+ const blastRadiusAnalysis = blastRadius || analyzeBlastRadius(runs);
16
+ const generatedAt = new Date().toISOString();
17
+ return {
18
+ schemaVersion: '1.0',
19
+ product: 'AgentGate',
20
+ reportType: 'security-assessment',
21
+ generatedAt,
22
+ metadata: { environment: metadata.environment || 'production', agent: metadata.agent || 'unknown', ...metadata },
23
+ summary: { ...summary, findingCount: findings.length, runCount: runs.length },
24
+ findings,
25
+ topRisks,
26
+ policies,
27
+ behavior: behaviorAnalysis,
28
+ blastRadius: blastRadiusAnalysis,
29
+ attacks: attackResults.map(({ response, ...item }) => ({ ...item })),
30
+ runs: runs.map(run => ({ id: run.id, createdAt: run.createdAt, decision: run.decision, risk: run.risk, reason: run.reason, request: run.request }))
31
+ };
32
+ }
33
+
34
+ export function renderSecurityReportHTML(report) {
35
+ const s = report.summary;
36
+ const rows = report.attacks.map(item => `<tr><td>${safe(item.name)}</td><td>${safe(item.decision)}</td><td>${safe(item.risk)}</td><td>${item.passed ? 'PASS' : 'FINDING'}</td></tr>`).join('');
37
+ const status = safe(s.status);
38
+ const behavior = report.behavior || { status:'NORMAL', findingCount:0, maxRisk:0, findings:[] };
39
+ const blast = report.blastRadius || { status:'LOW', maxScore:0, assessments:[] };
40
+ const behaviorRows = behavior.findings.map(item => `<tr><td>${safe(item.title)}</td><td>${safe(item.severity)}</td><td>${safe(item.risk)}</td><td><code>${safe(item.action)}</code></td></tr>`).join('');
41
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AgentGate Security Report</title><style>body{margin:0;background:#08080d;color:#eee;font:15px system-ui,sans-serif}main{max-width:1000px;margin:auto;padding:40px}.hero,.card{background:#11111a;border:1px solid #29283a;border-radius:18px;padding:24px;margin-bottom:18px}.brand{color:#a78bfa;font-weight:800;letter-spacing:.08em}.status{font-size:28px;font-weight:800;margin:12px 0}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric{background:#0b0b12;border-radius:12px;padding:16px}.metric b{display:block;font-size:24px}.muted{color:#9292a4}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px;border-bottom:1px solid #262533}th{color:#aaa}code{color:#c4b5fd}.pill{display:inline-block;padding:6px 10px;border:1px solid #343246;border-radius:999px;margin-right:8px}@media(max-width:700px){main{padding:18px}.grid{grid-template-columns:repeat(2,1fr)}}</style></head><body><main><section class="hero"><div class="brand">AGENTGATE / SECURITY REPORT</div><div class="status">${status}</div><div class="muted">${safe(report.metadata.agent)} · ${safe(report.metadata.environment)} · ${safe(report.generatedAt)}</div></section><section class="card"><div class="grid"><div class="metric"><span class="muted">Attacks</span><b>${s.total}</b></div><div class="metric"><span class="muted">Passed</span><b>${s.passed}</b></div><div class="metric"><span class="muted">Findings</span><b>${s.failed}</b></div><div class="metric"><span class="muted">Avg risk</span><b>${s.averageRisk}</b></div></div></section><section class="card"><h2>Behavior analysis</h2><p><span class="pill">Status: ${safe(behavior.status)}</span><span class="pill">Findings: ${behavior.findingCount}</span><span class="pill">Max risk: ${behavior.maxRisk}</span></p><table><thead><tr><th>Finding</th><th>Severity</th><th>Risk</th><th>Action</th></tr></thead><tbody>${behaviorRows || '<tr><td colspan="4">No behavior findings</td></tr>'}</tbody></table></section><section class="card"><h2>Blast radius</h2><p><span class="pill">Status: ${safe(blast.status)}</span><span class="pill">Max score: ${blast.maxScore}</span><span class="pill">Runs: ${blast.runCount}</span></p><p class="muted">Deterministic estimate based on request metadata, not a guarantee of actual impact.</p></section><section class="card"><h2>Attack assessment</h2><table><thead><tr><th>Test</th><th>Decision</th><th>Risk</th><th>Result</th></tr></thead><tbody>${rows || '<tr><td colspan="4">No attack results</td></tr>'}</tbody></table></section><section class="card"><h2>Policies</h2><pre><code>${safe(JSON.stringify(report.policies,null,2))}</code></pre></section></main></body></html>`;
42
+ }
@@ -0,0 +1,92 @@
1
+ import { createMCPGateway } from './mcp-gateway.js';
2
+ import { runGatewayAttackLab } from './attack-lab.js';
3
+ import { inspectEgress } from './egress-guard.js';
4
+ import { evaluate } from './policy-engine.js';
5
+ import { createControlPlane } from './control-plane.js';
6
+ import { TenantRegistry } from './multi-tenant.js';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ function result(name, ok, details = {}) { return { name, ok: Boolean(ok), details }; }
12
+
13
+ async function httpJson(url, options = {}) {
14
+ const response = await fetch(url, options);
15
+ let body = null;
16
+ try { body = await response.json(); } catch { body = null; }
17
+ return { status: response.status, body };
18
+ }
19
+
20
+ export async function runSecurityValidation(options = {}) {
21
+ const counters = { read: 0, refund: 0, delete: 0, export_all: 0 };
22
+ const gateway = options.gateway || createMCPGateway({
23
+ mode: 'enforce',
24
+ maxRuns: Number(options.maxRuns || 2000),
25
+ policies: { productionBlock: true, approvalActions: ['refund'], approvalAmount: 5000, blockActions: ['export_all'] },
26
+ tools: [
27
+ { name: 'read', handler: async () => { counters.read++; return { ok: true }; } },
28
+ { name: 'refund', handler: async args => { counters.refund++; return { refunded: args.amount }; } },
29
+ { name: 'delete', handler: async () => { counters.delete++; return { deleted: true }; } },
30
+ { name: 'export_all', handler: async () => { counters.export_all++; return { secret: 'SHOULD_NOT_RUN' }; } },
31
+ { name: 'update_production', handler: async () => ({ changed: true }) },
32
+ { name: 'publish', handler: async () => ({ published: true }) }
33
+ ]
34
+ });
35
+ const checks = [];
36
+ const attacks = await runGatewayAttackLab(gateway);
37
+ checks.push(result('attack-lab', attacks.every(x => x.passed), { total: attacks.length, passed: attacks.filter(x => x.passed).length }));
38
+
39
+ const beforeDelete = counters.delete;
40
+ const blocked = await gateway.handle({ jsonrpc:'2.0', id:'validation-block', method:'tools/call', params:{ name:'delete', action:'delete', environment:'production', arguments:{ id:'x' }, agent:'validator', tenantId:'tenant_a' }});
41
+ checks.push(result('pre-execution-block', blocked?.result?._agentgate?.decision?.decision === 'BLOCK' && counters.delete === beforeDelete, { decision: blocked?.result?._agentgate?.decision?.decision, handlerExecuted: counters.delete !== beforeDelete }));
42
+
43
+ const beforeRefund = counters.refund;
44
+ const ask = await gateway.handle({ jsonrpc:'2.0', id:'validation-ask', method:'tools/call', params:{ name:'refund', action:'refund', environment:'development', arguments:{ amount:1200 }, agent:'validator', tenantId:'tenant_a' }});
45
+ const approvalId = ask.result?._agentgate?.approvalId;
46
+ checks.push(result('approval-boundary', ask?.result?._agentgate?.decision?.decision === 'ASK' && counters.refund === beforeRefund && Boolean(approvalId), { decision: ask?.result?._agentgate?.decision?.decision, executed: counters.refund !== beforeRefund, approvalId }));
47
+
48
+ const secret = inspectEgress(JSON.stringify({ token:'Bearer abcdefghijklmnopqrstuvwxyz123456', email:'x@example.com' }));
49
+ checks.push(result('egress-detection', Array.isArray(secret?.findings) && secret.findings.length > 0, { findings: secret?.findings?.length || 0 }));
50
+
51
+ const malformed = [null, {}, { action:null }, { action:'refund', amount:'not-a-number' }, { action:'delete', environment:123 }];
52
+ const malformedResults = malformed.map(input => { try { const x = evaluate(input || {}, { productionBlock:true }); return ['ALLOW','ASK','BLOCK'].includes(x.decision); } catch { return false; } });
53
+ checks.push(result('malformed-input-resilience', malformedResults.every(Boolean), { cases: malformedResults.length, passed: malformedResults.filter(Boolean).length }));
54
+
55
+ const tenantA = gateway.runs('tenant_a');
56
+ const tenantB = gateway.runs('tenant_b');
57
+ checks.push(result('tenant-run-scope', tenantA.every(x => x.tenantId === 'tenant_a') && tenantB.every(x => x.tenantId === 'tenant_b'), { tenantA: tenantA.length, tenantB: tenantB.length }));
58
+
59
+ const retention = gateway.retention?.() || {};
60
+ checks.push(result('replay-retention-status', retention.limit >= 1000 && retention.count >= 1 && typeof retention.nearLimit === 'boolean', retention));
61
+
62
+ // Exercise the actual HTTP control plane with two scoped tenant keys.
63
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agentgate-validation-'));
64
+ const registry = new TenantRegistry({ filePath: path.join(tempRoot, 'tenants.json'), keyPath: path.join(tempRoot, 'keys.json') });
65
+ const tenantOne = registry.create('Validation A');
66
+ const tenantTwo = registry.create('Validation B');
67
+ const keyA = registry.issueKey(tenantOne.id, { scopes: ['runs:read'] }).secret;
68
+ const keyB = registry.issueKey(tenantTwo.id, { scopes: ['runs:read'] }).secret;
69
+ await gateway.handle({ jsonrpc:'2.0', id:'tenant-a-seed', method:'tools/call', params:{ name:'read', action:'read', environment:'development', arguments:{ seed:'A' }, tenantId:tenantOne.id }});
70
+ await gateway.handle({ jsonrpc:'2.0', id:'tenant-b-seed', method:'tools/call', params:{ name:'read', action:'read', environment:'development', arguments:{ seed:'B' }, tenantId:tenantTwo.id }});
71
+ const control = createControlPlane({ gateway, tenantRegistry: registry, authRequired: true, rateLimit: 100000, tenantRateLimit: 100000 });
72
+ await new Promise((resolve, reject) => { control.server.listen(0, '127.0.0.1', resolve).on('error', reject); });
73
+ const address = control.server.address();
74
+ const base = `http://127.0.0.1:${address.port}`;
75
+ try {
76
+ const own = await httpJson(`${base}/api/runs`, { headers: { 'x-agentgate-key': keyA } });
77
+ const crossQuery = await httpJson(`${base}/api/runs?tenantId=${encodeURIComponent(tenantTwo.id)}`, { headers: { 'x-agentgate-key': keyA } });
78
+ const ownB = await httpJson(`${base}/api/runs`, { headers: { authorization: `Bearer ${keyB}` } });
79
+ checks.push(result('tenant-cross-tenant-http', own.status === 200 && Array.isArray(own.body?.runs) && own.body.runs.every(r => r.tenantId === tenantOne.id) && crossQuery.status === 403 && ownB.status === 200 && Array.isArray(ownB.body?.runs) && ownB.body.runs.every(r => r.tenantId === tenantTwo.id), {
80
+ tenantAStatus: own.status,
81
+ tenantAVisibleTenants: [...new Set((own.body?.runs || []).map(r => r.tenantId))],
82
+ crossTenantStatus: crossQuery.status,
83
+ tenantBStatus: ownB.status,
84
+ tenantBVisibleTenants: [...new Set((ownB.body?.runs || []).map(r => r.tenantId))]
85
+ }));
86
+ } finally {
87
+ await new Promise(resolve => control.server.close(resolve));
88
+ fs.rmSync(tempRoot, { recursive: true, force: true });
89
+ }
90
+
91
+ return { ok: checks.every(x => x.ok), checks, attacks, counters, generatedAt: new Date().toISOString() };
92
+ }