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 +135 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +74 -0
- package/dist/__tests__/gate-client.test.d.ts +1 -0
- package/dist/__tests__/gate-client.test.js +36 -0
- package/dist/__tests__/integration.test.d.ts +1 -0
- package/dist/__tests__/integration.test.js +88 -0
- package/dist/__tests__/server.test.d.ts +1 -0
- package/dist/__tests__/server.test.js +75 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +28 -0
- package/dist/gate-client.d.ts +19 -0
- package/dist/gate-client.js +44 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/server.d.ts +22 -0
- package/dist/server.js +78 -0
- package/docker-compose.yml +27 -0
- package/package.json +33 -0
- package/src/__tests__/config.test.ts +76 -0
- package/src/__tests__/gate-client.test.ts +41 -0
- package/src/__tests__/integration.test.ts +103 -0
- package/src/__tests__/server.test.ts +85 -0
- package/src/config.ts +31 -0
- package/src/gate-client.ts +50 -0
- package/src/index.ts +20 -0
- package/src/server.ts +92 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +7 -0
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
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -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;
|
package/dist/index.d.ts
ADDED
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
|
+
});
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|