predicate-claw 0.1.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/.github/workflows/release.yml +76 -0
- package/.github/workflows/tests.yml +34 -0
- package/.markdownlint.yaml +5 -0
- package/.pre-commit-config.yaml +100 -0
- package/README.md +405 -0
- package/dist/src/adapter.d.ts +17 -0
- package/dist/src/adapter.js +36 -0
- package/dist/src/authority-client.d.ts +21 -0
- package/dist/src/authority-client.js +22 -0
- package/dist/src/circuit-breaker.d.ts +86 -0
- package/dist/src/circuit-breaker.js +174 -0
- package/dist/src/config.d.ts +8 -0
- package/dist/src/config.js +7 -0
- package/dist/src/control-plane-sync.d.ts +57 -0
- package/dist/src/control-plane-sync.js +99 -0
- package/dist/src/errors.d.ts +6 -0
- package/dist/src/errors.js +6 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +12 -0
- package/dist/src/non-web-evidence.d.ts +46 -0
- package/dist/src/non-web-evidence.js +54 -0
- package/dist/src/openclaw-hooks.d.ts +27 -0
- package/dist/src/openclaw-hooks.js +54 -0
- package/dist/src/openclaw-plugin-api.d.ts +18 -0
- package/dist/src/openclaw-plugin-api.js +17 -0
- package/dist/src/provider.d.ts +48 -0
- package/dist/src/provider.js +154 -0
- package/dist/src/runtime-integration.d.ts +20 -0
- package/dist/src/runtime-integration.js +43 -0
- package/dist/src/web-evidence.d.ts +48 -0
- package/dist/src/web-evidence.js +49 -0
- package/dist/tests/adapter.test.d.ts +1 -0
- package/dist/tests/adapter.test.js +63 -0
- package/dist/tests/audit-event-e2e.test.d.ts +1 -0
- package/dist/tests/audit-event-e2e.test.js +209 -0
- package/dist/tests/authority-client.test.d.ts +1 -0
- package/dist/tests/authority-client.test.js +46 -0
- package/dist/tests/circuit-breaker.test.d.ts +1 -0
- package/dist/tests/circuit-breaker.test.js +200 -0
- package/dist/tests/control-plane-sync.test.d.ts +1 -0
- package/dist/tests/control-plane-sync.test.js +90 -0
- package/dist/tests/hack-vs-fix-demo.test.d.ts +1 -0
- package/dist/tests/hack-vs-fix-demo.test.js +36 -0
- package/dist/tests/jwks-rotation.test.d.ts +1 -0
- package/dist/tests/jwks-rotation.test.js +232 -0
- package/dist/tests/load-latency.test.d.ts +1 -0
- package/dist/tests/load-latency.test.js +175 -0
- package/dist/tests/multi-tenant-isolation.test.d.ts +1 -0
- package/dist/tests/multi-tenant-isolation.test.js +146 -0
- package/dist/tests/non-web-evidence.test.d.ts +1 -0
- package/dist/tests/non-web-evidence.test.js +139 -0
- package/dist/tests/openclaw-hooks.test.d.ts +1 -0
- package/dist/tests/openclaw-hooks.test.js +38 -0
- package/dist/tests/openclaw-plugin-api.test.d.ts +1 -0
- package/dist/tests/openclaw-plugin-api.test.js +40 -0
- package/dist/tests/provider.test.d.ts +1 -0
- package/dist/tests/provider.test.js +190 -0
- package/dist/tests/runtime-integration.test.d.ts +1 -0
- package/dist/tests/runtime-integration.test.js +57 -0
- package/dist/tests/web-evidence.test.d.ts +1 -0
- package/dist/tests/web-evidence.test.js +89 -0
- package/docs/MIGRATION_GUIDE.md +405 -0
- package/docs/OPERATIONAL_RUNBOOK.md +389 -0
- package/docs/PRODUCTION_READINESS.md +134 -0
- package/docs/SLO_THRESHOLDS.md +193 -0
- package/examples/README.md +171 -0
- package/examples/docker/Dockerfile.test +16 -0
- package/examples/docker/README.md +48 -0
- package/examples/docker/docker-compose.test.yml +16 -0
- package/examples/non-web-evidence-demo.ts +184 -0
- package/examples/openclaw-plugin-smoke/index.ts +30 -0
- package/examples/openclaw-plugin-smoke/openclaw.plugin.json +11 -0
- package/examples/openclaw-plugin-smoke/package.json +9 -0
- package/examples/openclaw_integration_example.py +41 -0
- package/examples/policy/README.md +165 -0
- package/examples/policy/approved-hosts.yaml +137 -0
- package/examples/policy/dev-workflow.yaml +206 -0
- package/examples/policy/policy.example.yaml +17 -0
- package/examples/policy/production-strict.yaml +97 -0
- package/examples/policy/sensitive-paths.yaml +114 -0
- package/examples/policy/source-trust.yaml +129 -0
- package/examples/policy/workspace-isolation.yaml +51 -0
- package/examples/runtime_registry_example.py +75 -0
- package/package.json +27 -0
- package/pyproject.toml +41 -0
- package/src/adapter.ts +45 -0
- package/src/authority-client.ts +50 -0
- package/src/circuit-breaker.ts +245 -0
- package/src/config.ts +15 -0
- package/src/control-plane-sync.ts +159 -0
- package/src/errors.ts +5 -0
- package/src/index.ts +12 -0
- package/src/non-web-evidence.ts +116 -0
- package/src/openclaw-hooks.ts +76 -0
- package/src/openclaw-plugin-api.ts +51 -0
- package/src/openclaw_predicate_provider/__init__.py +16 -0
- package/src/openclaw_predicate_provider/__main__.py +5 -0
- package/src/openclaw_predicate_provider/adapter.py +84 -0
- package/src/openclaw_predicate_provider/agentidentity_backend.py +78 -0
- package/src/openclaw_predicate_provider/cli.py +160 -0
- package/src/openclaw_predicate_provider/config.py +42 -0
- package/src/openclaw_predicate_provider/errors.py +13 -0
- package/src/openclaw_predicate_provider/integrations/__init__.py +5 -0
- package/src/openclaw_predicate_provider/integrations/openclaw_runtime.py +74 -0
- package/src/openclaw_predicate_provider/models.py +19 -0
- package/src/openclaw_predicate_provider/openclaw_hooks.py +75 -0
- package/src/openclaw_predicate_provider/provider.py +69 -0
- package/src/openclaw_predicate_provider/py.typed +1 -0
- package/src/openclaw_predicate_provider/sidecar.py +59 -0
- package/src/provider.ts +220 -0
- package/src/runtime-integration.ts +68 -0
- package/src/web-evidence.ts +95 -0
- package/tests/adapter.test.ts +76 -0
- package/tests/audit-event-e2e.test.ts +258 -0
- package/tests/authority-client.test.ts +52 -0
- package/tests/circuit-breaker.test.ts +266 -0
- package/tests/conftest.py +9 -0
- package/tests/control-plane-sync.test.ts +114 -0
- package/tests/hack-vs-fix-demo.test.ts +44 -0
- package/tests/jwks-rotation.test.ts +274 -0
- package/tests/load-latency.test.ts +214 -0
- package/tests/multi-tenant-isolation.test.ts +183 -0
- package/tests/non-web-evidence.test.ts +168 -0
- package/tests/openclaw-hooks.test.ts +46 -0
- package/tests/openclaw-plugin-api.test.ts +50 -0
- package/tests/provider.test.ts +227 -0
- package/tests/runtime-integration.test.ts +70 -0
- package/tests/test_adapter.py +46 -0
- package/tests/test_cli.py +26 -0
- package/tests/test_openclaw_hooks.py +53 -0
- package/tests/test_provider.py +59 -0
- package/tests/test_runtime_integration.py +77 -0
- package/tests/test_sidecar_client.py +198 -0
- package/tests/web-evidence.test.ts +113 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ToolAdapter } from "../src/adapter.js";
|
|
3
|
+
import { ActionDeniedError, GuardedProvider } from "../src/provider.js";
|
|
4
|
+
|
|
5
|
+
describe("Hack vs Fix demo scenario", () => {
|
|
6
|
+
it("shows unguarded exfil path and guarded deny path", async () => {
|
|
7
|
+
const injectedArgs = { path: "/Users/demo/.ssh/id_rsa" };
|
|
8
|
+
const injectedContext = { source: "untrusted_dm" };
|
|
9
|
+
|
|
10
|
+
const unguardedRead = async (args: Record<string, unknown>) => {
|
|
11
|
+
return `SECRET:${String(args.path)}`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const hacked = await unguardedRead(injectedArgs);
|
|
15
|
+
expect(hacked).toContain("SECRET:/Users/demo/.ssh/id_rsa");
|
|
16
|
+
|
|
17
|
+
const guardedProvider = new GuardedProvider({
|
|
18
|
+
principal: "agent:openclaw-local",
|
|
19
|
+
authorityClient: {
|
|
20
|
+
authorize: async (request) => {
|
|
21
|
+
const resource = String(request.resource ?? "");
|
|
22
|
+
const labels = Array.isArray(request.labels) ? request.labels : [];
|
|
23
|
+
const isUntrusted = labels.includes("source:untrusted_dm");
|
|
24
|
+
const isSensitiveRead =
|
|
25
|
+
request.action === "fs.read" &&
|
|
26
|
+
(resource.includes("/.ssh/") || resource.startsWith("/etc/"));
|
|
27
|
+
if (isUntrusted && isSensitiveRead) {
|
|
28
|
+
return { allow: false, reason: "deny_sensitive_read_from_untrusted_context" };
|
|
29
|
+
}
|
|
30
|
+
return { allow: true, reason: "allowed", mandateId: "mnd_ok" };
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const adapter = new ToolAdapter(guardedProvider);
|
|
35
|
+
|
|
36
|
+
await expect(
|
|
37
|
+
adapter.readFile({
|
|
38
|
+
args: injectedArgs,
|
|
39
|
+
context: injectedContext,
|
|
40
|
+
execute: unguardedRead,
|
|
41
|
+
}),
|
|
42
|
+
).rejects.toBeInstanceOf(ActionDeniedError);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { AuthorizationRequest } from "@predicatesystems/authority";
|
|
3
|
+
import { GuardedProvider } from "../src/provider.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Integration tests for JWKS/key-rotation-driven policy contexts.
|
|
7
|
+
*
|
|
8
|
+
* These tests verify that the provider correctly handles scenarios where
|
|
9
|
+
* policy decisions depend on JWT validation contexts that may change
|
|
10
|
+
* during key rotation events.
|
|
11
|
+
*
|
|
12
|
+
* Note: The provider passes context fields to the sidecar, which handles
|
|
13
|
+
* actual JWKS validation. These tests verify context propagation and
|
|
14
|
+
* decision handling, not the JWKS validation itself.
|
|
15
|
+
*/
|
|
16
|
+
describe("JWKS and key rotation contexts", () => {
|
|
17
|
+
it("passes authorization request to sidecar for validation", async () => {
|
|
18
|
+
const capturedRequests: AuthorizationRequest[] = [];
|
|
19
|
+
|
|
20
|
+
const mockClient = {
|
|
21
|
+
authorize: vi.fn().mockImplementation((req: AuthorizationRequest) => {
|
|
22
|
+
capturedRequests.push(req);
|
|
23
|
+
return Promise.resolve({ allow: true });
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const provider = new GuardedProvider({
|
|
28
|
+
principal: "agent:jwks-test",
|
|
29
|
+
authorityClient: mockClient,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await provider.authorize({
|
|
33
|
+
action: "shell.execute",
|
|
34
|
+
resource: "npm install",
|
|
35
|
+
args: { cmd: "npm install" },
|
|
36
|
+
context: {
|
|
37
|
+
kid: "key-2024-02-20",
|
|
38
|
+
iss: "https://auth.example.com",
|
|
39
|
+
tenant_id: "tenant-a",
|
|
40
|
+
source: "trusted_ui",
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(capturedRequests).toHaveLength(1);
|
|
45
|
+
expect(capturedRequests[0].principal).toBe("agent:jwks-test");
|
|
46
|
+
expect(capturedRequests[0].action).toBe("shell.execute");
|
|
47
|
+
expect(capturedRequests[0].labels).toContain("source:trusted_ui");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("handles allow decisions from sidecar during key rotation", async () => {
|
|
51
|
+
// Sidecar accepts requests during rotation window
|
|
52
|
+
const mockClient = {
|
|
53
|
+
authorize: vi.fn().mockResolvedValue({
|
|
54
|
+
allow: true,
|
|
55
|
+
reason: "valid_key",
|
|
56
|
+
mandateId: "mandate-rotation-1",
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const provider = new GuardedProvider({
|
|
61
|
+
principal: "agent:rotation-test",
|
|
62
|
+
authorityClient: mockClient,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Both old and new key contexts should be accepted by sidecar
|
|
66
|
+
const result1 = await provider.authorize({
|
|
67
|
+
action: "fs.read",
|
|
68
|
+
resource: "/config.json",
|
|
69
|
+
args: { path: "/config.json" },
|
|
70
|
+
context: { kid: "key-v1", source: "trusted_ui" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const result2 = await provider.authorize({
|
|
74
|
+
action: "fs.read",
|
|
75
|
+
resource: "/settings.json",
|
|
76
|
+
args: { path: "/settings.json" },
|
|
77
|
+
context: { kid: "key-v2", source: "trusted_ui" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result1).toBe("mandate-rotation-1");
|
|
81
|
+
expect(result2).toBe("mandate-rotation-1");
|
|
82
|
+
expect(mockClient.authorize).toHaveBeenCalledTimes(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("handles deny decisions from sidecar for revoked keys", async () => {
|
|
86
|
+
let callCount = 0;
|
|
87
|
+
|
|
88
|
+
// Sidecar rejects old key, accepts new key
|
|
89
|
+
const mockClient = {
|
|
90
|
+
authorize: vi.fn().mockImplementation(async () => {
|
|
91
|
+
callCount++;
|
|
92
|
+
if (callCount === 1) {
|
|
93
|
+
// First call with old key - rejected
|
|
94
|
+
return { allow: false, reason: "key_revoked" };
|
|
95
|
+
}
|
|
96
|
+
// Second call with new key - accepted
|
|
97
|
+
return { allow: true, reason: "valid_key" };
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const provider = new GuardedProvider({
|
|
102
|
+
principal: "agent:post-rotation-test",
|
|
103
|
+
authorityClient: mockClient,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Request with revoked old key
|
|
107
|
+
await expect(
|
|
108
|
+
provider.authorize({
|
|
109
|
+
action: "shell.execute",
|
|
110
|
+
resource: "echo hello",
|
|
111
|
+
args: { cmd: "echo hello" },
|
|
112
|
+
context: { kid: "key-v1", source: "trusted_ui" },
|
|
113
|
+
}),
|
|
114
|
+
).rejects.toThrow("key_revoked");
|
|
115
|
+
|
|
116
|
+
// Request with valid new key
|
|
117
|
+
const result = await provider.authorize({
|
|
118
|
+
action: "shell.execute",
|
|
119
|
+
resource: "echo hello",
|
|
120
|
+
args: { cmd: "echo hello" },
|
|
121
|
+
context: { kid: "key-v2", source: "trusted_ui" },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result).toBeNull(); // No mandate ID in this mock response
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("propagates issuer context for IdP validation", async () => {
|
|
128
|
+
const capturedRequests: AuthorizationRequest[] = [];
|
|
129
|
+
|
|
130
|
+
const mockClient = {
|
|
131
|
+
authorize: vi.fn().mockImplementation((req: AuthorizationRequest) => {
|
|
132
|
+
capturedRequests.push(req);
|
|
133
|
+
return Promise.resolve({ allow: true });
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const provider = new GuardedProvider({
|
|
138
|
+
principal: "agent:idp-test",
|
|
139
|
+
authorityClient: mockClient,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await provider.authorize({
|
|
143
|
+
action: "net.http",
|
|
144
|
+
resource: "https://api.internal.com/data",
|
|
145
|
+
args: { url: "https://api.internal.com/data" },
|
|
146
|
+
context: {
|
|
147
|
+
iss: "https://login.microsoftonline.com/tenant-id/v2.0",
|
|
148
|
+
aud: "api://predicate-authority",
|
|
149
|
+
sub: "user@example.com",
|
|
150
|
+
tenant_id: "tenant-azure",
|
|
151
|
+
source: "azure_entra",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(capturedRequests).toHaveLength(1);
|
|
156
|
+
expect(capturedRequests[0].labels).toContain("source:azure_entra");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("handles expired token rejection from sidecar", async () => {
|
|
160
|
+
const mockClient = {
|
|
161
|
+
authorize: vi.fn().mockResolvedValue({
|
|
162
|
+
allow: false,
|
|
163
|
+
reason: "token_expired",
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const provider = new GuardedProvider({
|
|
168
|
+
principal: "agent:expired-test",
|
|
169
|
+
authorityClient: mockClient,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await expect(
|
|
173
|
+
provider.authorize({
|
|
174
|
+
action: "fs.write",
|
|
175
|
+
resource: "/data.json",
|
|
176
|
+
args: { path: "/data.json", content: "{}" },
|
|
177
|
+
context: {
|
|
178
|
+
exp: Math.floor(Date.now() / 1000) - 3600,
|
|
179
|
+
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
180
|
+
source: "trusted_ui",
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
).rejects.toThrow("token_expired");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("handles multiple concurrent requests with sidecar", async () => {
|
|
187
|
+
let concurrentCalls = 0;
|
|
188
|
+
let maxConcurrent = 0;
|
|
189
|
+
|
|
190
|
+
const mockClient = {
|
|
191
|
+
authorize: vi.fn().mockImplementation(async () => {
|
|
192
|
+
concurrentCalls++;
|
|
193
|
+
maxConcurrent = Math.max(maxConcurrent, concurrentCalls);
|
|
194
|
+
// Small delay to allow concurrency
|
|
195
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
196
|
+
concurrentCalls--;
|
|
197
|
+
return { allow: true };
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const provider = new GuardedProvider({
|
|
202
|
+
principal: "agent:multi-key-test",
|
|
203
|
+
authorityClient: mockClient,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await Promise.all([
|
|
207
|
+
provider.authorize({
|
|
208
|
+
action: "fs.read",
|
|
209
|
+
resource: "/a.txt",
|
|
210
|
+
args: { path: "/a.txt" },
|
|
211
|
+
context: { session_id: "session-a", source: "trusted_ui" },
|
|
212
|
+
}),
|
|
213
|
+
provider.authorize({
|
|
214
|
+
action: "fs.read",
|
|
215
|
+
resource: "/b.txt",
|
|
216
|
+
args: { path: "/b.txt" },
|
|
217
|
+
context: { session_id: "session-b", source: "trusted_ui" },
|
|
218
|
+
}),
|
|
219
|
+
provider.authorize({
|
|
220
|
+
action: "fs.read",
|
|
221
|
+
resource: "/c.txt",
|
|
222
|
+
args: { path: "/c.txt" },
|
|
223
|
+
context: { session_id: "session-a", source: "trusted_ui" },
|
|
224
|
+
}),
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
expect(mockClient.authorize).toHaveBeenCalledTimes(3);
|
|
228
|
+
// Should have had some concurrent calls
|
|
229
|
+
expect(maxConcurrent).toBeGreaterThan(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("includes source label for policy evaluation by key trust level", async () => {
|
|
233
|
+
const capturedRequests: AuthorizationRequest[] = [];
|
|
234
|
+
|
|
235
|
+
const mockClient = {
|
|
236
|
+
authorize: vi.fn().mockImplementation((req: AuthorizationRequest) => {
|
|
237
|
+
capturedRequests.push(req);
|
|
238
|
+
const labels = req.labels ?? [];
|
|
239
|
+
// Sidecar policy: only allow trusted sources
|
|
240
|
+
if (labels.includes("source:untrusted_dm")) {
|
|
241
|
+
return Promise.resolve({ allow: false, reason: "untrusted_source" });
|
|
242
|
+
}
|
|
243
|
+
return Promise.resolve({ allow: true });
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const provider = new GuardedProvider({
|
|
248
|
+
principal: "agent:trust-test",
|
|
249
|
+
authorityClient: mockClient,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Trusted source - allowed
|
|
253
|
+
await provider.authorize({
|
|
254
|
+
action: "shell.execute",
|
|
255
|
+
resource: "npm install",
|
|
256
|
+
args: { cmd: "npm install" },
|
|
257
|
+
context: { source: "trusted_ui" },
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Untrusted source - denied
|
|
261
|
+
await expect(
|
|
262
|
+
provider.authorize({
|
|
263
|
+
action: "shell.execute",
|
|
264
|
+
resource: "curl evil.com",
|
|
265
|
+
args: { cmd: "curl evil.com" },
|
|
266
|
+
context: { source: "untrusted_dm" },
|
|
267
|
+
}),
|
|
268
|
+
).rejects.toThrow("untrusted_source");
|
|
269
|
+
|
|
270
|
+
expect(capturedRequests).toHaveLength(2);
|
|
271
|
+
expect(capturedRequests[0].labels).toContain("source:trusted_ui");
|
|
272
|
+
expect(capturedRequests[1].labels).toContain("source:untrusted_dm");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { GuardedProvider } from "../src/provider.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load and latency tests for high-frequency tool invocation paths.
|
|
6
|
+
*
|
|
7
|
+
* These tests verify that the provider can handle burst traffic while
|
|
8
|
+
* maintaining acceptable latency characteristics.
|
|
9
|
+
*/
|
|
10
|
+
describe("load and latency", () => {
|
|
11
|
+
it("handles burst of 100 sequential authorizations under 500ms total", async () => {
|
|
12
|
+
const mockClient = {
|
|
13
|
+
authorize: vi.fn().mockResolvedValue({ allow: true, mandateId: "m1" }),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const provider = new GuardedProvider({
|
|
17
|
+
principal: "agent:load-test",
|
|
18
|
+
authorityClient: mockClient,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const iterations = 100;
|
|
22
|
+
const start = performance.now();
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < iterations; i++) {
|
|
25
|
+
await provider.authorize({
|
|
26
|
+
action: "fs.read",
|
|
27
|
+
resource: `/workspace/file-${i}.txt`,
|
|
28
|
+
args: { path: `/workspace/file-${i}.txt` },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const elapsed = performance.now() - start;
|
|
33
|
+
|
|
34
|
+
expect(mockClient.authorize).toHaveBeenCalledTimes(iterations);
|
|
35
|
+
// 100 calls should complete well under 500ms with mocked client
|
|
36
|
+
expect(elapsed).toBeLessThan(500);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("handles 50 concurrent authorizations", async () => {
|
|
40
|
+
const mockClient = {
|
|
41
|
+
authorize: vi.fn().mockImplementation(async () => {
|
|
42
|
+
// Simulate small network delay
|
|
43
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
44
|
+
return { allow: true, mandateId: "m1" };
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const provider = new GuardedProvider({
|
|
49
|
+
principal: "agent:concurrent-test",
|
|
50
|
+
authorityClient: mockClient,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const concurrency = 50;
|
|
54
|
+
const start = performance.now();
|
|
55
|
+
|
|
56
|
+
const promises = Array.from({ length: concurrency }, (_, i) =>
|
|
57
|
+
provider.authorize({
|
|
58
|
+
action: "shell.execute",
|
|
59
|
+
resource: `echo test-${i}`,
|
|
60
|
+
args: { cmd: `echo test-${i}` },
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const results = await Promise.all(promises);
|
|
65
|
+
const elapsed = performance.now() - start;
|
|
66
|
+
|
|
67
|
+
expect(results).toHaveLength(concurrency);
|
|
68
|
+
expect(mockClient.authorize).toHaveBeenCalledTimes(concurrency);
|
|
69
|
+
// Concurrent calls should complete faster than sequential
|
|
70
|
+
expect(elapsed).toBeLessThan(200);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("maintains intent_hash computation performance", () => {
|
|
74
|
+
const iterations = 1000;
|
|
75
|
+
const start = performance.now();
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < iterations; i++) {
|
|
78
|
+
GuardedProvider.intentHash({
|
|
79
|
+
cmd: `echo "iteration ${i}"`,
|
|
80
|
+
workdir: "/workspace",
|
|
81
|
+
env: { NODE_ENV: "test", ITERATION: String(i) },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const elapsed = performance.now() - start;
|
|
86
|
+
|
|
87
|
+
// 1000 hash computations should complete under 100ms
|
|
88
|
+
expect(elapsed).toBeLessThan(100);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("measures p50/p95 latency for authorization calls", async () => {
|
|
92
|
+
const latencies: number[] = [];
|
|
93
|
+
|
|
94
|
+
const mockClient = {
|
|
95
|
+
authorize: vi.fn().mockImplementation(async () => {
|
|
96
|
+
// Simulate variable latency (1-5ms)
|
|
97
|
+
const delay = 1 + Math.random() * 4;
|
|
98
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
99
|
+
return { allow: true };
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const provider = new GuardedProvider({
|
|
104
|
+
principal: "agent:latency-test",
|
|
105
|
+
authorityClient: mockClient,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const iterations = 50;
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < iterations; i++) {
|
|
111
|
+
const start = performance.now();
|
|
112
|
+
await provider.authorize({
|
|
113
|
+
action: "net.http",
|
|
114
|
+
resource: "https://api.example.com",
|
|
115
|
+
args: { url: "https://api.example.com" },
|
|
116
|
+
});
|
|
117
|
+
latencies.push(performance.now() - start);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
latencies.sort((a, b) => a - b);
|
|
121
|
+
|
|
122
|
+
const p50 = latencies[Math.floor(iterations * 0.5)];
|
|
123
|
+
const p95 = latencies[Math.floor(iterations * 0.95)];
|
|
124
|
+
|
|
125
|
+
// Verify latency targets from design doc (with mocked overhead)
|
|
126
|
+
// Design targets: p50 < 25ms, p95 < 75ms
|
|
127
|
+
// With mocked 1-5ms delay, we expect p50 < 15ms, p95 < 20ms
|
|
128
|
+
expect(p50).toBeLessThan(25);
|
|
129
|
+
expect(p95).toBeLessThan(75);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("handles mixed allow/deny outcomes under load", async () => {
|
|
133
|
+
let callCount = 0;
|
|
134
|
+
|
|
135
|
+
const mockClient = {
|
|
136
|
+
authorize: vi.fn().mockImplementation(async () => {
|
|
137
|
+
callCount++;
|
|
138
|
+
// Alternate between allow and deny
|
|
139
|
+
if (callCount % 3 === 0) {
|
|
140
|
+
return { allow: false, reason: "rate_limited" };
|
|
141
|
+
}
|
|
142
|
+
return { allow: true, mandateId: `m${callCount}` };
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const provider = new GuardedProvider({
|
|
147
|
+
principal: "agent:mixed-test",
|
|
148
|
+
authorityClient: mockClient,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const iterations = 30;
|
|
152
|
+
const results = { allowed: 0, denied: 0 };
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < iterations; i++) {
|
|
155
|
+
try {
|
|
156
|
+
await provider.authorize({
|
|
157
|
+
action: "fs.write",
|
|
158
|
+
resource: `/workspace/file-${i}.txt`,
|
|
159
|
+
args: { path: `/workspace/file-${i}.txt`, content: "data" },
|
|
160
|
+
});
|
|
161
|
+
results.allowed++;
|
|
162
|
+
} catch {
|
|
163
|
+
results.denied++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
expect(results.allowed).toBe(20); // 2/3 allowed
|
|
168
|
+
expect(results.denied).toBe(10); // 1/3 denied
|
|
169
|
+
expect(mockClient.authorize).toHaveBeenCalledTimes(iterations);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("telemetry emission does not block authorization path", async () => {
|
|
173
|
+
const telemetryDelays: number[] = [];
|
|
174
|
+
|
|
175
|
+
const mockClient = {
|
|
176
|
+
authorize: vi.fn().mockResolvedValue({ allow: true }),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const slowTelemetry = {
|
|
180
|
+
onDecision: vi.fn().mockImplementation(() => {
|
|
181
|
+
// Simulate slow telemetry (should not block auth)
|
|
182
|
+
const start = performance.now();
|
|
183
|
+
while (performance.now() - start < 1) {
|
|
184
|
+
// Busy wait 1ms
|
|
185
|
+
}
|
|
186
|
+
telemetryDelays.push(performance.now() - start);
|
|
187
|
+
}),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const provider = new GuardedProvider({
|
|
191
|
+
principal: "agent:telemetry-test",
|
|
192
|
+
authorityClient: mockClient,
|
|
193
|
+
telemetry: slowTelemetry,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const iterations = 10;
|
|
197
|
+
const start = performance.now();
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < iterations; i++) {
|
|
200
|
+
await provider.authorize({
|
|
201
|
+
action: "fs.read",
|
|
202
|
+
resource: `/file-${i}.txt`,
|
|
203
|
+
args: { path: `/file-${i}.txt` },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const elapsed = performance.now() - start;
|
|
208
|
+
|
|
209
|
+
// Even with slow telemetry, auth should complete reasonably fast
|
|
210
|
+
// Telemetry runs synchronously here, but in production would be async
|
|
211
|
+
expect(elapsed).toBeLessThan(100);
|
|
212
|
+
expect(slowTelemetry.onDecision).toHaveBeenCalledTimes(iterations);
|
|
213
|
+
});
|
|
214
|
+
});
|