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.
Files changed (136) hide show
  1. package/.github/workflows/release.yml +76 -0
  2. package/.github/workflows/tests.yml +34 -0
  3. package/.markdownlint.yaml +5 -0
  4. package/.pre-commit-config.yaml +100 -0
  5. package/README.md +405 -0
  6. package/dist/src/adapter.d.ts +17 -0
  7. package/dist/src/adapter.js +36 -0
  8. package/dist/src/authority-client.d.ts +21 -0
  9. package/dist/src/authority-client.js +22 -0
  10. package/dist/src/circuit-breaker.d.ts +86 -0
  11. package/dist/src/circuit-breaker.js +174 -0
  12. package/dist/src/config.d.ts +8 -0
  13. package/dist/src/config.js +7 -0
  14. package/dist/src/control-plane-sync.d.ts +57 -0
  15. package/dist/src/control-plane-sync.js +99 -0
  16. package/dist/src/errors.d.ts +6 -0
  17. package/dist/src/errors.js +6 -0
  18. package/dist/src/index.d.ts +12 -0
  19. package/dist/src/index.js +12 -0
  20. package/dist/src/non-web-evidence.d.ts +46 -0
  21. package/dist/src/non-web-evidence.js +54 -0
  22. package/dist/src/openclaw-hooks.d.ts +27 -0
  23. package/dist/src/openclaw-hooks.js +54 -0
  24. package/dist/src/openclaw-plugin-api.d.ts +18 -0
  25. package/dist/src/openclaw-plugin-api.js +17 -0
  26. package/dist/src/provider.d.ts +48 -0
  27. package/dist/src/provider.js +154 -0
  28. package/dist/src/runtime-integration.d.ts +20 -0
  29. package/dist/src/runtime-integration.js +43 -0
  30. package/dist/src/web-evidence.d.ts +48 -0
  31. package/dist/src/web-evidence.js +49 -0
  32. package/dist/tests/adapter.test.d.ts +1 -0
  33. package/dist/tests/adapter.test.js +63 -0
  34. package/dist/tests/audit-event-e2e.test.d.ts +1 -0
  35. package/dist/tests/audit-event-e2e.test.js +209 -0
  36. package/dist/tests/authority-client.test.d.ts +1 -0
  37. package/dist/tests/authority-client.test.js +46 -0
  38. package/dist/tests/circuit-breaker.test.d.ts +1 -0
  39. package/dist/tests/circuit-breaker.test.js +200 -0
  40. package/dist/tests/control-plane-sync.test.d.ts +1 -0
  41. package/dist/tests/control-plane-sync.test.js +90 -0
  42. package/dist/tests/hack-vs-fix-demo.test.d.ts +1 -0
  43. package/dist/tests/hack-vs-fix-demo.test.js +36 -0
  44. package/dist/tests/jwks-rotation.test.d.ts +1 -0
  45. package/dist/tests/jwks-rotation.test.js +232 -0
  46. package/dist/tests/load-latency.test.d.ts +1 -0
  47. package/dist/tests/load-latency.test.js +175 -0
  48. package/dist/tests/multi-tenant-isolation.test.d.ts +1 -0
  49. package/dist/tests/multi-tenant-isolation.test.js +146 -0
  50. package/dist/tests/non-web-evidence.test.d.ts +1 -0
  51. package/dist/tests/non-web-evidence.test.js +139 -0
  52. package/dist/tests/openclaw-hooks.test.d.ts +1 -0
  53. package/dist/tests/openclaw-hooks.test.js +38 -0
  54. package/dist/tests/openclaw-plugin-api.test.d.ts +1 -0
  55. package/dist/tests/openclaw-plugin-api.test.js +40 -0
  56. package/dist/tests/provider.test.d.ts +1 -0
  57. package/dist/tests/provider.test.js +190 -0
  58. package/dist/tests/runtime-integration.test.d.ts +1 -0
  59. package/dist/tests/runtime-integration.test.js +57 -0
  60. package/dist/tests/web-evidence.test.d.ts +1 -0
  61. package/dist/tests/web-evidence.test.js +89 -0
  62. package/docs/MIGRATION_GUIDE.md +405 -0
  63. package/docs/OPERATIONAL_RUNBOOK.md +389 -0
  64. package/docs/PRODUCTION_READINESS.md +134 -0
  65. package/docs/SLO_THRESHOLDS.md +193 -0
  66. package/examples/README.md +171 -0
  67. package/examples/docker/Dockerfile.test +16 -0
  68. package/examples/docker/README.md +48 -0
  69. package/examples/docker/docker-compose.test.yml +16 -0
  70. package/examples/non-web-evidence-demo.ts +184 -0
  71. package/examples/openclaw-plugin-smoke/index.ts +30 -0
  72. package/examples/openclaw-plugin-smoke/openclaw.plugin.json +11 -0
  73. package/examples/openclaw-plugin-smoke/package.json +9 -0
  74. package/examples/openclaw_integration_example.py +41 -0
  75. package/examples/policy/README.md +165 -0
  76. package/examples/policy/approved-hosts.yaml +137 -0
  77. package/examples/policy/dev-workflow.yaml +206 -0
  78. package/examples/policy/policy.example.yaml +17 -0
  79. package/examples/policy/production-strict.yaml +97 -0
  80. package/examples/policy/sensitive-paths.yaml +114 -0
  81. package/examples/policy/source-trust.yaml +129 -0
  82. package/examples/policy/workspace-isolation.yaml +51 -0
  83. package/examples/runtime_registry_example.py +75 -0
  84. package/package.json +27 -0
  85. package/pyproject.toml +41 -0
  86. package/src/adapter.ts +45 -0
  87. package/src/authority-client.ts +50 -0
  88. package/src/circuit-breaker.ts +245 -0
  89. package/src/config.ts +15 -0
  90. package/src/control-plane-sync.ts +159 -0
  91. package/src/errors.ts +5 -0
  92. package/src/index.ts +12 -0
  93. package/src/non-web-evidence.ts +116 -0
  94. package/src/openclaw-hooks.ts +76 -0
  95. package/src/openclaw-plugin-api.ts +51 -0
  96. package/src/openclaw_predicate_provider/__init__.py +16 -0
  97. package/src/openclaw_predicate_provider/__main__.py +5 -0
  98. package/src/openclaw_predicate_provider/adapter.py +84 -0
  99. package/src/openclaw_predicate_provider/agentidentity_backend.py +78 -0
  100. package/src/openclaw_predicate_provider/cli.py +160 -0
  101. package/src/openclaw_predicate_provider/config.py +42 -0
  102. package/src/openclaw_predicate_provider/errors.py +13 -0
  103. package/src/openclaw_predicate_provider/integrations/__init__.py +5 -0
  104. package/src/openclaw_predicate_provider/integrations/openclaw_runtime.py +74 -0
  105. package/src/openclaw_predicate_provider/models.py +19 -0
  106. package/src/openclaw_predicate_provider/openclaw_hooks.py +75 -0
  107. package/src/openclaw_predicate_provider/provider.py +69 -0
  108. package/src/openclaw_predicate_provider/py.typed +1 -0
  109. package/src/openclaw_predicate_provider/sidecar.py +59 -0
  110. package/src/provider.ts +220 -0
  111. package/src/runtime-integration.ts +68 -0
  112. package/src/web-evidence.ts +95 -0
  113. package/tests/adapter.test.ts +76 -0
  114. package/tests/audit-event-e2e.test.ts +258 -0
  115. package/tests/authority-client.test.ts +52 -0
  116. package/tests/circuit-breaker.test.ts +266 -0
  117. package/tests/conftest.py +9 -0
  118. package/tests/control-plane-sync.test.ts +114 -0
  119. package/tests/hack-vs-fix-demo.test.ts +44 -0
  120. package/tests/jwks-rotation.test.ts +274 -0
  121. package/tests/load-latency.test.ts +214 -0
  122. package/tests/multi-tenant-isolation.test.ts +183 -0
  123. package/tests/non-web-evidence.test.ts +168 -0
  124. package/tests/openclaw-hooks.test.ts +46 -0
  125. package/tests/openclaw-plugin-api.test.ts +50 -0
  126. package/tests/provider.test.ts +227 -0
  127. package/tests/runtime-integration.test.ts +70 -0
  128. package/tests/test_adapter.py +46 -0
  129. package/tests/test_cli.py +26 -0
  130. package/tests/test_openclaw_hooks.py +53 -0
  131. package/tests/test_provider.py +59 -0
  132. package/tests/test_runtime_integration.py +77 -0
  133. package/tests/test_sidecar_client.py +198 -0
  134. package/tests/web-evidence.test.ts +113 -0
  135. package/tsconfig.json +14 -0
  136. package/vitest.config.ts +7 -0
@@ -0,0 +1,258 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ type DecisionAuditExporter,
4
+ type DecisionTelemetryEvent,
5
+ GuardedProvider,
6
+ ActionDeniedError,
7
+ } from "../src/provider.js";
8
+
9
+ /**
10
+ * End-to-end audit event visibility tests.
11
+ *
12
+ * These tests verify that decision events flow correctly from the provider
13
+ * through to audit exporters in a format compatible with control-plane
14
+ * audit pipelines.
15
+ */
16
+ describe("audit event visibility (e2e)", () => {
17
+ it("exports allow decisions with full context to audit sink", async () => {
18
+ const exportedEvents: DecisionTelemetryEvent[] = [];
19
+
20
+ const mockClient = {
21
+ authorize: vi.fn().mockResolvedValue({
22
+ allow: true,
23
+ reason: "policy_matched",
24
+ mandateId: "mandate-12345",
25
+ }),
26
+ };
27
+
28
+ const auditExporter: DecisionAuditExporter = {
29
+ exportDecision: async (event) => {
30
+ exportedEvents.push(event);
31
+ },
32
+ };
33
+
34
+ const provider = new GuardedProvider({
35
+ principal: "agent:e2e-test-agent",
36
+ authorityClient: mockClient,
37
+ auditExporter,
38
+ });
39
+
40
+ await provider.authorize({
41
+ action: "shell.execute",
42
+ resource: "npm install lodash",
43
+ args: { cmd: "npm install lodash" },
44
+ context: {
45
+ tenant_id: "tenant-prod",
46
+ session_id: "sess-e2e-001",
47
+ user_id: "user-developer",
48
+ trace_id: "trace-e2e-xyz",
49
+ source: "trusted_ui",
50
+ },
51
+ });
52
+
53
+ // Verify event was exported
54
+ expect(exportedEvents).toHaveLength(1);
55
+
56
+ const event = exportedEvents[0];
57
+
58
+ // Verify control-plane compatible fields
59
+ expect(event.principal).toBe("agent:e2e-test-agent");
60
+ expect(event.action).toBe("shell.execute");
61
+ expect(event.resource).toBe("npm install lodash");
62
+ expect(event.outcome).toBe("allow");
63
+ expect(event.reason).toBe("policy_matched");
64
+ expect(event.mandateId).toBe("mandate-12345");
65
+
66
+ // Verify tenant/session context
67
+ expect(event.tenantId).toBe("tenant-prod");
68
+ expect(event.sessionId).toBe("sess-e2e-001");
69
+ expect(event.userId).toBe("user-developer");
70
+ expect(event.traceId).toBe("trace-e2e-xyz");
71
+ expect(event.source).toBe("trusted_ui");
72
+
73
+ // Verify timestamp is ISO format
74
+ expect(event.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
75
+ });
76
+
77
+ it("exports deny decisions with redacted sensitive resources", async () => {
78
+ const exportedEvents: DecisionTelemetryEvent[] = [];
79
+
80
+ const mockClient = {
81
+ authorize: vi.fn().mockResolvedValue({
82
+ allow: false,
83
+ reason: "sensitive_path_blocked",
84
+ }),
85
+ };
86
+
87
+ const auditExporter: DecisionAuditExporter = {
88
+ exportDecision: async (event) => {
89
+ exportedEvents.push(event);
90
+ },
91
+ };
92
+
93
+ const provider = new GuardedProvider({
94
+ principal: "agent:sensitive-test",
95
+ authorityClient: mockClient,
96
+ auditExporter,
97
+ });
98
+
99
+ await expect(
100
+ provider.authorize({
101
+ action: "fs.read",
102
+ resource: "/home/user/.ssh/id_rsa",
103
+ args: { path: "/home/user/.ssh/id_rsa" },
104
+ context: { tenant_id: "tenant-sec" },
105
+ }),
106
+ ).rejects.toThrow(ActionDeniedError);
107
+
108
+ expect(exportedEvents).toHaveLength(1);
109
+
110
+ const event = exportedEvents[0];
111
+ expect(event.outcome).toBe("deny");
112
+ expect(event.reason).toBe("sensitive_path_blocked");
113
+ // Resource should be redacted for sensitive paths
114
+ expect(event.resource).toBe("[REDACTED]");
115
+ });
116
+
117
+ it("exports error events when sidecar is unavailable", async () => {
118
+ const exportedEvents: DecisionTelemetryEvent[] = [];
119
+
120
+ const mockClient = {
121
+ authorize: vi.fn().mockRejectedValue(new Error("Connection refused")),
122
+ };
123
+
124
+ const auditExporter: DecisionAuditExporter = {
125
+ exportDecision: async (event) => {
126
+ exportedEvents.push(event);
127
+ },
128
+ };
129
+
130
+ const provider = new GuardedProvider({
131
+ principal: "agent:error-test",
132
+ authorityClient: mockClient,
133
+ auditExporter,
134
+ });
135
+
136
+ await expect(
137
+ provider.authorize({
138
+ action: "net.http",
139
+ resource: "https://api.example.com",
140
+ args: { url: "https://api.example.com" },
141
+ context: { tenant_id: "tenant-error" },
142
+ }),
143
+ ).rejects.toThrow();
144
+
145
+ expect(exportedEvents).toHaveLength(1);
146
+
147
+ const event = exportedEvents[0];
148
+ expect(event.outcome).toBe("error");
149
+ expect(event.tenantId).toBe("tenant-error");
150
+ });
151
+
152
+ it("handles audit exporter failures gracefully (best-effort)", async () => {
153
+ let callCount = 0;
154
+
155
+ const mockClient = {
156
+ authorize: vi.fn().mockResolvedValue({ allow: true }),
157
+ };
158
+
159
+ const failingExporter: DecisionAuditExporter = {
160
+ exportDecision: async () => {
161
+ callCount++;
162
+ throw new Error("Audit sink unavailable");
163
+ },
164
+ };
165
+
166
+ const provider = new GuardedProvider({
167
+ principal: "agent:failing-audit",
168
+ authorityClient: mockClient,
169
+ auditExporter: failingExporter,
170
+ });
171
+
172
+ // Should not throw even though exporter fails
173
+ const result = await provider.authorize({
174
+ action: "fs.read",
175
+ resource: "/workspace/safe-file.txt",
176
+ args: { path: "/workspace/safe-file.txt" },
177
+ });
178
+
179
+ // Authorization should succeed despite audit failure
180
+ expect(result).toBeNull(); // No mandate ID returned in this case
181
+ expect(callCount).toBe(1); // Exporter was called
182
+ });
183
+
184
+ it("chains multiple decision events with consistent trace context", async () => {
185
+ const exportedEvents: DecisionTelemetryEvent[] = [];
186
+
187
+ const mockClient = {
188
+ authorize: vi
189
+ .fn()
190
+ .mockResolvedValueOnce({ allow: true, mandateId: "m1" })
191
+ .mockResolvedValueOnce({ allow: true, mandateId: "m2" })
192
+ .mockResolvedValueOnce({ allow: false, reason: "rate_limited" }),
193
+ };
194
+
195
+ const auditExporter: DecisionAuditExporter = {
196
+ exportDecision: async (event) => {
197
+ exportedEvents.push(event);
198
+ },
199
+ };
200
+
201
+ const provider = new GuardedProvider({
202
+ principal: "agent:chained-ops",
203
+ authorityClient: mockClient,
204
+ auditExporter,
205
+ });
206
+
207
+ const sharedContext = {
208
+ tenant_id: "tenant-chain",
209
+ session_id: "sess-chain",
210
+ trace_id: "trace-chain-root",
211
+ };
212
+
213
+ // First operation - allowed
214
+ await provider.authorize({
215
+ action: "fs.read",
216
+ resource: "/workspace/config.json",
217
+ args: { path: "/workspace/config.json" },
218
+ context: sharedContext,
219
+ });
220
+
221
+ // Second operation - allowed
222
+ await provider.authorize({
223
+ action: "net.http",
224
+ resource: "https://api.internal/fetch",
225
+ args: { url: "https://api.internal/fetch" },
226
+ context: sharedContext,
227
+ });
228
+
229
+ // Third operation - denied
230
+ try {
231
+ await provider.authorize({
232
+ action: "shell.execute",
233
+ resource: "curl external.com",
234
+ args: { cmd: "curl external.com" },
235
+ context: sharedContext,
236
+ });
237
+ } catch {
238
+ // Expected denial
239
+ }
240
+
241
+ expect(exportedEvents).toHaveLength(3);
242
+
243
+ // All events should share the same trace context
244
+ for (const event of exportedEvents) {
245
+ expect(event.tenantId).toBe("tenant-chain");
246
+ expect(event.sessionId).toBe("sess-chain");
247
+ expect(event.traceId).toBe("trace-chain-root");
248
+ }
249
+
250
+ // Verify outcomes
251
+ expect(exportedEvents[0].outcome).toBe("allow");
252
+ expect(exportedEvents[0].mandateId).toBe("m1");
253
+ expect(exportedEvents[1].outcome).toBe("allow");
254
+ expect(exportedEvents[1].mandateId).toBe("m2");
255
+ expect(exportedEvents[2].outcome).toBe("deny");
256
+ expect(exportedEvents[2].reason).toBe("rate_limited");
257
+ });
258
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createAuthorityAdapter } from "../src/authority-client.js";
3
+
4
+ describe("createAuthorityAdapter", () => {
5
+ it("maps SDK allow decisions", async () => {
6
+ const adapter = createAuthorityAdapter({
7
+ authorize: async () => ({
8
+ allowed: true,
9
+ reason: "allowed",
10
+ mandate_id: "mnd_ok",
11
+ }),
12
+ });
13
+
14
+ const decision = await adapter.authorize({
15
+ principal: "agent:openclaw-local",
16
+ action: "shell.execute",
17
+ resource: "echo hi",
18
+ intent_hash: "ih",
19
+ labels: [],
20
+ });
21
+
22
+ expect(decision).toEqual({
23
+ allow: true,
24
+ reason: "allowed",
25
+ mandateId: "mnd_ok",
26
+ });
27
+ });
28
+
29
+ it("maps SDK deny decisions", async () => {
30
+ const adapter = createAuthorityAdapter({
31
+ authorize: async () => ({
32
+ allowed: false,
33
+ reason: "explicit_deny",
34
+ mandate_id: undefined,
35
+ }),
36
+ });
37
+
38
+ const decision = await adapter.authorize({
39
+ principal: "agent:openclaw-local",
40
+ action: "fs.read",
41
+ resource: "/etc/passwd",
42
+ intent_hash: "ih",
43
+ labels: ["source:untrusted_dm"],
44
+ });
45
+
46
+ expect(decision).toEqual({
47
+ allow: false,
48
+ reason: "explicit_deny",
49
+ mandateId: undefined,
50
+ });
51
+ });
52
+ });
@@ -0,0 +1,266 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ calculateBackoff,
4
+ CircuitBreaker,
5
+ CircuitOpenError,
6
+ defaultBackoffConfig,
7
+ defaultCircuitBreakerConfig,
8
+ withCircuitBreaker,
9
+ type CircuitState,
10
+ } from "../src/circuit-breaker.js";
11
+
12
+ describe("CircuitBreaker", () => {
13
+ it("starts in closed state", () => {
14
+ const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
15
+ expect(breaker.getState()).toBe("closed");
16
+ expect(breaker.allowRequest()).toBe(true);
17
+ });
18
+
19
+ it("opens after reaching failure threshold", () => {
20
+ const breaker = new CircuitBreaker({
21
+ ...defaultCircuitBreakerConfig,
22
+ failureThreshold: 3,
23
+ });
24
+
25
+ breaker.recordFailure();
26
+ expect(breaker.getState()).toBe("closed");
27
+
28
+ breaker.recordFailure();
29
+ expect(breaker.getState()).toBe("closed");
30
+
31
+ breaker.recordFailure();
32
+ expect(breaker.getState()).toBe("open");
33
+ expect(breaker.allowRequest()).toBe(false);
34
+ });
35
+
36
+ it("transitions to half-open after reset timeout", async () => {
37
+ const breaker = new CircuitBreaker({
38
+ ...defaultCircuitBreakerConfig,
39
+ failureThreshold: 1,
40
+ resetTimeoutMs: 50,
41
+ });
42
+
43
+ breaker.recordFailure();
44
+ expect(breaker.getState()).toBe("open");
45
+ expect(breaker.allowRequest()).toBe(false);
46
+
47
+ // Wait for reset timeout
48
+ await new Promise((r) => setTimeout(r, 60));
49
+
50
+ expect(breaker.allowRequest()).toBe(true);
51
+ expect(breaker.getState()).toBe("half_open");
52
+ });
53
+
54
+ it("closes after success threshold in half-open", async () => {
55
+ const breaker = new CircuitBreaker({
56
+ ...defaultCircuitBreakerConfig,
57
+ failureThreshold: 1,
58
+ resetTimeoutMs: 10,
59
+ successThreshold: 2,
60
+ });
61
+
62
+ breaker.recordFailure();
63
+ expect(breaker.getState()).toBe("open");
64
+
65
+ await new Promise((r) => setTimeout(r, 15));
66
+ breaker.allowRequest(); // Triggers half-open
67
+
68
+ expect(breaker.getState()).toBe("half_open");
69
+
70
+ breaker.recordSuccess();
71
+ expect(breaker.getState()).toBe("half_open");
72
+
73
+ breaker.recordSuccess();
74
+ expect(breaker.getState()).toBe("closed");
75
+ });
76
+
77
+ it("reopens on failure in half-open state", async () => {
78
+ const breaker = new CircuitBreaker({
79
+ ...defaultCircuitBreakerConfig,
80
+ failureThreshold: 1,
81
+ resetTimeoutMs: 10,
82
+ });
83
+
84
+ breaker.recordFailure();
85
+ await new Promise((r) => setTimeout(r, 15));
86
+ breaker.allowRequest();
87
+
88
+ expect(breaker.getState()).toBe("half_open");
89
+
90
+ breaker.recordFailure();
91
+ expect(breaker.getState()).toBe("open");
92
+ });
93
+
94
+ it("resets failure count on success in closed state", () => {
95
+ const breaker = new CircuitBreaker({
96
+ ...defaultCircuitBreakerConfig,
97
+ failureThreshold: 3,
98
+ });
99
+
100
+ breaker.recordFailure();
101
+ breaker.recordFailure();
102
+ breaker.recordSuccess();
103
+ breaker.recordFailure();
104
+ breaker.recordFailure();
105
+
106
+ // Should still be closed because success reset the count
107
+ expect(breaker.getState()).toBe("closed");
108
+ });
109
+
110
+ it("tracks metrics correctly", () => {
111
+ const breaker = new CircuitBreaker({
112
+ ...defaultCircuitBreakerConfig,
113
+ failureThreshold: 2,
114
+ });
115
+
116
+ breaker.recordSuccess();
117
+ breaker.recordSuccess();
118
+ breaker.recordFailure();
119
+ breaker.recordFailure();
120
+ breaker.allowRequest(); // Should be rejected
121
+
122
+ const metrics = breaker.getMetrics();
123
+ expect(metrics.totalSuccesses).toBe(2);
124
+ expect(metrics.totalFailures).toBe(2);
125
+ expect(metrics.totalRejections).toBe(1);
126
+ expect(metrics.state).toBe("open");
127
+ });
128
+
129
+ it("calls onStateChange callback", () => {
130
+ const stateChanges: Array<{ from: CircuitState; to: CircuitState }> = [];
131
+
132
+ const breaker = new CircuitBreaker({
133
+ ...defaultCircuitBreakerConfig,
134
+ failureThreshold: 1,
135
+ onStateChange: (from, to) => stateChanges.push({ from, to }),
136
+ });
137
+
138
+ breaker.recordFailure();
139
+ expect(stateChanges).toHaveLength(1);
140
+ expect(stateChanges[0]).toEqual({ from: "closed", to: "open" });
141
+ });
142
+
143
+ it("can be manually reset", () => {
144
+ const breaker = new CircuitBreaker({
145
+ ...defaultCircuitBreakerConfig,
146
+ failureThreshold: 1,
147
+ });
148
+
149
+ breaker.recordFailure();
150
+ expect(breaker.getState()).toBe("open");
151
+
152
+ breaker.reset();
153
+ expect(breaker.getState()).toBe("closed");
154
+ expect(breaker.allowRequest()).toBe(true);
155
+ });
156
+ });
157
+
158
+ describe("calculateBackoff", () => {
159
+ it("calculates exponential backoff", () => {
160
+ const config = { ...defaultBackoffConfig, jitterFactor: 0 };
161
+
162
+ expect(calculateBackoff(0, config)).toBe(100);
163
+ expect(calculateBackoff(1, config)).toBe(200);
164
+ expect(calculateBackoff(2, config)).toBe(400);
165
+ expect(calculateBackoff(3, config)).toBe(800);
166
+ });
167
+
168
+ it("respects max backoff", () => {
169
+ const config = { ...defaultBackoffConfig, jitterFactor: 0, maxMs: 500 };
170
+
171
+ expect(calculateBackoff(0, config)).toBe(100);
172
+ expect(calculateBackoff(1, config)).toBe(200);
173
+ expect(calculateBackoff(2, config)).toBe(400);
174
+ expect(calculateBackoff(3, config)).toBe(500); // Capped at max
175
+ expect(calculateBackoff(10, config)).toBe(500);
176
+ });
177
+
178
+ it("adds jitter within bounds", () => {
179
+ const config = { ...defaultBackoffConfig, jitterFactor: 0.5 };
180
+ const results = new Set<number>();
181
+
182
+ for (let i = 0; i < 20; i++) {
183
+ results.add(calculateBackoff(0, config));
184
+ }
185
+
186
+ // With jitter, we should get varied results
187
+ expect(results.size).toBeGreaterThan(1);
188
+
189
+ // All results should be within expected range (100 +/- 50)
190
+ for (const result of results) {
191
+ expect(result).toBeGreaterThanOrEqual(50);
192
+ expect(result).toBeLessThanOrEqual(150);
193
+ }
194
+ });
195
+ });
196
+
197
+ describe("withCircuitBreaker", () => {
198
+ it("executes function and records success", async () => {
199
+ const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
200
+ const fn = vi.fn().mockResolvedValue("result");
201
+
202
+ const result = await withCircuitBreaker(breaker, fn);
203
+
204
+ expect(result).toBe("result");
205
+ expect(fn).toHaveBeenCalledTimes(1);
206
+ expect(breaker.getMetrics().totalSuccesses).toBe(1);
207
+ });
208
+
209
+ it("throws CircuitOpenError when circuit is open", async () => {
210
+ const breaker = new CircuitBreaker({
211
+ ...defaultCircuitBreakerConfig,
212
+ failureThreshold: 1,
213
+ });
214
+
215
+ breaker.recordFailure();
216
+ expect(breaker.getState()).toBe("open");
217
+
218
+ const fn = vi.fn().mockResolvedValue("result");
219
+
220
+ await expect(withCircuitBreaker(breaker, fn)).rejects.toThrow(
221
+ CircuitOpenError,
222
+ );
223
+ expect(fn).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("retries with backoff on failure", async () => {
227
+ const breaker = new CircuitBreaker({
228
+ ...defaultCircuitBreakerConfig,
229
+ failureThreshold: 10,
230
+ });
231
+
232
+ let attempts = 0;
233
+ const fn = vi.fn().mockImplementation(() => {
234
+ attempts++;
235
+ if (attempts < 3) {
236
+ return Promise.reject(new Error("fail"));
237
+ }
238
+ return Promise.resolve("success");
239
+ });
240
+
241
+ const result = await withCircuitBreaker(breaker, fn, {
242
+ maxRetries: 3,
243
+ backoffConfig: { ...defaultBackoffConfig, initialMs: 10, jitterFactor: 0 },
244
+ });
245
+
246
+ expect(result).toBe("success");
247
+ expect(fn).toHaveBeenCalledTimes(3);
248
+ });
249
+
250
+ it("respects isFailure predicate", async () => {
251
+ const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
252
+
253
+ const businessError = new Error("business_error");
254
+ const fn = vi.fn().mockRejectedValue(businessError);
255
+
256
+ // Business errors should not trigger circuit breaker
257
+ await expect(
258
+ withCircuitBreaker(breaker, fn, {
259
+ isFailure: (e) => !(e instanceof Error && e.message === "business_error"),
260
+ }),
261
+ ).rejects.toThrow("business_error");
262
+
263
+ // No failures recorded
264
+ expect(breaker.getMetrics().totalFailures).toBe(0);
265
+ });
266
+ });
@@ -0,0 +1,9 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+
5
+ ROOT = Path(__file__).resolve().parents[1]
6
+ SRC = ROOT / "src"
7
+
8
+ if str(SRC) not in sys.path:
9
+ sys.path.insert(0, str(SRC))
@@ -0,0 +1,114 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ ControlPlaneSyncClient,
4
+ ControlPlaneSyncStatusTracker,
5
+ syncControlPlaneState,
6
+ } from "../src/control-plane-sync.js";
7
+
8
+ describe("ControlPlaneSyncClient", () => {
9
+ afterEach(() => {
10
+ vi.unstubAllGlobals();
11
+ });
12
+
13
+ it("pulls policy and revocation snapshots", async () => {
14
+ const fetchMock = vi.fn(async (input: string) => {
15
+ if (input.includes("/v1/policy/sync")) {
16
+ return {
17
+ ok: true,
18
+ status: 200,
19
+ json: async () => ({
20
+ version: "p-1",
21
+ cursor: "pc-1",
22
+ rules: [{ name: "allow-safe-read" }],
23
+ }),
24
+ };
25
+ }
26
+ if (input.includes("/v1/revocations/sync")) {
27
+ return {
28
+ ok: true,
29
+ status: 200,
30
+ json: async () => ({
31
+ version: "r-1",
32
+ cursor: "rc-1",
33
+ revoked: [{ type: "principal", id: "agent:deny" }],
34
+ }),
35
+ };
36
+ }
37
+ throw new Error(`Unexpected URL: ${input}`);
38
+ });
39
+ vi.stubGlobal("fetch", fetchMock);
40
+
41
+ const client = new ControlPlaneSyncClient({
42
+ baseUrl: "http://127.0.0.1:9000",
43
+ tenantId: "tenant-a",
44
+ });
45
+ const result = await syncControlPlaneState(client, {
46
+ policyCursor: "p0",
47
+ revocationCursor: "r0",
48
+ });
49
+
50
+ expect(result.policy.version).toBe("p-1");
51
+ expect(result.revocations.version).toBe("r-1");
52
+ expect(fetchMock).toHaveBeenCalledTimes(2);
53
+ });
54
+
55
+ it("throws on non-OK sync responses", async () => {
56
+ vi.stubGlobal("fetch", async () => ({
57
+ ok: false,
58
+ status: 503,
59
+ json: async () => ({ error: "unavailable" }),
60
+ }));
61
+
62
+ const client = new ControlPlaneSyncClient({
63
+ baseUrl: "http://127.0.0.1:9000",
64
+ tenantId: "tenant-a",
65
+ });
66
+
67
+ await expect(client.pullPolicySnapshot("c1")).rejects.toThrow(
68
+ "policy sync failed",
69
+ );
70
+ });
71
+
72
+ it("flags policy version mismatch against pinned version", () => {
73
+ const statuses: Array<{ policyVersionMismatch: boolean; stale: boolean }> = [];
74
+ const tracker = new ControlPlaneSyncStatusTracker({
75
+ pinnedPolicyVersion: "p-expected",
76
+ staleAfterMs: 300000,
77
+ onStatus: (status) => {
78
+ statuses.push({
79
+ policyVersionMismatch: status.policyVersionMismatch,
80
+ stale: status.stale,
81
+ });
82
+ },
83
+ });
84
+
85
+ const status = tracker.recordSync(
86
+ {
87
+ policy: { version: "p-actual", cursor: "pc-1", rules: [] },
88
+ revocations: { version: "r-1", cursor: "rc-1", revoked: [] },
89
+ },
90
+ 1000,
91
+ );
92
+
93
+ expect(status.policyVersionMismatch).toBe(true);
94
+ expect(status.stale).toBe(false);
95
+ expect(statuses).toEqual([{ policyVersionMismatch: true, stale: false }]);
96
+ });
97
+
98
+ it("reports stale sync state when sync age exceeds threshold", () => {
99
+ const tracker = new ControlPlaneSyncStatusTracker({
100
+ staleAfterMs: 5000,
101
+ });
102
+ tracker.recordSync(
103
+ {
104
+ policy: { version: "p-1", cursor: "pc-1", rules: [] },
105
+ revocations: { version: "r-1", cursor: "rc-1", revoked: [] },
106
+ },
107
+ 1000,
108
+ );
109
+
110
+ const status = tracker.snapshot(7001);
111
+ expect(status.stale).toBe(true);
112
+ expect(status.syncAgeMs).toBe(6001);
113
+ });
114
+ });