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,175 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { GuardedProvider } from "../src/provider.js";
3
+ /**
4
+ * Load and latency tests for high-frequency tool invocation paths.
5
+ *
6
+ * These tests verify that the provider can handle burst traffic while
7
+ * maintaining acceptable latency characteristics.
8
+ */
9
+ describe("load and latency", () => {
10
+ it("handles burst of 100 sequential authorizations under 500ms total", async () => {
11
+ const mockClient = {
12
+ authorize: vi.fn().mockResolvedValue({ allow: true, mandateId: "m1" }),
13
+ };
14
+ const provider = new GuardedProvider({
15
+ principal: "agent:load-test",
16
+ authorityClient: mockClient,
17
+ });
18
+ const iterations = 100;
19
+ const start = performance.now();
20
+ for (let i = 0; i < iterations; i++) {
21
+ await provider.authorize({
22
+ action: "fs.read",
23
+ resource: `/workspace/file-${i}.txt`,
24
+ args: { path: `/workspace/file-${i}.txt` },
25
+ });
26
+ }
27
+ const elapsed = performance.now() - start;
28
+ expect(mockClient.authorize).toHaveBeenCalledTimes(iterations);
29
+ // 100 calls should complete well under 500ms with mocked client
30
+ expect(elapsed).toBeLessThan(500);
31
+ });
32
+ it("handles 50 concurrent authorizations", async () => {
33
+ const mockClient = {
34
+ authorize: vi.fn().mockImplementation(async () => {
35
+ // Simulate small network delay
36
+ await new Promise((r) => setTimeout(r, 1));
37
+ return { allow: true, mandateId: "m1" };
38
+ }),
39
+ };
40
+ const provider = new GuardedProvider({
41
+ principal: "agent:concurrent-test",
42
+ authorityClient: mockClient,
43
+ });
44
+ const concurrency = 50;
45
+ const start = performance.now();
46
+ const promises = Array.from({ length: concurrency }, (_, i) => provider.authorize({
47
+ action: "shell.execute",
48
+ resource: `echo test-${i}`,
49
+ args: { cmd: `echo test-${i}` },
50
+ }));
51
+ const results = await Promise.all(promises);
52
+ const elapsed = performance.now() - start;
53
+ expect(results).toHaveLength(concurrency);
54
+ expect(mockClient.authorize).toHaveBeenCalledTimes(concurrency);
55
+ // Concurrent calls should complete faster than sequential
56
+ expect(elapsed).toBeLessThan(200);
57
+ });
58
+ it("maintains intent_hash computation performance", () => {
59
+ const iterations = 1000;
60
+ const start = performance.now();
61
+ for (let i = 0; i < iterations; i++) {
62
+ GuardedProvider.intentHash({
63
+ cmd: `echo "iteration ${i}"`,
64
+ workdir: "/workspace",
65
+ env: { NODE_ENV: "test", ITERATION: String(i) },
66
+ });
67
+ }
68
+ const elapsed = performance.now() - start;
69
+ // 1000 hash computations should complete under 100ms
70
+ expect(elapsed).toBeLessThan(100);
71
+ });
72
+ it("measures p50/p95 latency for authorization calls", async () => {
73
+ const latencies = [];
74
+ const mockClient = {
75
+ authorize: vi.fn().mockImplementation(async () => {
76
+ // Simulate variable latency (1-5ms)
77
+ const delay = 1 + Math.random() * 4;
78
+ await new Promise((r) => setTimeout(r, delay));
79
+ return { allow: true };
80
+ }),
81
+ };
82
+ const provider = new GuardedProvider({
83
+ principal: "agent:latency-test",
84
+ authorityClient: mockClient,
85
+ });
86
+ const iterations = 50;
87
+ for (let i = 0; i < iterations; i++) {
88
+ const start = performance.now();
89
+ await provider.authorize({
90
+ action: "net.http",
91
+ resource: "https://api.example.com",
92
+ args: { url: "https://api.example.com" },
93
+ });
94
+ latencies.push(performance.now() - start);
95
+ }
96
+ latencies.sort((a, b) => a - b);
97
+ const p50 = latencies[Math.floor(iterations * 0.5)];
98
+ const p95 = latencies[Math.floor(iterations * 0.95)];
99
+ // Verify latency targets from design doc (with mocked overhead)
100
+ // Design targets: p50 < 25ms, p95 < 75ms
101
+ // With mocked 1-5ms delay, we expect p50 < 15ms, p95 < 20ms
102
+ expect(p50).toBeLessThan(25);
103
+ expect(p95).toBeLessThan(75);
104
+ });
105
+ it("handles mixed allow/deny outcomes under load", async () => {
106
+ let callCount = 0;
107
+ const mockClient = {
108
+ authorize: vi.fn().mockImplementation(async () => {
109
+ callCount++;
110
+ // Alternate between allow and deny
111
+ if (callCount % 3 === 0) {
112
+ return { allow: false, reason: "rate_limited" };
113
+ }
114
+ return { allow: true, mandateId: `m${callCount}` };
115
+ }),
116
+ };
117
+ const provider = new GuardedProvider({
118
+ principal: "agent:mixed-test",
119
+ authorityClient: mockClient,
120
+ });
121
+ const iterations = 30;
122
+ const results = { allowed: 0, denied: 0 };
123
+ for (let i = 0; i < iterations; i++) {
124
+ try {
125
+ await provider.authorize({
126
+ action: "fs.write",
127
+ resource: `/workspace/file-${i}.txt`,
128
+ args: { path: `/workspace/file-${i}.txt`, content: "data" },
129
+ });
130
+ results.allowed++;
131
+ }
132
+ catch {
133
+ results.denied++;
134
+ }
135
+ }
136
+ expect(results.allowed).toBe(20); // 2/3 allowed
137
+ expect(results.denied).toBe(10); // 1/3 denied
138
+ expect(mockClient.authorize).toHaveBeenCalledTimes(iterations);
139
+ });
140
+ it("telemetry emission does not block authorization path", async () => {
141
+ const telemetryDelays = [];
142
+ const mockClient = {
143
+ authorize: vi.fn().mockResolvedValue({ allow: true }),
144
+ };
145
+ const slowTelemetry = {
146
+ onDecision: vi.fn().mockImplementation(() => {
147
+ // Simulate slow telemetry (should not block auth)
148
+ const start = performance.now();
149
+ while (performance.now() - start < 1) {
150
+ // Busy wait 1ms
151
+ }
152
+ telemetryDelays.push(performance.now() - start);
153
+ }),
154
+ };
155
+ const provider = new GuardedProvider({
156
+ principal: "agent:telemetry-test",
157
+ authorityClient: mockClient,
158
+ telemetry: slowTelemetry,
159
+ });
160
+ const iterations = 10;
161
+ const start = performance.now();
162
+ for (let i = 0; i < iterations; i++) {
163
+ await provider.authorize({
164
+ action: "fs.read",
165
+ resource: `/file-${i}.txt`,
166
+ args: { path: `/file-${i}.txt` },
167
+ });
168
+ }
169
+ const elapsed = performance.now() - start;
170
+ // Even with slow telemetry, auth should complete reasonably fast
171
+ // Telemetry runs synchronously here, but in production would be async
172
+ expect(elapsed).toBeLessThan(100);
173
+ expect(slowTelemetry.onDecision).toHaveBeenCalledTimes(iterations);
174
+ });
175
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { GuardedProvider, } from "../src/provider.js";
3
+ describe("multi-tenant isolation", () => {
4
+ it("propagates tenant_id through authorization request", async () => {
5
+ const capturedRequests = [];
6
+ const mockClient = {
7
+ authorize: vi.fn().mockImplementation((req) => {
8
+ capturedRequests.push(req);
9
+ return Promise.resolve({ allow: true, reason: "policy_pass" });
10
+ }),
11
+ };
12
+ const provider = new GuardedProvider({
13
+ principal: "agent:test-agent",
14
+ authorityClient: mockClient,
15
+ });
16
+ await provider.authorize({
17
+ action: "fs.read",
18
+ resource: "/workspace/file.txt",
19
+ args: { path: "/workspace/file.txt" },
20
+ context: {
21
+ tenant_id: "tenant-alpha",
22
+ session_id: "session-123",
23
+ source: "trusted_ui",
24
+ },
25
+ });
26
+ expect(capturedRequests).toHaveLength(1);
27
+ expect(capturedRequests[0].labels).toContain("source:trusted_ui");
28
+ });
29
+ it("isolates decisions by tenant in telemetry events", async () => {
30
+ const events = [];
31
+ const mockClient = {
32
+ authorize: vi
33
+ .fn()
34
+ .mockResolvedValue({ allow: true, reason: "tenant_policy" }),
35
+ };
36
+ const telemetry = {
37
+ onDecision: (event) => events.push(event),
38
+ };
39
+ const provider = new GuardedProvider({
40
+ principal: "agent:multi-tenant-agent",
41
+ authorityClient: mockClient,
42
+ telemetry,
43
+ });
44
+ // Authorize as tenant A
45
+ await provider.authorize({
46
+ action: "shell.execute",
47
+ resource: "echo hello",
48
+ args: { cmd: "echo hello" },
49
+ context: { tenant_id: "tenant-a", user_id: "user-a1" },
50
+ });
51
+ // Authorize as tenant B
52
+ await provider.authorize({
53
+ action: "shell.execute",
54
+ resource: "echo world",
55
+ args: { cmd: "echo world" },
56
+ context: { tenant_id: "tenant-b", user_id: "user-b1" },
57
+ });
58
+ expect(events).toHaveLength(2);
59
+ expect(events[0].tenantId).toBe("tenant-a");
60
+ expect(events[0].userId).toBe("user-a1");
61
+ expect(events[1].tenantId).toBe("tenant-b");
62
+ expect(events[1].userId).toBe("user-b1");
63
+ });
64
+ it("audit exports include tenant isolation context", async () => {
65
+ const exportedEvents = [];
66
+ const mockClient = {
67
+ authorize: vi.fn().mockResolvedValue({ allow: true }),
68
+ };
69
+ const auditExporter = {
70
+ exportDecision: async (event) => {
71
+ exportedEvents.push(event);
72
+ },
73
+ };
74
+ const provider = new GuardedProvider({
75
+ principal: "agent:audited-agent",
76
+ authorityClient: mockClient,
77
+ auditExporter,
78
+ });
79
+ await provider.authorize({
80
+ action: "net.http",
81
+ resource: "https://api.example.com/data",
82
+ args: { method: "GET", url: "https://api.example.com/data" },
83
+ context: {
84
+ tenant_id: "tenant-enterprise",
85
+ session_id: "sess-456",
86
+ trace_id: "trace-abc",
87
+ source: "trusted_ui",
88
+ },
89
+ });
90
+ expect(exportedEvents).toHaveLength(1);
91
+ expect(exportedEvents[0].tenantId).toBe("tenant-enterprise");
92
+ expect(exportedEvents[0].sessionId).toBe("sess-456");
93
+ expect(exportedEvents[0].traceId).toBe("trace-abc");
94
+ expect(exportedEvents[0].source).toBe("trusted_ui");
95
+ });
96
+ it("denials preserve tenant context in error events", async () => {
97
+ const events = [];
98
+ const mockClient = {
99
+ authorize: vi.fn().mockResolvedValue({
100
+ allow: false,
101
+ reason: "tenant_quota_exceeded",
102
+ }),
103
+ };
104
+ const telemetry = {
105
+ onDecision: (event) => events.push(event),
106
+ };
107
+ const provider = new GuardedProvider({
108
+ principal: "agent:quota-agent",
109
+ authorityClient: mockClient,
110
+ telemetry,
111
+ });
112
+ await expect(provider.authorize({
113
+ action: "shell.execute",
114
+ resource: "rm -rf /",
115
+ args: { cmd: "rm -rf /" },
116
+ context: { tenant_id: "tenant-restricted" },
117
+ })).rejects.toThrow();
118
+ expect(events).toHaveLength(1);
119
+ expect(events[0].outcome).toBe("deny");
120
+ expect(events[0].tenantId).toBe("tenant-restricted");
121
+ expect(events[0].reason).toBe("tenant_quota_exceeded");
122
+ });
123
+ it("handles missing tenant context gracefully", async () => {
124
+ const events = [];
125
+ const mockClient = {
126
+ authorize: vi.fn().mockResolvedValue({ allow: true }),
127
+ };
128
+ const telemetry = {
129
+ onDecision: (event) => events.push(event),
130
+ };
131
+ const provider = new GuardedProvider({
132
+ principal: "agent:no-tenant",
133
+ authorityClient: mockClient,
134
+ telemetry,
135
+ });
136
+ await provider.authorize({
137
+ action: "fs.read",
138
+ resource: "/tmp/file.txt",
139
+ args: { path: "/tmp/file.txt" },
140
+ // No context provided
141
+ });
142
+ expect(events).toHaveLength(1);
143
+ expect(events[0].tenantId).toBeUndefined();
144
+ expect(events[0].sessionId).toBeUndefined();
145
+ });
146
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { OpenClawDesktopAccessibilityEvidenceProvider, OpenClawTerminalEvidenceProvider, buildDesktopEvidenceFromProvider, buildTerminalEvidenceFromProvider, } from "../src/non-web-evidence.js";
3
+ describe("non-web evidence providers", () => {
4
+ describe("legacy mode (useCanonicalHash=false)", () => {
5
+ it("builds terminal session state evidence", async () => {
6
+ const provider = new OpenClawTerminalEvidenceProvider(() => ({
7
+ sessionId: "s-1",
8
+ terminalId: "t-1",
9
+ cwd: "/workspace",
10
+ command: "cat secrets.txt",
11
+ transcript: "line1\nline2\n",
12
+ observedAt: "2026-02-20T08:00:00.000Z",
13
+ confidence: 0.92,
14
+ }));
15
+ const snapshot = await provider.captureTerminalSnapshot();
16
+ const evidence = await buildTerminalEvidenceFromProvider(provider, {
17
+ useCanonicalHash: false,
18
+ });
19
+ expect(snapshot.session_id).toBe("s-1");
20
+ expect(snapshot.transcript_hash).toEqual(expect.any(String));
21
+ expect(evidence).toMatchObject({
22
+ source: "terminal",
23
+ schema_version: "terminal-v1",
24
+ confidence: 0.92,
25
+ });
26
+ expect(evidence.state_hash).toEqual(expect.any(String));
27
+ });
28
+ it("builds desktop accessibility state evidence", async () => {
29
+ const provider = new OpenClawDesktopAccessibilityEvidenceProvider(() => ({
30
+ appName: "Terminal",
31
+ windowTitle: "Deploy Prod",
32
+ focusedRole: "button",
33
+ focusedName: "Confirm",
34
+ uiTreeText: "root > dialog > button:Confirm",
35
+ observedAt: "2026-02-20T08:01:00.000Z",
36
+ confidence: 0.88,
37
+ }));
38
+ const snapshot = await provider.captureAccessibilitySnapshot();
39
+ const evidence = await buildDesktopEvidenceFromProvider(provider, {
40
+ useCanonicalHash: false,
41
+ });
42
+ expect(snapshot.ui_tree_hash).toEqual(expect.any(String));
43
+ expect(evidence).toMatchObject({
44
+ source: "desktop_accessibility",
45
+ schema_version: "desktop-a11y-v1",
46
+ confidence: 0.88,
47
+ });
48
+ expect(evidence.state_hash).toEqual(expect.any(String));
49
+ });
50
+ });
51
+ describe("canonical mode (default)", () => {
52
+ it("builds terminal evidence with canonical schema version", async () => {
53
+ const provider = new OpenClawTerminalEvidenceProvider(() => ({
54
+ sessionId: "s-1",
55
+ terminalId: "t-1",
56
+ cwd: "/workspace",
57
+ command: "npm test",
58
+ transcript: "\x1b[32mPASS\x1b[0m all tests",
59
+ observedAt: "2026-02-20T08:00:00.000Z",
60
+ confidence: 0.95,
61
+ }));
62
+ const evidence = await buildTerminalEvidenceFromProvider(provider);
63
+ expect(evidence).toMatchObject({
64
+ source: "terminal",
65
+ schema_version: "terminal:v1.0",
66
+ confidence: 0.95,
67
+ });
68
+ expect(evidence.state_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
69
+ });
70
+ it("builds desktop evidence with canonical schema version", async () => {
71
+ const provider = new OpenClawDesktopAccessibilityEvidenceProvider(() => ({
72
+ appName: "Firefox",
73
+ windowTitle: "GitHub",
74
+ focusedRole: "button",
75
+ focusedName: "Submit",
76
+ uiTreeText: "window > form > button:Submit",
77
+ observedAt: "2026-02-20T08:01:00.000Z",
78
+ confidence: 0.9,
79
+ }));
80
+ const evidence = await buildDesktopEvidenceFromProvider(provider);
81
+ expect(evidence).toMatchObject({
82
+ source: "desktop_accessibility",
83
+ schema_version: "desktop:v1.0",
84
+ confidence: 0.9,
85
+ });
86
+ expect(evidence.state_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
87
+ });
88
+ it("produces stable hashes for equivalent terminal inputs", async () => {
89
+ // Same content with different whitespace/ANSI codes
90
+ const provider1 = new OpenClawTerminalEvidenceProvider(() => ({
91
+ sessionId: "s-1",
92
+ command: " npm test ",
93
+ transcript: "\x1b[32mOK\x1b[0m",
94
+ }));
95
+ const provider2 = new OpenClawTerminalEvidenceProvider(() => ({
96
+ sessionId: "s-1",
97
+ command: "npm test",
98
+ transcript: "OK",
99
+ }));
100
+ const evidence1 = await buildTerminalEvidenceFromProvider(provider1);
101
+ const evidence2 = await buildTerminalEvidenceFromProvider(provider2);
102
+ // Canonical hashing should produce identical hashes
103
+ expect(evidence1.state_hash).toBe(evidence2.state_hash);
104
+ });
105
+ it("produces stable hashes for equivalent desktop inputs", async () => {
106
+ // Same content with different whitespace
107
+ const provider1 = new OpenClawDesktopAccessibilityEvidenceProvider(() => ({
108
+ appName: " Firefox ",
109
+ windowTitle: " GitHub ",
110
+ focusedRole: "BUTTON",
111
+ focusedName: " Submit ",
112
+ }));
113
+ const provider2 = new OpenClawDesktopAccessibilityEvidenceProvider(() => ({
114
+ appName: "Firefox",
115
+ windowTitle: "GitHub",
116
+ focusedRole: "button",
117
+ focusedName: "Submit",
118
+ }));
119
+ const evidence1 = await buildDesktopEvidenceFromProvider(provider1);
120
+ const evidence2 = await buildDesktopEvidenceFromProvider(provider2);
121
+ // Canonical hashing should produce identical hashes
122
+ expect(evidence1.state_hash).toBe(evidence2.state_hash);
123
+ });
124
+ it("passes raw transcript for canonical hashing in SDK", async () => {
125
+ const provider = new OpenClawTerminalEvidenceProvider(() => ({
126
+ sessionId: "s-1",
127
+ transcript: "\x1b[31mERROR\x1b[0m: something went wrong",
128
+ }));
129
+ const snapshot = await provider.captureTerminalSnapshot();
130
+ // transcript_hash now contains raw text (hashing is done by SDK when useCanonicalHash=true)
131
+ expect(snapshot.transcript_hash).toEqual(expect.any(String));
132
+ // Raw transcript with ANSI codes is 36 chars
133
+ expect(snapshot.transcript_hash).toBe("\x1b[31mERROR\x1b[0m: something went wrong");
134
+ // Verify the final evidence hash is canonical (sha256-prefixed)
135
+ const evidence = await buildTerminalEvidenceFromProvider(provider);
136
+ expect(evidence.state_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
137
+ });
138
+ });
139
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { HookEnvelope, OpenClawHooks } from "../src/openclaw-hooks.js";
3
+ describe("OpenClawHooks", () => {
4
+ it("renders hook context shape", () => {
5
+ const env = new HookEnvelope({
6
+ toolName: "cmd.run",
7
+ args: { command: "echo hi" },
8
+ sessionId: "s1",
9
+ source: "trusted_ui",
10
+ tenantId: "t1",
11
+ userId: "u1",
12
+ traceId: "tr1",
13
+ });
14
+ expect(env.context()).toMatchObject({
15
+ source: "trusted_ui",
16
+ tenant_id: "t1",
17
+ });
18
+ });
19
+ it("routes cmd hooks through the shell guard", async () => {
20
+ const calls = [];
21
+ const hooks = new OpenClawHooks({
22
+ runShell: async ({ execute, args }) => {
23
+ calls.push("shell");
24
+ return execute(args);
25
+ },
26
+ readFile: async ({ execute, args }) => execute(args),
27
+ httpRequest: async ({ execute, args }) => execute(args),
28
+ });
29
+ const result = await hooks.onCmdRun(new HookEnvelope({
30
+ toolName: "cmd.run",
31
+ args: { command: "echo hi" },
32
+ sessionId: "s1",
33
+ source: "trusted_ui",
34
+ }), async (args) => args);
35
+ expect(result).toEqual({ command: "echo hi" });
36
+ expect(calls).toEqual(["shell"]);
37
+ });
38
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { registerOpenClawPredicateTools } from "../src/openclaw-plugin-api.js";
3
+ describe("registerOpenClawPredicateTools", () => {
4
+ it("registers cmd/fs/http tools via registerTool API", async () => {
5
+ const registered = [];
6
+ const api = {
7
+ registerTool: (tool) => {
8
+ registered.push(tool);
9
+ },
10
+ };
11
+ const calls = [];
12
+ registerOpenClawPredicateTools(api, {
13
+ executeCmdRun: async (args) => {
14
+ calls.push(`cmd:${String(args.command ?? "")}`);
15
+ return { ok: true };
16
+ },
17
+ executeFsReadFile: async (args) => {
18
+ calls.push(`fs:${String(args.path ?? "")}`);
19
+ return { ok: true };
20
+ },
21
+ executeHttpRequest: async (args) => {
22
+ calls.push(`http:${String(args.url ?? "")}`);
23
+ return { ok: true };
24
+ },
25
+ });
26
+ expect(registered.map((tool) => tool.name)).toEqual([
27
+ "predicate_cmd_run",
28
+ "predicate_fs_read_file",
29
+ "predicate_http_request",
30
+ ]);
31
+ await registered[0].execute("run-1", { command: "echo hi" });
32
+ await registered[1].execute("run-2", { path: "/tmp/demo" });
33
+ await registered[2].execute("run-3", { url: "https://example.com" });
34
+ expect(calls).toEqual([
35
+ "cmd:echo hi",
36
+ "fs:/tmp/demo",
37
+ "http:https://example.com",
38
+ ]);
39
+ });
40
+ });
@@ -0,0 +1 @@
1
+ export {};