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,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createAuthorityAdapter } from "../src/authority-client.js";
3
+ describe("createAuthorityAdapter", () => {
4
+ it("maps SDK allow decisions", async () => {
5
+ const adapter = createAuthorityAdapter({
6
+ authorize: async () => ({
7
+ allowed: true,
8
+ reason: "allowed",
9
+ mandate_id: "mnd_ok",
10
+ }),
11
+ });
12
+ const decision = await adapter.authorize({
13
+ principal: "agent:openclaw-local",
14
+ action: "shell.execute",
15
+ resource: "echo hi",
16
+ intent_hash: "ih",
17
+ labels: [],
18
+ });
19
+ expect(decision).toEqual({
20
+ allow: true,
21
+ reason: "allowed",
22
+ mandateId: "mnd_ok",
23
+ });
24
+ });
25
+ it("maps SDK deny decisions", async () => {
26
+ const adapter = createAuthorityAdapter({
27
+ authorize: async () => ({
28
+ allowed: false,
29
+ reason: "explicit_deny",
30
+ mandate_id: undefined,
31
+ }),
32
+ });
33
+ const decision = await adapter.authorize({
34
+ principal: "agent:openclaw-local",
35
+ action: "fs.read",
36
+ resource: "/etc/passwd",
37
+ intent_hash: "ih",
38
+ labels: ["source:untrusted_dm"],
39
+ });
40
+ expect(decision).toEqual({
41
+ allow: false,
42
+ reason: "explicit_deny",
43
+ mandateId: undefined,
44
+ });
45
+ });
46
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,200 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { calculateBackoff, CircuitBreaker, CircuitOpenError, defaultBackoffConfig, defaultCircuitBreakerConfig, withCircuitBreaker, } from "../src/circuit-breaker.js";
3
+ describe("CircuitBreaker", () => {
4
+ it("starts in closed state", () => {
5
+ const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
6
+ expect(breaker.getState()).toBe("closed");
7
+ expect(breaker.allowRequest()).toBe(true);
8
+ });
9
+ it("opens after reaching failure threshold", () => {
10
+ const breaker = new CircuitBreaker({
11
+ ...defaultCircuitBreakerConfig,
12
+ failureThreshold: 3,
13
+ });
14
+ breaker.recordFailure();
15
+ expect(breaker.getState()).toBe("closed");
16
+ breaker.recordFailure();
17
+ expect(breaker.getState()).toBe("closed");
18
+ breaker.recordFailure();
19
+ expect(breaker.getState()).toBe("open");
20
+ expect(breaker.allowRequest()).toBe(false);
21
+ });
22
+ it("transitions to half-open after reset timeout", async () => {
23
+ const breaker = new CircuitBreaker({
24
+ ...defaultCircuitBreakerConfig,
25
+ failureThreshold: 1,
26
+ resetTimeoutMs: 50,
27
+ });
28
+ breaker.recordFailure();
29
+ expect(breaker.getState()).toBe("open");
30
+ expect(breaker.allowRequest()).toBe(false);
31
+ // Wait for reset timeout
32
+ await new Promise((r) => setTimeout(r, 60));
33
+ expect(breaker.allowRequest()).toBe(true);
34
+ expect(breaker.getState()).toBe("half_open");
35
+ });
36
+ it("closes after success threshold in half-open", async () => {
37
+ const breaker = new CircuitBreaker({
38
+ ...defaultCircuitBreakerConfig,
39
+ failureThreshold: 1,
40
+ resetTimeoutMs: 10,
41
+ successThreshold: 2,
42
+ });
43
+ breaker.recordFailure();
44
+ expect(breaker.getState()).toBe("open");
45
+ await new Promise((r) => setTimeout(r, 15));
46
+ breaker.allowRequest(); // Triggers half-open
47
+ expect(breaker.getState()).toBe("half_open");
48
+ breaker.recordSuccess();
49
+ expect(breaker.getState()).toBe("half_open");
50
+ breaker.recordSuccess();
51
+ expect(breaker.getState()).toBe("closed");
52
+ });
53
+ it("reopens on failure in half-open state", async () => {
54
+ const breaker = new CircuitBreaker({
55
+ ...defaultCircuitBreakerConfig,
56
+ failureThreshold: 1,
57
+ resetTimeoutMs: 10,
58
+ });
59
+ breaker.recordFailure();
60
+ await new Promise((r) => setTimeout(r, 15));
61
+ breaker.allowRequest();
62
+ expect(breaker.getState()).toBe("half_open");
63
+ breaker.recordFailure();
64
+ expect(breaker.getState()).toBe("open");
65
+ });
66
+ it("resets failure count on success in closed state", () => {
67
+ const breaker = new CircuitBreaker({
68
+ ...defaultCircuitBreakerConfig,
69
+ failureThreshold: 3,
70
+ });
71
+ breaker.recordFailure();
72
+ breaker.recordFailure();
73
+ breaker.recordSuccess();
74
+ breaker.recordFailure();
75
+ breaker.recordFailure();
76
+ // Should still be closed because success reset the count
77
+ expect(breaker.getState()).toBe("closed");
78
+ });
79
+ it("tracks metrics correctly", () => {
80
+ const breaker = new CircuitBreaker({
81
+ ...defaultCircuitBreakerConfig,
82
+ failureThreshold: 2,
83
+ });
84
+ breaker.recordSuccess();
85
+ breaker.recordSuccess();
86
+ breaker.recordFailure();
87
+ breaker.recordFailure();
88
+ breaker.allowRequest(); // Should be rejected
89
+ const metrics = breaker.getMetrics();
90
+ expect(metrics.totalSuccesses).toBe(2);
91
+ expect(metrics.totalFailures).toBe(2);
92
+ expect(metrics.totalRejections).toBe(1);
93
+ expect(metrics.state).toBe("open");
94
+ });
95
+ it("calls onStateChange callback", () => {
96
+ const stateChanges = [];
97
+ const breaker = new CircuitBreaker({
98
+ ...defaultCircuitBreakerConfig,
99
+ failureThreshold: 1,
100
+ onStateChange: (from, to) => stateChanges.push({ from, to }),
101
+ });
102
+ breaker.recordFailure();
103
+ expect(stateChanges).toHaveLength(1);
104
+ expect(stateChanges[0]).toEqual({ from: "closed", to: "open" });
105
+ });
106
+ it("can be manually reset", () => {
107
+ const breaker = new CircuitBreaker({
108
+ ...defaultCircuitBreakerConfig,
109
+ failureThreshold: 1,
110
+ });
111
+ breaker.recordFailure();
112
+ expect(breaker.getState()).toBe("open");
113
+ breaker.reset();
114
+ expect(breaker.getState()).toBe("closed");
115
+ expect(breaker.allowRequest()).toBe(true);
116
+ });
117
+ });
118
+ describe("calculateBackoff", () => {
119
+ it("calculates exponential backoff", () => {
120
+ const config = { ...defaultBackoffConfig, jitterFactor: 0 };
121
+ expect(calculateBackoff(0, config)).toBe(100);
122
+ expect(calculateBackoff(1, config)).toBe(200);
123
+ expect(calculateBackoff(2, config)).toBe(400);
124
+ expect(calculateBackoff(3, config)).toBe(800);
125
+ });
126
+ it("respects max backoff", () => {
127
+ const config = { ...defaultBackoffConfig, jitterFactor: 0, maxMs: 500 };
128
+ expect(calculateBackoff(0, config)).toBe(100);
129
+ expect(calculateBackoff(1, config)).toBe(200);
130
+ expect(calculateBackoff(2, config)).toBe(400);
131
+ expect(calculateBackoff(3, config)).toBe(500); // Capped at max
132
+ expect(calculateBackoff(10, config)).toBe(500);
133
+ });
134
+ it("adds jitter within bounds", () => {
135
+ const config = { ...defaultBackoffConfig, jitterFactor: 0.5 };
136
+ const results = new Set();
137
+ for (let i = 0; i < 20; i++) {
138
+ results.add(calculateBackoff(0, config));
139
+ }
140
+ // With jitter, we should get varied results
141
+ expect(results.size).toBeGreaterThan(1);
142
+ // All results should be within expected range (100 +/- 50)
143
+ for (const result of results) {
144
+ expect(result).toBeGreaterThanOrEqual(50);
145
+ expect(result).toBeLessThanOrEqual(150);
146
+ }
147
+ });
148
+ });
149
+ describe("withCircuitBreaker", () => {
150
+ it("executes function and records success", async () => {
151
+ const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
152
+ const fn = vi.fn().mockResolvedValue("result");
153
+ const result = await withCircuitBreaker(breaker, fn);
154
+ expect(result).toBe("result");
155
+ expect(fn).toHaveBeenCalledTimes(1);
156
+ expect(breaker.getMetrics().totalSuccesses).toBe(1);
157
+ });
158
+ it("throws CircuitOpenError when circuit is open", async () => {
159
+ const breaker = new CircuitBreaker({
160
+ ...defaultCircuitBreakerConfig,
161
+ failureThreshold: 1,
162
+ });
163
+ breaker.recordFailure();
164
+ expect(breaker.getState()).toBe("open");
165
+ const fn = vi.fn().mockResolvedValue("result");
166
+ await expect(withCircuitBreaker(breaker, fn)).rejects.toThrow(CircuitOpenError);
167
+ expect(fn).not.toHaveBeenCalled();
168
+ });
169
+ it("retries with backoff on failure", async () => {
170
+ const breaker = new CircuitBreaker({
171
+ ...defaultCircuitBreakerConfig,
172
+ failureThreshold: 10,
173
+ });
174
+ let attempts = 0;
175
+ const fn = vi.fn().mockImplementation(() => {
176
+ attempts++;
177
+ if (attempts < 3) {
178
+ return Promise.reject(new Error("fail"));
179
+ }
180
+ return Promise.resolve("success");
181
+ });
182
+ const result = await withCircuitBreaker(breaker, fn, {
183
+ maxRetries: 3,
184
+ backoffConfig: { ...defaultBackoffConfig, initialMs: 10, jitterFactor: 0 },
185
+ });
186
+ expect(result).toBe("success");
187
+ expect(fn).toHaveBeenCalledTimes(3);
188
+ });
189
+ it("respects isFailure predicate", async () => {
190
+ const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
191
+ const businessError = new Error("business_error");
192
+ const fn = vi.fn().mockRejectedValue(businessError);
193
+ // Business errors should not trigger circuit breaker
194
+ await expect(withCircuitBreaker(breaker, fn, {
195
+ isFailure: (e) => !(e instanceof Error && e.message === "business_error"),
196
+ })).rejects.toThrow("business_error");
197
+ // No failures recorded
198
+ expect(breaker.getMetrics().totalFailures).toBe(0);
199
+ });
200
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { ControlPlaneSyncClient, ControlPlaneSyncStatusTracker, syncControlPlaneState, } from "../src/control-plane-sync.js";
3
+ describe("ControlPlaneSyncClient", () => {
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ });
7
+ it("pulls policy and revocation snapshots", async () => {
8
+ const fetchMock = vi.fn(async (input) => {
9
+ if (input.includes("/v1/policy/sync")) {
10
+ return {
11
+ ok: true,
12
+ status: 200,
13
+ json: async () => ({
14
+ version: "p-1",
15
+ cursor: "pc-1",
16
+ rules: [{ name: "allow-safe-read" }],
17
+ }),
18
+ };
19
+ }
20
+ if (input.includes("/v1/revocations/sync")) {
21
+ return {
22
+ ok: true,
23
+ status: 200,
24
+ json: async () => ({
25
+ version: "r-1",
26
+ cursor: "rc-1",
27
+ revoked: [{ type: "principal", id: "agent:deny" }],
28
+ }),
29
+ };
30
+ }
31
+ throw new Error(`Unexpected URL: ${input}`);
32
+ });
33
+ vi.stubGlobal("fetch", fetchMock);
34
+ const client = new ControlPlaneSyncClient({
35
+ baseUrl: "http://127.0.0.1:9000",
36
+ tenantId: "tenant-a",
37
+ });
38
+ const result = await syncControlPlaneState(client, {
39
+ policyCursor: "p0",
40
+ revocationCursor: "r0",
41
+ });
42
+ expect(result.policy.version).toBe("p-1");
43
+ expect(result.revocations.version).toBe("r-1");
44
+ expect(fetchMock).toHaveBeenCalledTimes(2);
45
+ });
46
+ it("throws on non-OK sync responses", async () => {
47
+ vi.stubGlobal("fetch", async () => ({
48
+ ok: false,
49
+ status: 503,
50
+ json: async () => ({ error: "unavailable" }),
51
+ }));
52
+ const client = new ControlPlaneSyncClient({
53
+ baseUrl: "http://127.0.0.1:9000",
54
+ tenantId: "tenant-a",
55
+ });
56
+ await expect(client.pullPolicySnapshot("c1")).rejects.toThrow("policy sync failed");
57
+ });
58
+ it("flags policy version mismatch against pinned version", () => {
59
+ const statuses = [];
60
+ const tracker = new ControlPlaneSyncStatusTracker({
61
+ pinnedPolicyVersion: "p-expected",
62
+ staleAfterMs: 300000,
63
+ onStatus: (status) => {
64
+ statuses.push({
65
+ policyVersionMismatch: status.policyVersionMismatch,
66
+ stale: status.stale,
67
+ });
68
+ },
69
+ });
70
+ const status = tracker.recordSync({
71
+ policy: { version: "p-actual", cursor: "pc-1", rules: [] },
72
+ revocations: { version: "r-1", cursor: "rc-1", revoked: [] },
73
+ }, 1000);
74
+ expect(status.policyVersionMismatch).toBe(true);
75
+ expect(status.stale).toBe(false);
76
+ expect(statuses).toEqual([{ policyVersionMismatch: true, stale: false }]);
77
+ });
78
+ it("reports stale sync state when sync age exceeds threshold", () => {
79
+ const tracker = new ControlPlaneSyncStatusTracker({
80
+ staleAfterMs: 5000,
81
+ });
82
+ tracker.recordSync({
83
+ policy: { version: "p-1", cursor: "pc-1", rules: [] },
84
+ revocations: { version: "r-1", cursor: "rc-1", revoked: [] },
85
+ }, 1000);
86
+ const status = tracker.snapshot(7001);
87
+ expect(status.stale).toBe(true);
88
+ expect(status.syncAgeMs).toBe(6001);
89
+ });
90
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ToolAdapter } from "../src/adapter.js";
3
+ import { ActionDeniedError, GuardedProvider } from "../src/provider.js";
4
+ describe("Hack vs Fix demo scenario", () => {
5
+ it("shows unguarded exfil path and guarded deny path", async () => {
6
+ const injectedArgs = { path: "/Users/demo/.ssh/id_rsa" };
7
+ const injectedContext = { source: "untrusted_dm" };
8
+ const unguardedRead = async (args) => {
9
+ return `SECRET:${String(args.path)}`;
10
+ };
11
+ const hacked = await unguardedRead(injectedArgs);
12
+ expect(hacked).toContain("SECRET:/Users/demo/.ssh/id_rsa");
13
+ const guardedProvider = new GuardedProvider({
14
+ principal: "agent:openclaw-local",
15
+ authorityClient: {
16
+ authorize: async (request) => {
17
+ const resource = String(request.resource ?? "");
18
+ const labels = Array.isArray(request.labels) ? request.labels : [];
19
+ const isUntrusted = labels.includes("source:untrusted_dm");
20
+ const isSensitiveRead = request.action === "fs.read" &&
21
+ (resource.includes("/.ssh/") || resource.startsWith("/etc/"));
22
+ if (isUntrusted && isSensitiveRead) {
23
+ return { allow: false, reason: "deny_sensitive_read_from_untrusted_context" };
24
+ }
25
+ return { allow: true, reason: "allowed", mandateId: "mnd_ok" };
26
+ },
27
+ },
28
+ });
29
+ const adapter = new ToolAdapter(guardedProvider);
30
+ await expect(adapter.readFile({
31
+ args: injectedArgs,
32
+ context: injectedContext,
33
+ execute: unguardedRead,
34
+ })).rejects.toBeInstanceOf(ActionDeniedError);
35
+ });
36
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,232 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { GuardedProvider } from "../src/provider.js";
3
+ /**
4
+ * Integration tests for JWKS/key-rotation-driven policy contexts.
5
+ *
6
+ * These tests verify that the provider correctly handles scenarios where
7
+ * policy decisions depend on JWT validation contexts that may change
8
+ * during key rotation events.
9
+ *
10
+ * Note: The provider passes context fields to the sidecar, which handles
11
+ * actual JWKS validation. These tests verify context propagation and
12
+ * decision handling, not the JWKS validation itself.
13
+ */
14
+ describe("JWKS and key rotation contexts", () => {
15
+ it("passes authorization request to sidecar for validation", async () => {
16
+ const capturedRequests = [];
17
+ const mockClient = {
18
+ authorize: vi.fn().mockImplementation((req) => {
19
+ capturedRequests.push(req);
20
+ return Promise.resolve({ allow: true });
21
+ }),
22
+ };
23
+ const provider = new GuardedProvider({
24
+ principal: "agent:jwks-test",
25
+ authorityClient: mockClient,
26
+ });
27
+ await provider.authorize({
28
+ action: "shell.execute",
29
+ resource: "npm install",
30
+ args: { cmd: "npm install" },
31
+ context: {
32
+ kid: "key-2024-02-20",
33
+ iss: "https://auth.example.com",
34
+ tenant_id: "tenant-a",
35
+ source: "trusted_ui",
36
+ },
37
+ });
38
+ expect(capturedRequests).toHaveLength(1);
39
+ expect(capturedRequests[0].principal).toBe("agent:jwks-test");
40
+ expect(capturedRequests[0].action).toBe("shell.execute");
41
+ expect(capturedRequests[0].labels).toContain("source:trusted_ui");
42
+ });
43
+ it("handles allow decisions from sidecar during key rotation", async () => {
44
+ // Sidecar accepts requests during rotation window
45
+ const mockClient = {
46
+ authorize: vi.fn().mockResolvedValue({
47
+ allow: true,
48
+ reason: "valid_key",
49
+ mandateId: "mandate-rotation-1",
50
+ }),
51
+ };
52
+ const provider = new GuardedProvider({
53
+ principal: "agent:rotation-test",
54
+ authorityClient: mockClient,
55
+ });
56
+ // Both old and new key contexts should be accepted by sidecar
57
+ const result1 = await provider.authorize({
58
+ action: "fs.read",
59
+ resource: "/config.json",
60
+ args: { path: "/config.json" },
61
+ context: { kid: "key-v1", source: "trusted_ui" },
62
+ });
63
+ const result2 = await provider.authorize({
64
+ action: "fs.read",
65
+ resource: "/settings.json",
66
+ args: { path: "/settings.json" },
67
+ context: { kid: "key-v2", source: "trusted_ui" },
68
+ });
69
+ expect(result1).toBe("mandate-rotation-1");
70
+ expect(result2).toBe("mandate-rotation-1");
71
+ expect(mockClient.authorize).toHaveBeenCalledTimes(2);
72
+ });
73
+ it("handles deny decisions from sidecar for revoked keys", async () => {
74
+ let callCount = 0;
75
+ // Sidecar rejects old key, accepts new key
76
+ const mockClient = {
77
+ authorize: vi.fn().mockImplementation(async () => {
78
+ callCount++;
79
+ if (callCount === 1) {
80
+ // First call with old key - rejected
81
+ return { allow: false, reason: "key_revoked" };
82
+ }
83
+ // Second call with new key - accepted
84
+ return { allow: true, reason: "valid_key" };
85
+ }),
86
+ };
87
+ const provider = new GuardedProvider({
88
+ principal: "agent:post-rotation-test",
89
+ authorityClient: mockClient,
90
+ });
91
+ // Request with revoked old key
92
+ await expect(provider.authorize({
93
+ action: "shell.execute",
94
+ resource: "echo hello",
95
+ args: { cmd: "echo hello" },
96
+ context: { kid: "key-v1", source: "trusted_ui" },
97
+ })).rejects.toThrow("key_revoked");
98
+ // Request with valid new key
99
+ const result = await provider.authorize({
100
+ action: "shell.execute",
101
+ resource: "echo hello",
102
+ args: { cmd: "echo hello" },
103
+ context: { kid: "key-v2", source: "trusted_ui" },
104
+ });
105
+ expect(result).toBeNull(); // No mandate ID in this mock response
106
+ });
107
+ it("propagates issuer context for IdP validation", async () => {
108
+ const capturedRequests = [];
109
+ const mockClient = {
110
+ authorize: vi.fn().mockImplementation((req) => {
111
+ capturedRequests.push(req);
112
+ return Promise.resolve({ allow: true });
113
+ }),
114
+ };
115
+ const provider = new GuardedProvider({
116
+ principal: "agent:idp-test",
117
+ authorityClient: mockClient,
118
+ });
119
+ await provider.authorize({
120
+ action: "net.http",
121
+ resource: "https://api.internal.com/data",
122
+ args: { url: "https://api.internal.com/data" },
123
+ context: {
124
+ iss: "https://login.microsoftonline.com/tenant-id/v2.0",
125
+ aud: "api://predicate-authority",
126
+ sub: "user@example.com",
127
+ tenant_id: "tenant-azure",
128
+ source: "azure_entra",
129
+ },
130
+ });
131
+ expect(capturedRequests).toHaveLength(1);
132
+ expect(capturedRequests[0].labels).toContain("source:azure_entra");
133
+ });
134
+ it("handles expired token rejection from sidecar", async () => {
135
+ const mockClient = {
136
+ authorize: vi.fn().mockResolvedValue({
137
+ allow: false,
138
+ reason: "token_expired",
139
+ }),
140
+ };
141
+ const provider = new GuardedProvider({
142
+ principal: "agent:expired-test",
143
+ authorityClient: mockClient,
144
+ });
145
+ await expect(provider.authorize({
146
+ action: "fs.write",
147
+ resource: "/data.json",
148
+ args: { path: "/data.json", content: "{}" },
149
+ context: {
150
+ exp: Math.floor(Date.now() / 1000) - 3600,
151
+ iat: Math.floor(Date.now() / 1000) - 7200,
152
+ source: "trusted_ui",
153
+ },
154
+ })).rejects.toThrow("token_expired");
155
+ });
156
+ it("handles multiple concurrent requests with sidecar", async () => {
157
+ let concurrentCalls = 0;
158
+ let maxConcurrent = 0;
159
+ const mockClient = {
160
+ authorize: vi.fn().mockImplementation(async () => {
161
+ concurrentCalls++;
162
+ maxConcurrent = Math.max(maxConcurrent, concurrentCalls);
163
+ // Small delay to allow concurrency
164
+ await new Promise((r) => setTimeout(r, 5));
165
+ concurrentCalls--;
166
+ return { allow: true };
167
+ }),
168
+ };
169
+ const provider = new GuardedProvider({
170
+ principal: "agent:multi-key-test",
171
+ authorityClient: mockClient,
172
+ });
173
+ await Promise.all([
174
+ provider.authorize({
175
+ action: "fs.read",
176
+ resource: "/a.txt",
177
+ args: { path: "/a.txt" },
178
+ context: { session_id: "session-a", source: "trusted_ui" },
179
+ }),
180
+ provider.authorize({
181
+ action: "fs.read",
182
+ resource: "/b.txt",
183
+ args: { path: "/b.txt" },
184
+ context: { session_id: "session-b", source: "trusted_ui" },
185
+ }),
186
+ provider.authorize({
187
+ action: "fs.read",
188
+ resource: "/c.txt",
189
+ args: { path: "/c.txt" },
190
+ context: { session_id: "session-a", source: "trusted_ui" },
191
+ }),
192
+ ]);
193
+ expect(mockClient.authorize).toHaveBeenCalledTimes(3);
194
+ // Should have had some concurrent calls
195
+ expect(maxConcurrent).toBeGreaterThan(1);
196
+ });
197
+ it("includes source label for policy evaluation by key trust level", async () => {
198
+ const capturedRequests = [];
199
+ const mockClient = {
200
+ authorize: vi.fn().mockImplementation((req) => {
201
+ capturedRequests.push(req);
202
+ const labels = req.labels ?? [];
203
+ // Sidecar policy: only allow trusted sources
204
+ if (labels.includes("source:untrusted_dm")) {
205
+ return Promise.resolve({ allow: false, reason: "untrusted_source" });
206
+ }
207
+ return Promise.resolve({ allow: true });
208
+ }),
209
+ };
210
+ const provider = new GuardedProvider({
211
+ principal: "agent:trust-test",
212
+ authorityClient: mockClient,
213
+ });
214
+ // Trusted source - allowed
215
+ await provider.authorize({
216
+ action: "shell.execute",
217
+ resource: "npm install",
218
+ args: { cmd: "npm install" },
219
+ context: { source: "trusted_ui" },
220
+ });
221
+ // Untrusted source - denied
222
+ await expect(provider.authorize({
223
+ action: "shell.execute",
224
+ resource: "curl evil.com",
225
+ args: { cmd: "curl evil.com" },
226
+ context: { source: "untrusted_dm" },
227
+ })).rejects.toThrow("untrusted_source");
228
+ expect(capturedRequests).toHaveLength(2);
229
+ expect(capturedRequests[0].labels).toContain("source:trusted_ui");
230
+ expect(capturedRequests[1].labels).toContain("source:untrusted_dm");
231
+ });
232
+ });
@@ -0,0 +1 @@
1
+ export {};