agentkit-guardrails 1.0.0

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 ADDED
@@ -0,0 +1,135 @@
1
+ # agentkit-guardrails
2
+
3
+ Reactive policy enforcement for AI agents. Watches metrics from **AgentLens** and automatically tightens **AgentGate** policies when thresholds are breached.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ ┌────────────┐ webhook ┌──────────────────────┐ Override API ┌────────────┐
9
+ │ AgentLens │ ──────────► │ agentkit-guardrails │ ─────────────► │ AgentGate │
10
+ │ (metrics) │ breach/ │ (this service) │ create/remove │ (policy) │
11
+ │ │ recovery │ │ overrides │ │
12
+ └────────────┘ └──────────────────────┘ └────────────┘
13
+ ```
14
+
15
+ **Flow:**
16
+ 1. AgentLens monitors agent metrics (error rate, latency, token usage, etc.)
17
+ 2. When a threshold is breached, AgentLens sends a webhook to this service
18
+ 3. This service creates a policy override in AgentGate (e.g., require approval for all tools)
19
+ 4. When the metric recovers, AgentLens sends a recovery webhook
20
+ 5. This service removes the override, restoring normal permissions
21
+
22
+ ## Quick Start
23
+
24
+ ### 1. Install
25
+
26
+ ```bash
27
+ npm install agentkit-guardrails
28
+ ```
29
+
30
+ ### 2. Configure
31
+
32
+ Create `config.yaml`:
33
+
34
+ ```yaml
35
+ agentgate:
36
+ url: http://localhost:3002
37
+ apiKey: your-api-key # optional
38
+
39
+ server:
40
+ port: 3010 # default: 3010
41
+
42
+ rules:
43
+ - metric: error_rate
44
+ action: require_approval # require_approval | deny | allow
45
+ toolPattern: "*" # glob pattern for tools to restrict
46
+ ttlSeconds: 3600 # override expires after 1 hour
47
+ reason: "Error rate exceeded threshold"
48
+
49
+ - metric: latency_p99
50
+ action: deny
51
+ toolPattern: "external_api.*"
52
+ ttlSeconds: 1800
53
+ reason: "Latency spike detected"
54
+ ```
55
+
56
+ ### 3. Configure AgentLens Thresholds
57
+
58
+ In AgentLens, set up threshold monitors that send webhooks to this service:
59
+
60
+ ```yaml
61
+ # AgentLens threshold config
62
+ thresholds:
63
+ - metric: error_rate
64
+ breach: 0.5
65
+ recovery: 0.3
66
+ webhook: http://localhost:3010/webhook
67
+ ```
68
+
69
+ ### 4. Run
70
+
71
+ ```bash
72
+ npx agentkit-guardrails config.yaml
73
+ ```
74
+
75
+ ## Configuration Reference
76
+
77
+ | Field | Type | Required | Default | Description |
78
+ |-------|------|----------|---------|-------------|
79
+ | `agentgate.url` | string (URL) | ✅ | — | AgentGate API base URL |
80
+ | `agentgate.apiKey` | string | ❌ | — | Bearer token for AgentGate API |
81
+ | `server.port` | number | ❌ | `3010` | Port for the webhook server |
82
+ | `rules[].metric` | string | ✅ | — | Metric name to match from webhooks |
83
+ | `rules[].action` | enum | ✅ | — | `require_approval`, `deny`, or `allow` |
84
+ | `rules[].toolPattern` | string | ❌ | `*` | Glob pattern for tools to restrict |
85
+ | `rules[].ttlSeconds` | number | ❌ | `3600` | Override auto-expires after this many seconds |
86
+ | `rules[].reason` | string | ❌ | `Guardrail triggered` | Human-readable reason stored with override |
87
+
88
+ ## Webhook Payload
89
+
90
+ AgentLens sends POST requests to `/webhook` with this JSON body:
91
+
92
+ ```json
93
+ {
94
+ "event": "breach",
95
+ "metric": "error_rate",
96
+ "currentValue": 0.85,
97
+ "threshold": 0.5,
98
+ "agentId": "agent-123",
99
+ "timestamp": "2026-02-13T09:00:00Z"
100
+ }
101
+ ```
102
+
103
+ `event` is either `"breach"` or `"recovery"`.
104
+
105
+ ## How Overrides Work
106
+
107
+ - **Creation:** On breach, an override is created in AgentGate restricting the matching tools for the specific agent.
108
+ - **TTL:** Overrides auto-expire after `ttlSeconds` even without recovery (safety net).
109
+ - **Recovery:** On recovery, the override is explicitly removed.
110
+ - **Idempotency:** Duplicate breach events for the same agent+metric are ignored — no second override is created.
111
+ - **Independence:** Each agent+metric pair is tracked independently. A breach on `error_rate` doesn't affect `latency_p99`.
112
+
113
+ ## Health Check
114
+
115
+ ```
116
+ GET /health → { "status": "ok" }
117
+ ```
118
+
119
+ ## Docker Compose Example
120
+
121
+ See `docker-compose.yml` for a complete 3-service setup.
122
+
123
+ ## Troubleshooting
124
+
125
+ | Problem | Solution |
126
+ |---------|----------|
127
+ | `502 AgentGate unreachable` | Check that AgentGate is running and `agentgate.url` is correct |
128
+ | Webhook returns `ignored` | The metric name doesn't match any rule in your config |
129
+ | Override not removed on recovery | Check AgentLens is sending recovery events; override may have TTL-expired already |
130
+ | Duplicate overrides | This shouldn't happen — the service is idempotent. Check logs for errors |
131
+ | Port already in use | Change `server.port` in config.yaml |
132
+
133
+ ## License
134
+
135
+ ISC
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const config_js_1 = require("../config.js");
5
+ const node_fs_1 = require("node:fs");
6
+ const node_path_1 = require("node:path");
7
+ const tmpDir = (0, node_path_1.join)(__dirname, '../../.tmp-test');
8
+ function writeYaml(name, content) {
9
+ (0, node_fs_1.mkdirSync)(tmpDir, { recursive: true });
10
+ const p = (0, node_path_1.join)(tmpDir, name);
11
+ (0, node_fs_1.writeFileSync)(p, content);
12
+ return p;
13
+ }
14
+ afterAll(() => { try {
15
+ (0, node_fs_1.rmSync)(tmpDir, { recursive: true });
16
+ }
17
+ catch { } });
18
+ (0, vitest_1.describe)('loadConfig', () => {
19
+ (0, vitest_1.it)('parses valid YAML config', () => {
20
+ const p = writeYaml('valid.yaml', `
21
+ agentgate:
22
+ url: http://localhost:3002
23
+ server:
24
+ port: 3010
25
+ rules:
26
+ - metric: error_rate
27
+ action: require_approval
28
+ toolPattern: "*"
29
+ ttlSeconds: 3600
30
+ reason: "Error rate high"
31
+ `);
32
+ const config = (0, config_js_1.loadConfig)(p);
33
+ (0, vitest_1.expect)(config.agentgate.url).toBe('http://localhost:3002');
34
+ (0, vitest_1.expect)(config.rules).toHaveLength(1);
35
+ (0, vitest_1.expect)(config.rules[0].metric).toBe('error_rate');
36
+ });
37
+ (0, vitest_1.it)('rejects config with no rules', () => {
38
+ const p = writeYaml('norules.yaml', `
39
+ agentgate:
40
+ url: http://localhost:3002
41
+ server:
42
+ port: 3010
43
+ rules: []
44
+ `);
45
+ (0, vitest_1.expect)(() => (0, config_js_1.loadConfig)(p)).toThrow();
46
+ });
47
+ (0, vitest_1.it)('rejects config with invalid url', () => {
48
+ const p = writeYaml('badurl.yaml', `
49
+ agentgate:
50
+ url: not-a-url
51
+ server:
52
+ port: 3010
53
+ rules:
54
+ - metric: x
55
+ action: deny
56
+ `);
57
+ (0, vitest_1.expect)(() => (0, config_js_1.loadConfig)(p)).toThrow();
58
+ });
59
+ (0, vitest_1.it)('applies defaults for optional fields', () => {
60
+ const p = writeYaml('defaults.yaml', `
61
+ agentgate:
62
+ url: http://localhost:3002
63
+ server:
64
+ port: 3010
65
+ rules:
66
+ - metric: latency
67
+ action: deny
68
+ `);
69
+ const config = (0, config_js_1.loadConfig)(p);
70
+ (0, vitest_1.expect)(config.rules[0].toolPattern).toBe('*');
71
+ (0, vitest_1.expect)(config.rules[0].ttlSeconds).toBe(3600);
72
+ (0, vitest_1.expect)(config.rules[0].reason).toBe('Guardrail triggered');
73
+ });
74
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const gate_client_js_1 = require("../gate-client.js");
5
+ const originalFetch = globalThis.fetch;
6
+ (0, vitest_1.describe)('GateClient', () => {
7
+ let mockFetch;
8
+ (0, vitest_1.beforeEach)(() => {
9
+ mockFetch = vitest_1.vi.fn();
10
+ globalThis.fetch = mockFetch;
11
+ });
12
+ (0, vitest_1.afterEach)(() => {
13
+ globalThis.fetch = originalFetch;
14
+ });
15
+ (0, vitest_1.it)('createOverride sends POST and returns result', async () => {
16
+ mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 'ovr-1', agentId: 'a1', toolPattern: '*', action: 'deny', reason: 'test', ttlSeconds: 60 }) });
17
+ const client = new gate_client_js_1.GateClient('http://localhost:3002', 'key123');
18
+ const result = await client.createOverride({ agentId: 'a1', toolPattern: '*', action: 'deny', reason: 'test', ttlSeconds: 60 });
19
+ (0, vitest_1.expect)(result.id).toBe('ovr-1');
20
+ (0, vitest_1.expect)(mockFetch).toHaveBeenCalledWith('http://localhost:3002/api/overrides', vitest_1.expect.objectContaining({ method: 'POST' }));
21
+ // Check auth header
22
+ const headers = mockFetch.mock.calls[0][1].headers;
23
+ (0, vitest_1.expect)(headers['Authorization']).toBe('Bearer key123');
24
+ });
25
+ (0, vitest_1.it)('removeOverride sends DELETE', async () => {
26
+ mockFetch.mockResolvedValue({ ok: true });
27
+ const client = new gate_client_js_1.GateClient('http://localhost:3002');
28
+ await client.removeOverride('ovr-1');
29
+ (0, vitest_1.expect)(mockFetch).toHaveBeenCalledWith('http://localhost:3002/api/overrides/ovr-1', vitest_1.expect.objectContaining({ method: 'DELETE' }));
30
+ });
31
+ (0, vitest_1.it)('throws on non-ok response', async () => {
32
+ mockFetch.mockResolvedValue({ ok: false, status: 500 });
33
+ const client = new gate_client_js_1.GateClient('http://localhost:3002');
34
+ await (0, vitest_1.expect)(client.listOverrides()).rejects.toThrow('500');
35
+ });
36
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const server_js_1 = require("../server.js");
5
+ const multiRuleConfig = {
6
+ agentgate: { url: 'http://localhost:3002' },
7
+ server: { port: 0 },
8
+ rules: [
9
+ { metric: 'error_rate', action: 'require_approval', toolPattern: '*', ttlSeconds: 3600, reason: 'Error rate high' },
10
+ { metric: 'latency_p99', action: 'deny', toolPattern: 'external_api.*', ttlSeconds: 1800, reason: 'Latency spike' },
11
+ ],
12
+ };
13
+ function makeClient() {
14
+ let nextId = 1;
15
+ return {
16
+ createOverride: vitest_1.vi.fn().mockImplementation(async () => ({
17
+ id: `ovr-${nextId++}`,
18
+ agentId: 'a',
19
+ toolPattern: '*',
20
+ action: 'require_approval',
21
+ reason: 'test',
22
+ ttlSeconds: 3600,
23
+ })),
24
+ removeOverride: vitest_1.vi.fn().mockResolvedValue(undefined),
25
+ listOverrides: vitest_1.vi.fn().mockResolvedValue([]),
26
+ };
27
+ }
28
+ const webhook = (event, metric, agentId = 'agent-1') => ({
29
+ event,
30
+ metric,
31
+ currentValue: event === 'breach' ? 0.9 : 0.1,
32
+ threshold: 0.5,
33
+ agentId,
34
+ timestamp: new Date().toISOString(),
35
+ });
36
+ (0, vitest_1.describe)('integration: full breach → recovery → idempotency flow', () => {
37
+ (0, vitest_1.it)('handles breach, recovery, and duplicate breach end-to-end', async () => {
38
+ const client = makeClient();
39
+ const { app, activeOverrides } = (0, server_js_1.buildServer)(multiRuleConfig, client);
40
+ // 1. Breach → override created
41
+ const r1 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'error_rate') });
42
+ (0, vitest_1.expect)(r1.statusCode).toBe(201);
43
+ (0, vitest_1.expect)(JSON.parse(r1.body).status).toBe('override_created');
44
+ (0, vitest_1.expect)(activeOverrides.size).toBe(1);
45
+ // 2. Recovery → override removed
46
+ const r2 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('recovery', 'error_rate') });
47
+ (0, vitest_1.expect)(r2.statusCode).toBe(200);
48
+ (0, vitest_1.expect)(JSON.parse(r2.body).status).toBe('override_removed');
49
+ (0, vitest_1.expect)(activeOverrides.size).toBe(0);
50
+ // 3. Breach again → new override created (not duplicate since previous was removed)
51
+ const r3 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'error_rate') });
52
+ (0, vitest_1.expect)(r3.statusCode).toBe(201);
53
+ // 4. Duplicate breach → idempotent
54
+ const r4 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'error_rate') });
55
+ (0, vitest_1.expect)(r4.statusCode).toBe(200);
56
+ (0, vitest_1.expect)(JSON.parse(r4.body).status).toBe('already_active');
57
+ (0, vitest_1.expect)(client.createOverride).toHaveBeenCalledTimes(2); // only 2 creates total
58
+ });
59
+ (0, vitest_1.it)('handles multiple independent rules for different metrics', async () => {
60
+ const client = makeClient();
61
+ const { app, activeOverrides } = (0, server_js_1.buildServer)(multiRuleConfig, client);
62
+ // Breach error_rate
63
+ const r1 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'error_rate') });
64
+ (0, vitest_1.expect)(r1.statusCode).toBe(201);
65
+ // Breach latency_p99 — independent rule
66
+ const r2 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'latency_p99') });
67
+ (0, vitest_1.expect)(r2.statusCode).toBe(201);
68
+ // Both active
69
+ (0, vitest_1.expect)(activeOverrides.size).toBe(2);
70
+ (0, vitest_1.expect)(client.createOverride).toHaveBeenCalledTimes(2);
71
+ // Verify correct toolPatterns were passed
72
+ const calls = client.createOverride.mock.calls;
73
+ (0, vitest_1.expect)(calls[0][0].toolPattern).toBe('*');
74
+ (0, vitest_1.expect)(calls[1][0].toolPattern).toBe('external_api.*');
75
+ // Recover one, other stays
76
+ await app.inject({ method: 'POST', url: '/webhook', payload: webhook('recovery', 'error_rate') });
77
+ (0, vitest_1.expect)(activeOverrides.size).toBe(1);
78
+ (0, vitest_1.expect)(activeOverrides.has('agent-1::latency_p99')).toBe(true);
79
+ });
80
+ });
81
+ (0, vitest_1.describe)('integration: health check', () => {
82
+ (0, vitest_1.it)('responds to health check', async () => {
83
+ const { app } = (0, server_js_1.buildServer)(multiRuleConfig, makeClient());
84
+ const res = await app.inject({ method: 'GET', url: '/health' });
85
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
86
+ (0, vitest_1.expect)(JSON.parse(res.body)).toEqual({ status: 'ok' });
87
+ });
88
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const server_js_1 = require("../server.js");
5
+ const testConfig = {
6
+ agentgate: { url: 'http://localhost:3002' },
7
+ server: { port: 0 },
8
+ rules: [
9
+ { metric: 'error_rate', action: 'require_approval', toolPattern: '*', ttlSeconds: 3600, reason: 'Error rate high' },
10
+ ],
11
+ };
12
+ function makeClient() {
13
+ return {
14
+ createOverride: vitest_1.vi.fn().mockResolvedValue({ id: 'ovr-123', agentId: 'agent-1', toolPattern: '*', action: 'require_approval', reason: 'test', ttlSeconds: 3600 }),
15
+ removeOverride: vitest_1.vi.fn().mockResolvedValue(undefined),
16
+ listOverrides: vitest_1.vi.fn().mockResolvedValue([]),
17
+ };
18
+ }
19
+ const breach = (metric = 'error_rate') => ({ event: 'breach', metric, currentValue: 0.9, threshold: 0.5, agentId: 'agent-1', timestamp: new Date().toISOString() });
20
+ const recovery = (metric = 'error_rate') => ({ event: 'recovery', metric, currentValue: 0.3, threshold: 0.5, agentId: 'agent-1', timestamp: new Date().toISOString() });
21
+ (0, vitest_1.describe)('webhook handler', () => {
22
+ (0, vitest_1.it)('rejects invalid payload', async () => {
23
+ const { app } = (0, server_js_1.buildServer)(testConfig, makeClient());
24
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: { bad: true } });
25
+ (0, vitest_1.expect)(res.statusCode).toBe(400);
26
+ });
27
+ (0, vitest_1.it)('creates override on breach event', async () => {
28
+ const client = makeClient();
29
+ const { app, activeOverrides } = (0, server_js_1.buildServer)(testConfig, client);
30
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
31
+ (0, vitest_1.expect)(res.statusCode).toBe(201);
32
+ (0, vitest_1.expect)(client.createOverride).toHaveBeenCalledOnce();
33
+ (0, vitest_1.expect)(activeOverrides.get('agent-1::error_rate')).toBe('ovr-123');
34
+ });
35
+ (0, vitest_1.it)('is idempotent — duplicate breach does not create second override', async () => {
36
+ const client = makeClient();
37
+ const { app } = (0, server_js_1.buildServer)(testConfig, client);
38
+ await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
39
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
40
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
41
+ (0, vitest_1.expect)(JSON.parse(res.body).status).toBe('already_active');
42
+ (0, vitest_1.expect)(client.createOverride).toHaveBeenCalledTimes(1);
43
+ });
44
+ (0, vitest_1.it)('removes override on recovery event', async () => {
45
+ const client = makeClient();
46
+ const { app, activeOverrides } = (0, server_js_1.buildServer)(testConfig, client);
47
+ await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
48
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: recovery() });
49
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
50
+ (0, vitest_1.expect)(JSON.parse(res.body).status).toBe('override_removed');
51
+ (0, vitest_1.expect)(client.removeOverride).toHaveBeenCalledWith('ovr-123');
52
+ (0, vitest_1.expect)(activeOverrides.size).toBe(0);
53
+ });
54
+ (0, vitest_1.it)('returns ignored for unknown metric', async () => {
55
+ const { app } = (0, server_js_1.buildServer)(testConfig, makeClient());
56
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: breach('unknown_metric') });
57
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
58
+ (0, vitest_1.expect)(JSON.parse(res.body).status).toBe('ignored');
59
+ });
60
+ (0, vitest_1.it)('returns 502 when AgentGate is unreachable on breach', async () => {
61
+ const client = makeClient();
62
+ client.createOverride.mockRejectedValue(new Error('connection refused'));
63
+ const { app } = (0, server_js_1.buildServer)(testConfig, client);
64
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
65
+ (0, vitest_1.expect)(res.statusCode).toBe(502);
66
+ });
67
+ (0, vitest_1.it)('returns 502 when AgentGate is unreachable on recovery', async () => {
68
+ const client = makeClient();
69
+ client.removeOverride.mockRejectedValue(new Error('connection refused'));
70
+ const { app, activeOverrides } = (0, server_js_1.buildServer)(testConfig, client);
71
+ activeOverrides.set('agent-1::error_rate', 'ovr-123');
72
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: recovery() });
73
+ (0, vitest_1.expect)(res.statusCode).toBe(502);
74
+ });
75
+ });
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ declare const RuleSchema: z.ZodObject<{
3
+ metric: z.ZodString;
4
+ action: z.ZodEnum<{
5
+ require_approval: "require_approval";
6
+ deny: "deny";
7
+ allow: "allow";
8
+ }>;
9
+ toolPattern: z.ZodDefault<z.ZodString>;
10
+ ttlSeconds: z.ZodDefault<z.ZodNumber>;
11
+ reason: z.ZodDefault<z.ZodString>;
12
+ }, z.core.$strip>;
13
+ declare const ConfigSchema: z.ZodObject<{
14
+ agentgate: z.ZodObject<{
15
+ url: z.ZodString;
16
+ apiKey: z.ZodOptional<z.ZodString>;
17
+ }, z.core.$strip>;
18
+ server: z.ZodObject<{
19
+ port: z.ZodDefault<z.ZodNumber>;
20
+ }, z.core.$strip>;
21
+ rules: z.ZodArray<z.ZodObject<{
22
+ metric: z.ZodString;
23
+ action: z.ZodEnum<{
24
+ require_approval: "require_approval";
25
+ deny: "deny";
26
+ allow: "allow";
27
+ }>;
28
+ toolPattern: z.ZodDefault<z.ZodString>;
29
+ ttlSeconds: z.ZodDefault<z.ZodNumber>;
30
+ reason: z.ZodDefault<z.ZodString>;
31
+ }, z.core.$strip>>;
32
+ }, z.core.$strip>;
33
+ export type Config = z.infer<typeof ConfigSchema>;
34
+ export type Rule = z.infer<typeof RuleSchema>;
35
+ export declare function loadConfig(path: string): Config;
36
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadConfig = loadConfig;
4
+ const zod_1 = require("zod");
5
+ const node_fs_1 = require("node:fs");
6
+ const yaml_1 = require("yaml");
7
+ const RuleSchema = zod_1.z.object({
8
+ metric: zod_1.z.string(),
9
+ action: zod_1.z.enum(['require_approval', 'deny', 'allow']),
10
+ toolPattern: zod_1.z.string().default('*'),
11
+ ttlSeconds: zod_1.z.number().positive().default(3600),
12
+ reason: zod_1.z.string().default('Guardrail triggered'),
13
+ });
14
+ const ConfigSchema = zod_1.z.object({
15
+ agentgate: zod_1.z.object({
16
+ url: zod_1.z.string().url(),
17
+ apiKey: zod_1.z.string().optional(),
18
+ }),
19
+ server: zod_1.z.object({
20
+ port: zod_1.z.number().int().positive().default(3010),
21
+ }),
22
+ rules: zod_1.z.array(RuleSchema).min(1),
23
+ });
24
+ function loadConfig(path) {
25
+ const raw = (0, node_fs_1.readFileSync)(path, 'utf-8');
26
+ const parsed = (0, yaml_1.parse)(raw);
27
+ return ConfigSchema.parse(parsed);
28
+ }
@@ -0,0 +1,19 @@
1
+ export interface OverrideRequest {
2
+ agentId: string;
3
+ toolPattern: string;
4
+ action: string;
5
+ reason: string;
6
+ ttlSeconds: number;
7
+ }
8
+ export interface Override extends OverrideRequest {
9
+ id: string;
10
+ }
11
+ export declare class GateClient {
12
+ private baseUrl;
13
+ private apiKey?;
14
+ constructor(baseUrl: string, apiKey?: string | undefined);
15
+ private headers;
16
+ createOverride(override: OverrideRequest): Promise<Override>;
17
+ removeOverride(id: string): Promise<void>;
18
+ listOverrides(): Promise<Override[]>;
19
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GateClient = void 0;
4
+ class GateClient {
5
+ baseUrl;
6
+ apiKey;
7
+ constructor(baseUrl, apiKey) {
8
+ this.baseUrl = baseUrl;
9
+ this.apiKey = apiKey;
10
+ }
11
+ headers() {
12
+ const h = { 'Content-Type': 'application/json' };
13
+ if (this.apiKey)
14
+ h['Authorization'] = `Bearer ${this.apiKey}`;
15
+ return h;
16
+ }
17
+ async createOverride(override) {
18
+ const res = await fetch(`${this.baseUrl}/api/overrides`, {
19
+ method: 'POST',
20
+ headers: this.headers(),
21
+ body: JSON.stringify(override),
22
+ });
23
+ if (!res.ok)
24
+ throw new Error(`AgentGate POST /api/overrides failed: ${res.status}`);
25
+ return res.json();
26
+ }
27
+ async removeOverride(id) {
28
+ const res = await fetch(`${this.baseUrl}/api/overrides/${id}`, {
29
+ method: 'DELETE',
30
+ headers: this.headers(),
31
+ });
32
+ if (!res.ok)
33
+ throw new Error(`AgentGate DELETE /api/overrides/${id} failed: ${res.status}`);
34
+ }
35
+ async listOverrides() {
36
+ const res = await fetch(`${this.baseUrl}/api/overrides`, {
37
+ headers: this.headers(),
38
+ });
39
+ if (!res.ok)
40
+ throw new Error(`AgentGate GET /api/overrides failed: ${res.status}`);
41
+ return res.json();
42
+ }
43
+ }
44
+ exports.GateClient = GateClient;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const config_js_1 = require("./config.js");
5
+ const server_js_1 = require("./server.js");
6
+ const gate_client_js_1 = require("./gate-client.js");
7
+ const configPath = process.argv[2] || 'config.yaml';
8
+ async function main() {
9
+ const config = (0, config_js_1.loadConfig)(configPath);
10
+ const gateClient = new gate_client_js_1.GateClient(config.agentgate.url, config.agentgate.apiKey);
11
+ const { app } = (0, server_js_1.buildServer)(config, gateClient);
12
+ await app.listen({ port: config.server.port, host: '0.0.0.0' });
13
+ console.log(`agentkit-guardrails listening on port ${config.server.port}`);
14
+ }
15
+ main().catch((err) => {
16
+ console.error('Fatal:', err);
17
+ process.exit(1);
18
+ });
@@ -0,0 +1,22 @@
1
+ import { FastifyInstance } from 'fastify';
2
+ import { z } from 'zod';
3
+ import { Config } from './config.js';
4
+ import { GateClient } from './gate-client.js';
5
+ declare const WebhookPayload: z.ZodObject<{
6
+ event: z.ZodEnum<{
7
+ breach: "breach";
8
+ recovery: "recovery";
9
+ }>;
10
+ metric: z.ZodString;
11
+ currentValue: z.ZodNumber;
12
+ threshold: z.ZodNumber;
13
+ agentId: z.ZodString;
14
+ timestamp: z.ZodString;
15
+ }, z.core.$strip>;
16
+ export type WebhookEvent = z.infer<typeof WebhookPayload>;
17
+ export interface GuardrailsServer {
18
+ app: FastifyInstance;
19
+ activeOverrides: Map<string, string>;
20
+ }
21
+ export declare function buildServer(config: Config, gateClient?: GateClient): GuardrailsServer;
22
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildServer = buildServer;
7
+ const fastify_1 = __importDefault(require("fastify"));
8
+ const zod_1 = require("zod");
9
+ const gate_client_js_1 = require("./gate-client.js");
10
+ const WebhookPayload = zod_1.z.object({
11
+ event: zod_1.z.enum(['breach', 'recovery']),
12
+ metric: zod_1.z.string(),
13
+ currentValue: zod_1.z.number(),
14
+ threshold: zod_1.z.number(),
15
+ agentId: zod_1.z.string(),
16
+ timestamp: zod_1.z.string(),
17
+ });
18
+ function matchRule(metric, rules) {
19
+ return rules.find((r) => r.metric === metric);
20
+ }
21
+ function overrideKey(agentId, metric) {
22
+ return `${agentId}::${metric}`;
23
+ }
24
+ function buildServer(config, gateClient) {
25
+ const app = (0, fastify_1.default)({ logger: false });
26
+ const client = gateClient ?? new gate_client_js_1.GateClient(config.agentgate.url, config.agentgate.apiKey);
27
+ const activeOverrides = new Map();
28
+ app.post('/webhook', async (request, reply) => {
29
+ const parseResult = WebhookPayload.safeParse(request.body);
30
+ if (!parseResult.success) {
31
+ return reply.status(400).send({ error: 'Invalid payload', details: parseResult.error.issues });
32
+ }
33
+ const payload = parseResult.data;
34
+ const rule = matchRule(payload.metric, config.rules);
35
+ if (!rule) {
36
+ return reply.status(200).send({ status: 'ignored', reason: 'no matching rule' });
37
+ }
38
+ const key = overrideKey(payload.agentId, payload.metric);
39
+ if (payload.event === 'breach') {
40
+ if (activeOverrides.has(key)) {
41
+ return reply.status(200).send({ status: 'already_active', overrideId: activeOverrides.get(key) });
42
+ }
43
+ try {
44
+ const override = await client.createOverride({
45
+ agentId: payload.agentId,
46
+ toolPattern: rule.toolPattern,
47
+ action: rule.action,
48
+ reason: rule.reason,
49
+ ttlSeconds: rule.ttlSeconds,
50
+ });
51
+ if (!override || !override.id) {
52
+ throw new Error('Invalid response from AgentGate: missing override id');
53
+ }
54
+ activeOverrides.set(key, override.id);
55
+ return reply.status(201).send({ status: 'override_created', overrideId: override.id });
56
+ }
57
+ catch (err) {
58
+ return reply.status(502).send({ error: 'AgentGate unreachable', detail: String(err) });
59
+ }
60
+ }
61
+ if (payload.event === 'recovery') {
62
+ const overrideId = activeOverrides.get(key);
63
+ if (!overrideId) {
64
+ return reply.status(200).send({ status: 'no_active_override' });
65
+ }
66
+ try {
67
+ await client.removeOverride(overrideId);
68
+ activeOverrides.delete(key);
69
+ return reply.status(200).send({ status: 'override_removed', overrideId });
70
+ }
71
+ catch (err) {
72
+ return reply.status(502).send({ error: 'AgentGate unreachable', detail: String(err) });
73
+ }
74
+ }
75
+ });
76
+ app.get('/health', async () => ({ status: 'ok' }));
77
+ return { app, activeOverrides };
78
+ }
@@ -0,0 +1,27 @@
1
+ services:
2
+ agentgate:
3
+ image: agentgate:latest
4
+ ports:
5
+ - "3002:3002"
6
+ environment:
7
+ - PORT=3002
8
+
9
+ agentlens:
10
+ image: agentlens:latest
11
+ ports:
12
+ - "3001:3001"
13
+ environment:
14
+ - PORT=3001
15
+ - WEBHOOK_URL=http://guardrails:3010/webhook
16
+ depends_on:
17
+ - agentgate
18
+
19
+ guardrails:
20
+ image: agentkit-guardrails:latest
21
+ ports:
22
+ - "3010:3010"
23
+ volumes:
24
+ - ./config.yaml:/app/config.yaml:ro
25
+ command: ["node", "dist/index.js", "/app/config.yaml"]
26
+ depends_on:
27
+ - agentgate
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "agentkit-guardrails",
3
+ "version": "1.0.0",
4
+ "description": "Reactive policy enforcement: auto-tighten AgentGate policies when AgentLens metrics breach thresholds",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "agentkit-guardrails": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "test": "vitest run",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "keywords": ["agentkit", "guardrails", "agentgate", "agentlens", "policy", "ai-safety"],
15
+ "author": "Amit Paz",
16
+ "license": "ISC",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/amitpaz/agentkit-guardrails"
20
+ },
21
+ "type": "commonjs",
22
+ "dependencies": {
23
+ "fastify": "^5.7.4",
24
+ "yaml": "^2.8.2",
25
+ "zod": "^4.3.6"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^25.2.3",
29
+ "tsx": "^4.21.0",
30
+ "typescript": "^5.9.3",
31
+ "vitest": "^4.0.18"
32
+ }
33
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { loadConfig } from '../config.js';
3
+ import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ const tmpDir = join(__dirname, '../../.tmp-test');
7
+
8
+ function writeYaml(name: string, content: string): string {
9
+ mkdirSync(tmpDir, { recursive: true });
10
+ const p = join(tmpDir, name);
11
+ writeFileSync(p, content);
12
+ return p;
13
+ }
14
+
15
+ afterAll(() => { try { rmSync(tmpDir, { recursive: true }); } catch {} });
16
+
17
+ describe('loadConfig', () => {
18
+ it('parses valid YAML config', () => {
19
+ const p = writeYaml('valid.yaml', `
20
+ agentgate:
21
+ url: http://localhost:3002
22
+ server:
23
+ port: 3010
24
+ rules:
25
+ - metric: error_rate
26
+ action: require_approval
27
+ toolPattern: "*"
28
+ ttlSeconds: 3600
29
+ reason: "Error rate high"
30
+ `);
31
+ const config = loadConfig(p);
32
+ expect(config.agentgate.url).toBe('http://localhost:3002');
33
+ expect(config.rules).toHaveLength(1);
34
+ expect(config.rules[0].metric).toBe('error_rate');
35
+ });
36
+
37
+ it('rejects config with no rules', () => {
38
+ const p = writeYaml('norules.yaml', `
39
+ agentgate:
40
+ url: http://localhost:3002
41
+ server:
42
+ port: 3010
43
+ rules: []
44
+ `);
45
+ expect(() => loadConfig(p)).toThrow();
46
+ });
47
+
48
+ it('rejects config with invalid url', () => {
49
+ const p = writeYaml('badurl.yaml', `
50
+ agentgate:
51
+ url: not-a-url
52
+ server:
53
+ port: 3010
54
+ rules:
55
+ - metric: x
56
+ action: deny
57
+ `);
58
+ expect(() => loadConfig(p)).toThrow();
59
+ });
60
+
61
+ it('applies defaults for optional fields', () => {
62
+ const p = writeYaml('defaults.yaml', `
63
+ agentgate:
64
+ url: http://localhost:3002
65
+ server:
66
+ port: 3010
67
+ rules:
68
+ - metric: latency
69
+ action: deny
70
+ `);
71
+ const config = loadConfig(p);
72
+ expect(config.rules[0].toolPattern).toBe('*');
73
+ expect(config.rules[0].ttlSeconds).toBe(3600);
74
+ expect(config.rules[0].reason).toBe('Guardrail triggered');
75
+ });
76
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { GateClient } from '../gate-client.js';
3
+
4
+ const originalFetch = globalThis.fetch;
5
+
6
+ describe('GateClient', () => {
7
+ let mockFetch: ReturnType<typeof vi.fn>;
8
+
9
+ beforeEach(() => {
10
+ mockFetch = vi.fn();
11
+ globalThis.fetch = mockFetch;
12
+ });
13
+
14
+ afterEach(() => {
15
+ globalThis.fetch = originalFetch;
16
+ });
17
+
18
+ it('createOverride sends POST and returns result', async () => {
19
+ mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 'ovr-1', agentId: 'a1', toolPattern: '*', action: 'deny', reason: 'test', ttlSeconds: 60 }) });
20
+ const client = new GateClient('http://localhost:3002', 'key123');
21
+ const result = await client.createOverride({ agentId: 'a1', toolPattern: '*', action: 'deny', reason: 'test', ttlSeconds: 60 });
22
+ expect(result.id).toBe('ovr-1');
23
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3002/api/overrides', expect.objectContaining({ method: 'POST' }));
24
+ // Check auth header
25
+ const headers = mockFetch.mock.calls[0][1].headers;
26
+ expect(headers['Authorization']).toBe('Bearer key123');
27
+ });
28
+
29
+ it('removeOverride sends DELETE', async () => {
30
+ mockFetch.mockResolvedValue({ ok: true });
31
+ const client = new GateClient('http://localhost:3002');
32
+ await client.removeOverride('ovr-1');
33
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3002/api/overrides/ovr-1', expect.objectContaining({ method: 'DELETE' }));
34
+ });
35
+
36
+ it('throws on non-ok response', async () => {
37
+ mockFetch.mockResolvedValue({ ok: false, status: 500 });
38
+ const client = new GateClient('http://localhost:3002');
39
+ await expect(client.listOverrides()).rejects.toThrow('500');
40
+ });
41
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { buildServer } from '../server.js';
3
+ import { Config } from '../config.js';
4
+ import { GateClient } from '../gate-client.js';
5
+
6
+ const multiRuleConfig: Config = {
7
+ agentgate: { url: 'http://localhost:3002' },
8
+ server: { port: 0 },
9
+ rules: [
10
+ { metric: 'error_rate', action: 'require_approval', toolPattern: '*', ttlSeconds: 3600, reason: 'Error rate high' },
11
+ { metric: 'latency_p99', action: 'deny', toolPattern: 'external_api.*', ttlSeconds: 1800, reason: 'Latency spike' },
12
+ ],
13
+ };
14
+
15
+ function makeClient() {
16
+ let nextId = 1;
17
+ return {
18
+ createOverride: vi.fn().mockImplementation(async () => ({
19
+ id: `ovr-${nextId++}`,
20
+ agentId: 'a',
21
+ toolPattern: '*',
22
+ action: 'require_approval',
23
+ reason: 'test',
24
+ ttlSeconds: 3600,
25
+ })),
26
+ removeOverride: vi.fn().mockResolvedValue(undefined),
27
+ listOverrides: vi.fn().mockResolvedValue([]),
28
+ } as unknown as GateClient;
29
+ }
30
+
31
+ const webhook = (event: string, metric: string, agentId = 'agent-1') => ({
32
+ event,
33
+ metric,
34
+ currentValue: event === 'breach' ? 0.9 : 0.1,
35
+ threshold: 0.5,
36
+ agentId,
37
+ timestamp: new Date().toISOString(),
38
+ });
39
+
40
+ describe('integration: full breach → recovery → idempotency flow', () => {
41
+ it('handles breach, recovery, and duplicate breach end-to-end', async () => {
42
+ const client = makeClient();
43
+ const { app, activeOverrides } = buildServer(multiRuleConfig, client);
44
+
45
+ // 1. Breach → override created
46
+ const r1 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'error_rate') });
47
+ expect(r1.statusCode).toBe(201);
48
+ expect(JSON.parse(r1.body).status).toBe('override_created');
49
+ expect(activeOverrides.size).toBe(1);
50
+
51
+ // 2. Recovery → override removed
52
+ const r2 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('recovery', 'error_rate') });
53
+ expect(r2.statusCode).toBe(200);
54
+ expect(JSON.parse(r2.body).status).toBe('override_removed');
55
+ expect(activeOverrides.size).toBe(0);
56
+
57
+ // 3. Breach again → new override created (not duplicate since previous was removed)
58
+ const r3 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'error_rate') });
59
+ expect(r3.statusCode).toBe(201);
60
+
61
+ // 4. Duplicate breach → idempotent
62
+ const r4 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'error_rate') });
63
+ expect(r4.statusCode).toBe(200);
64
+ expect(JSON.parse(r4.body).status).toBe('already_active');
65
+ expect(client.createOverride).toHaveBeenCalledTimes(2); // only 2 creates total
66
+ });
67
+
68
+ it('handles multiple independent rules for different metrics', async () => {
69
+ const client = makeClient();
70
+ const { app, activeOverrides } = buildServer(multiRuleConfig, client);
71
+
72
+ // Breach error_rate
73
+ const r1 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'error_rate') });
74
+ expect(r1.statusCode).toBe(201);
75
+
76
+ // Breach latency_p99 — independent rule
77
+ const r2 = await app.inject({ method: 'POST', url: '/webhook', payload: webhook('breach', 'latency_p99') });
78
+ expect(r2.statusCode).toBe(201);
79
+
80
+ // Both active
81
+ expect(activeOverrides.size).toBe(2);
82
+ expect(client.createOverride).toHaveBeenCalledTimes(2);
83
+
84
+ // Verify correct toolPatterns were passed
85
+ const calls = (client.createOverride as any).mock.calls;
86
+ expect(calls[0][0].toolPattern).toBe('*');
87
+ expect(calls[1][0].toolPattern).toBe('external_api.*');
88
+
89
+ // Recover one, other stays
90
+ await app.inject({ method: 'POST', url: '/webhook', payload: webhook('recovery', 'error_rate') });
91
+ expect(activeOverrides.size).toBe(1);
92
+ expect(activeOverrides.has('agent-1::latency_p99')).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe('integration: health check', () => {
97
+ it('responds to health check', async () => {
98
+ const { app } = buildServer(multiRuleConfig, makeClient());
99
+ const res = await app.inject({ method: 'GET', url: '/health' });
100
+ expect(res.statusCode).toBe(200);
101
+ expect(JSON.parse(res.body)).toEqual({ status: 'ok' });
102
+ });
103
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { buildServer } from '../server.js';
3
+ import { Config } from '../config.js';
4
+ import { GateClient } from '../gate-client.js';
5
+
6
+ const testConfig: Config = {
7
+ agentgate: { url: 'http://localhost:3002' },
8
+ server: { port: 0 },
9
+ rules: [
10
+ { metric: 'error_rate', action: 'require_approval', toolPattern: '*', ttlSeconds: 3600, reason: 'Error rate high' },
11
+ ],
12
+ };
13
+
14
+ function makeClient() {
15
+ return {
16
+ createOverride: vi.fn().mockResolvedValue({ id: 'ovr-123', agentId: 'agent-1', toolPattern: '*', action: 'require_approval', reason: 'test', ttlSeconds: 3600 }),
17
+ removeOverride: vi.fn().mockResolvedValue(undefined),
18
+ listOverrides: vi.fn().mockResolvedValue([]),
19
+ } as unknown as GateClient;
20
+ }
21
+
22
+ const breach = (metric = 'error_rate') => ({ event: 'breach', metric, currentValue: 0.9, threshold: 0.5, agentId: 'agent-1', timestamp: new Date().toISOString() });
23
+ const recovery = (metric = 'error_rate') => ({ event: 'recovery', metric, currentValue: 0.3, threshold: 0.5, agentId: 'agent-1', timestamp: new Date().toISOString() });
24
+
25
+ describe('webhook handler', () => {
26
+ it('rejects invalid payload', async () => {
27
+ const { app } = buildServer(testConfig, makeClient());
28
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: { bad: true } });
29
+ expect(res.statusCode).toBe(400);
30
+ });
31
+
32
+ it('creates override on breach event', async () => {
33
+ const client = makeClient();
34
+ const { app, activeOverrides } = buildServer(testConfig, client);
35
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
36
+ expect(res.statusCode).toBe(201);
37
+ expect(client.createOverride).toHaveBeenCalledOnce();
38
+ expect(activeOverrides.get('agent-1::error_rate')).toBe('ovr-123');
39
+ });
40
+
41
+ it('is idempotent — duplicate breach does not create second override', async () => {
42
+ const client = makeClient();
43
+ const { app } = buildServer(testConfig, client);
44
+ await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
45
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
46
+ expect(res.statusCode).toBe(200);
47
+ expect(JSON.parse(res.body).status).toBe('already_active');
48
+ expect(client.createOverride).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it('removes override on recovery event', async () => {
52
+ const client = makeClient();
53
+ const { app, activeOverrides } = buildServer(testConfig, client);
54
+ await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
55
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: recovery() });
56
+ expect(res.statusCode).toBe(200);
57
+ expect(JSON.parse(res.body).status).toBe('override_removed');
58
+ expect(client.removeOverride).toHaveBeenCalledWith('ovr-123');
59
+ expect(activeOverrides.size).toBe(0);
60
+ });
61
+
62
+ it('returns ignored for unknown metric', async () => {
63
+ const { app } = buildServer(testConfig, makeClient());
64
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: breach('unknown_metric') });
65
+ expect(res.statusCode).toBe(200);
66
+ expect(JSON.parse(res.body).status).toBe('ignored');
67
+ });
68
+
69
+ it('returns 502 when AgentGate is unreachable on breach', async () => {
70
+ const client = makeClient();
71
+ (client.createOverride as any).mockRejectedValue(new Error('connection refused'));
72
+ const { app } = buildServer(testConfig, client);
73
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: breach() });
74
+ expect(res.statusCode).toBe(502);
75
+ });
76
+
77
+ it('returns 502 when AgentGate is unreachable on recovery', async () => {
78
+ const client = makeClient();
79
+ (client.removeOverride as any).mockRejectedValue(new Error('connection refused'));
80
+ const { app, activeOverrides } = buildServer(testConfig, client);
81
+ activeOverrides.set('agent-1::error_rate', 'ovr-123');
82
+ const res = await app.inject({ method: 'POST', url: '/webhook', payload: recovery() });
83
+ expect(res.statusCode).toBe(502);
84
+ });
85
+ });
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { readFileSync } from 'node:fs';
3
+ import { parse as parseYaml } from 'yaml';
4
+
5
+ const RuleSchema = z.object({
6
+ metric: z.string(),
7
+ action: z.enum(['require_approval', 'deny', 'allow']),
8
+ toolPattern: z.string().default('*'),
9
+ ttlSeconds: z.number().positive().default(3600),
10
+ reason: z.string().default('Guardrail triggered'),
11
+ });
12
+
13
+ const ConfigSchema = z.object({
14
+ agentgate: z.object({
15
+ url: z.string().url(),
16
+ apiKey: z.string().optional(),
17
+ }),
18
+ server: z.object({
19
+ port: z.number().int().positive().default(3010),
20
+ }),
21
+ rules: z.array(RuleSchema).min(1),
22
+ });
23
+
24
+ export type Config = z.infer<typeof ConfigSchema>;
25
+ export type Rule = z.infer<typeof RuleSchema>;
26
+
27
+ export function loadConfig(path: string): Config {
28
+ const raw = readFileSync(path, 'utf-8');
29
+ const parsed = parseYaml(raw);
30
+ return ConfigSchema.parse(parsed);
31
+ }
@@ -0,0 +1,50 @@
1
+ export interface OverrideRequest {
2
+ agentId: string;
3
+ toolPattern: string;
4
+ action: string;
5
+ reason: string;
6
+ ttlSeconds: number;
7
+ }
8
+
9
+ export interface Override extends OverrideRequest {
10
+ id: string;
11
+ }
12
+
13
+ export class GateClient {
14
+ constructor(
15
+ private baseUrl: string,
16
+ private apiKey?: string,
17
+ ) {}
18
+
19
+ private headers(): Record<string, string> {
20
+ const h: Record<string, string> = { 'Content-Type': 'application/json' };
21
+ if (this.apiKey) h['Authorization'] = `Bearer ${this.apiKey}`;
22
+ return h;
23
+ }
24
+
25
+ async createOverride(override: OverrideRequest): Promise<Override> {
26
+ const res = await fetch(`${this.baseUrl}/api/overrides`, {
27
+ method: 'POST',
28
+ headers: this.headers(),
29
+ body: JSON.stringify(override),
30
+ });
31
+ if (!res.ok) throw new Error(`AgentGate POST /api/overrides failed: ${res.status}`);
32
+ return res.json() as Promise<Override>;
33
+ }
34
+
35
+ async removeOverride(id: string): Promise<void> {
36
+ const res = await fetch(`${this.baseUrl}/api/overrides/${id}`, {
37
+ method: 'DELETE',
38
+ headers: this.headers(),
39
+ });
40
+ if (!res.ok) throw new Error(`AgentGate DELETE /api/overrides/${id} failed: ${res.status}`);
41
+ }
42
+
43
+ async listOverrides(): Promise<Override[]> {
44
+ const res = await fetch(`${this.baseUrl}/api/overrides`, {
45
+ headers: this.headers(),
46
+ });
47
+ if (!res.ok) throw new Error(`AgentGate GET /api/overrides failed: ${res.status}`);
48
+ return res.json() as Promise<Override[]>;
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { loadConfig } from './config.js';
3
+ import { buildServer } from './server.js';
4
+ import { GateClient } from './gate-client.js';
5
+
6
+ const configPath = process.argv[2] || 'config.yaml';
7
+
8
+ async function main() {
9
+ const config = loadConfig(configPath);
10
+ const gateClient = new GateClient(config.agentgate.url, config.agentgate.apiKey);
11
+ const { app } = buildServer(config, gateClient);
12
+
13
+ await app.listen({ port: config.server.port, host: '0.0.0.0' });
14
+ console.log(`agentkit-guardrails listening on port ${config.server.port}`);
15
+ }
16
+
17
+ main().catch((err) => {
18
+ console.error('Fatal:', err);
19
+ process.exit(1);
20
+ });
package/src/server.ts ADDED
@@ -0,0 +1,92 @@
1
+ import Fastify, { FastifyInstance } from 'fastify';
2
+ import { z } from 'zod';
3
+ import { Config, Rule } from './config.js';
4
+ import { GateClient } from './gate-client.js';
5
+
6
+ const WebhookPayload = z.object({
7
+ event: z.enum(['breach', 'recovery']),
8
+ metric: z.string(),
9
+ currentValue: z.number(),
10
+ threshold: z.number(),
11
+ agentId: z.string(),
12
+ timestamp: z.string(),
13
+ });
14
+
15
+ export type WebhookEvent = z.infer<typeof WebhookPayload>;
16
+
17
+ function matchRule(metric: string, rules: Rule[]): Rule | undefined {
18
+ return rules.find((r) => r.metric === metric);
19
+ }
20
+
21
+ function overrideKey(agentId: string, metric: string): string {
22
+ return `${agentId}::${metric}`;
23
+ }
24
+
25
+ export interface GuardrailsServer {
26
+ app: FastifyInstance;
27
+ activeOverrides: Map<string, string>;
28
+ }
29
+
30
+ export function buildServer(config: Config, gateClient?: GateClient): GuardrailsServer {
31
+ const app = Fastify({ logger: false });
32
+ const client = gateClient ?? new GateClient(config.agentgate.url, config.agentgate.apiKey);
33
+ const activeOverrides = new Map<string, string>();
34
+
35
+ app.post('/webhook', async (request, reply) => {
36
+ const parseResult = WebhookPayload.safeParse(request.body);
37
+ if (!parseResult.success) {
38
+ return reply.status(400).send({ error: 'Invalid payload', details: parseResult.error.issues });
39
+ }
40
+
41
+ const payload = parseResult.data;
42
+ const rule = matchRule(payload.metric, config.rules);
43
+
44
+ if (!rule) {
45
+ return reply.status(200).send({ status: 'ignored', reason: 'no matching rule' });
46
+ }
47
+
48
+ const key = overrideKey(payload.agentId, payload.metric);
49
+
50
+ if (payload.event === 'breach') {
51
+ if (activeOverrides.has(key)) {
52
+ return reply.status(200).send({ status: 'already_active', overrideId: activeOverrides.get(key) });
53
+ }
54
+
55
+ try {
56
+ const override = await client.createOverride({
57
+ agentId: payload.agentId,
58
+ toolPattern: rule.toolPattern,
59
+ action: rule.action,
60
+ reason: rule.reason,
61
+ ttlSeconds: rule.ttlSeconds,
62
+ });
63
+ if (!override || !override.id) {
64
+ throw new Error('Invalid response from AgentGate: missing override id');
65
+ }
66
+ activeOverrides.set(key, override.id);
67
+ return reply.status(201).send({ status: 'override_created', overrideId: override.id });
68
+ } catch (err) {
69
+ return reply.status(502).send({ error: 'AgentGate unreachable', detail: String(err) });
70
+ }
71
+ }
72
+
73
+ if (payload.event === 'recovery') {
74
+ const overrideId = activeOverrides.get(key);
75
+ if (!overrideId) {
76
+ return reply.status(200).send({ status: 'no_active_override' });
77
+ }
78
+
79
+ try {
80
+ await client.removeOverride(overrideId);
81
+ activeOverrides.delete(key);
82
+ return reply.status(200).send({ status: 'override_removed', overrideId });
83
+ } catch (err) {
84
+ return reply.status(502).send({ error: 'AgentGate unreachable', detail: String(err) });
85
+ }
86
+ }
87
+ });
88
+
89
+ app.get('/health', async () => ({ status: 'ok' }));
90
+
91
+ return { app, activeOverrides };
92
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src"],
14
+ "exclude": ["src/__tests__"]
15
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ },
7
+ });