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,183 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { AuthorizationRequest } from "@predicatesystems/authority";
|
|
3
|
+
import {
|
|
4
|
+
type DecisionAuditExporter,
|
|
5
|
+
type DecisionTelemetryEvent,
|
|
6
|
+
GuardedProvider,
|
|
7
|
+
} from "../src/provider.js";
|
|
8
|
+
|
|
9
|
+
describe("multi-tenant isolation", () => {
|
|
10
|
+
it("propagates tenant_id through authorization request", async () => {
|
|
11
|
+
const capturedRequests: AuthorizationRequest[] = [];
|
|
12
|
+
|
|
13
|
+
const mockClient = {
|
|
14
|
+
authorize: vi.fn().mockImplementation((req: AuthorizationRequest) => {
|
|
15
|
+
capturedRequests.push(req);
|
|
16
|
+
return Promise.resolve({ allow: true, reason: "policy_pass" });
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const provider = new GuardedProvider({
|
|
21
|
+
principal: "agent:test-agent",
|
|
22
|
+
authorityClient: mockClient,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
await provider.authorize({
|
|
26
|
+
action: "fs.read",
|
|
27
|
+
resource: "/workspace/file.txt",
|
|
28
|
+
args: { path: "/workspace/file.txt" },
|
|
29
|
+
context: {
|
|
30
|
+
tenant_id: "tenant-alpha",
|
|
31
|
+
session_id: "session-123",
|
|
32
|
+
source: "trusted_ui",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(capturedRequests).toHaveLength(1);
|
|
37
|
+
expect(capturedRequests[0].labels).toContain("source:trusted_ui");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("isolates decisions by tenant in telemetry events", async () => {
|
|
41
|
+
const events: DecisionTelemetryEvent[] = [];
|
|
42
|
+
|
|
43
|
+
const mockClient = {
|
|
44
|
+
authorize: vi
|
|
45
|
+
.fn()
|
|
46
|
+
.mockResolvedValue({ allow: true, reason: "tenant_policy" }),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const telemetry = {
|
|
50
|
+
onDecision: (event: DecisionTelemetryEvent) => events.push(event),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const provider = new GuardedProvider({
|
|
54
|
+
principal: "agent:multi-tenant-agent",
|
|
55
|
+
authorityClient: mockClient,
|
|
56
|
+
telemetry,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Authorize as tenant A
|
|
60
|
+
await provider.authorize({
|
|
61
|
+
action: "shell.execute",
|
|
62
|
+
resource: "echo hello",
|
|
63
|
+
args: { cmd: "echo hello" },
|
|
64
|
+
context: { tenant_id: "tenant-a", user_id: "user-a1" },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Authorize as tenant B
|
|
68
|
+
await provider.authorize({
|
|
69
|
+
action: "shell.execute",
|
|
70
|
+
resource: "echo world",
|
|
71
|
+
args: { cmd: "echo world" },
|
|
72
|
+
context: { tenant_id: "tenant-b", user_id: "user-b1" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(events).toHaveLength(2);
|
|
76
|
+
expect(events[0].tenantId).toBe("tenant-a");
|
|
77
|
+
expect(events[0].userId).toBe("user-a1");
|
|
78
|
+
expect(events[1].tenantId).toBe("tenant-b");
|
|
79
|
+
expect(events[1].userId).toBe("user-b1");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("audit exports include tenant isolation context", async () => {
|
|
83
|
+
const exportedEvents: DecisionTelemetryEvent[] = [];
|
|
84
|
+
|
|
85
|
+
const mockClient = {
|
|
86
|
+
authorize: vi.fn().mockResolvedValue({ allow: true }),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const auditExporter: DecisionAuditExporter = {
|
|
90
|
+
exportDecision: async (event) => {
|
|
91
|
+
exportedEvents.push(event);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const provider = new GuardedProvider({
|
|
96
|
+
principal: "agent:audited-agent",
|
|
97
|
+
authorityClient: mockClient,
|
|
98
|
+
auditExporter,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await provider.authorize({
|
|
102
|
+
action: "net.http",
|
|
103
|
+
resource: "https://api.example.com/data",
|
|
104
|
+
args: { method: "GET", url: "https://api.example.com/data" },
|
|
105
|
+
context: {
|
|
106
|
+
tenant_id: "tenant-enterprise",
|
|
107
|
+
session_id: "sess-456",
|
|
108
|
+
trace_id: "trace-abc",
|
|
109
|
+
source: "trusted_ui",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(exportedEvents).toHaveLength(1);
|
|
114
|
+
expect(exportedEvents[0].tenantId).toBe("tenant-enterprise");
|
|
115
|
+
expect(exportedEvents[0].sessionId).toBe("sess-456");
|
|
116
|
+
expect(exportedEvents[0].traceId).toBe("trace-abc");
|
|
117
|
+
expect(exportedEvents[0].source).toBe("trusted_ui");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("denials preserve tenant context in error events", async () => {
|
|
121
|
+
const events: DecisionTelemetryEvent[] = [];
|
|
122
|
+
|
|
123
|
+
const mockClient = {
|
|
124
|
+
authorize: vi.fn().mockResolvedValue({
|
|
125
|
+
allow: false,
|
|
126
|
+
reason: "tenant_quota_exceeded",
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const telemetry = {
|
|
131
|
+
onDecision: (event: DecisionTelemetryEvent) => events.push(event),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const provider = new GuardedProvider({
|
|
135
|
+
principal: "agent:quota-agent",
|
|
136
|
+
authorityClient: mockClient,
|
|
137
|
+
telemetry,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await expect(
|
|
141
|
+
provider.authorize({
|
|
142
|
+
action: "shell.execute",
|
|
143
|
+
resource: "rm -rf /",
|
|
144
|
+
args: { cmd: "rm -rf /" },
|
|
145
|
+
context: { tenant_id: "tenant-restricted" },
|
|
146
|
+
}),
|
|
147
|
+
).rejects.toThrow();
|
|
148
|
+
|
|
149
|
+
expect(events).toHaveLength(1);
|
|
150
|
+
expect(events[0].outcome).toBe("deny");
|
|
151
|
+
expect(events[0].tenantId).toBe("tenant-restricted");
|
|
152
|
+
expect(events[0].reason).toBe("tenant_quota_exceeded");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("handles missing tenant context gracefully", async () => {
|
|
156
|
+
const events: DecisionTelemetryEvent[] = [];
|
|
157
|
+
|
|
158
|
+
const mockClient = {
|
|
159
|
+
authorize: vi.fn().mockResolvedValue({ allow: true }),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const telemetry = {
|
|
163
|
+
onDecision: (event: DecisionTelemetryEvent) => events.push(event),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const provider = new GuardedProvider({
|
|
167
|
+
principal: "agent:no-tenant",
|
|
168
|
+
authorityClient: mockClient,
|
|
169
|
+
telemetry,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await provider.authorize({
|
|
173
|
+
action: "fs.read",
|
|
174
|
+
resource: "/tmp/file.txt",
|
|
175
|
+
args: { path: "/tmp/file.txt" },
|
|
176
|
+
// No context provided
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(events).toHaveLength(1);
|
|
180
|
+
expect(events[0].tenantId).toBeUndefined();
|
|
181
|
+
expect(events[0].sessionId).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
OpenClawDesktopAccessibilityEvidenceProvider,
|
|
4
|
+
OpenClawTerminalEvidenceProvider,
|
|
5
|
+
buildDesktopEvidenceFromProvider,
|
|
6
|
+
buildTerminalEvidenceFromProvider,
|
|
7
|
+
} from "../src/non-web-evidence.js";
|
|
8
|
+
|
|
9
|
+
describe("non-web evidence providers", () => {
|
|
10
|
+
describe("legacy mode (useCanonicalHash=false)", () => {
|
|
11
|
+
it("builds terminal session state evidence", async () => {
|
|
12
|
+
const provider = new OpenClawTerminalEvidenceProvider(() => ({
|
|
13
|
+
sessionId: "s-1",
|
|
14
|
+
terminalId: "t-1",
|
|
15
|
+
cwd: "/workspace",
|
|
16
|
+
command: "cat secrets.txt",
|
|
17
|
+
transcript: "line1\nline2\n",
|
|
18
|
+
observedAt: "2026-02-20T08:00:00.000Z",
|
|
19
|
+
confidence: 0.92,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const snapshot = await provider.captureTerminalSnapshot();
|
|
23
|
+
const evidence = await buildTerminalEvidenceFromProvider(provider, {
|
|
24
|
+
useCanonicalHash: false,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(snapshot.session_id).toBe("s-1");
|
|
28
|
+
expect(snapshot.transcript_hash).toEqual(expect.any(String));
|
|
29
|
+
expect(evidence).toMatchObject({
|
|
30
|
+
source: "terminal",
|
|
31
|
+
schema_version: "terminal-v1",
|
|
32
|
+
confidence: 0.92,
|
|
33
|
+
});
|
|
34
|
+
expect(evidence.state_hash).toEqual(expect.any(String));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("builds desktop accessibility state evidence", async () => {
|
|
38
|
+
const provider = new OpenClawDesktopAccessibilityEvidenceProvider(() => ({
|
|
39
|
+
appName: "Terminal",
|
|
40
|
+
windowTitle: "Deploy Prod",
|
|
41
|
+
focusedRole: "button",
|
|
42
|
+
focusedName: "Confirm",
|
|
43
|
+
uiTreeText: "root > dialog > button:Confirm",
|
|
44
|
+
observedAt: "2026-02-20T08:01:00.000Z",
|
|
45
|
+
confidence: 0.88,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const snapshot = await provider.captureAccessibilitySnapshot();
|
|
49
|
+
const evidence = await buildDesktopEvidenceFromProvider(provider, {
|
|
50
|
+
useCanonicalHash: false,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(snapshot.ui_tree_hash).toEqual(expect.any(String));
|
|
54
|
+
expect(evidence).toMatchObject({
|
|
55
|
+
source: "desktop_accessibility",
|
|
56
|
+
schema_version: "desktop-a11y-v1",
|
|
57
|
+
confidence: 0.88,
|
|
58
|
+
});
|
|
59
|
+
expect(evidence.state_hash).toEqual(expect.any(String));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("canonical mode (default)", () => {
|
|
64
|
+
it("builds terminal evidence with canonical schema version", async () => {
|
|
65
|
+
const provider = new OpenClawTerminalEvidenceProvider(() => ({
|
|
66
|
+
sessionId: "s-1",
|
|
67
|
+
terminalId: "t-1",
|
|
68
|
+
cwd: "/workspace",
|
|
69
|
+
command: "npm test",
|
|
70
|
+
transcript: "\x1b[32mPASS\x1b[0m all tests",
|
|
71
|
+
observedAt: "2026-02-20T08:00:00.000Z",
|
|
72
|
+
confidence: 0.95,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const evidence = await buildTerminalEvidenceFromProvider(provider);
|
|
76
|
+
|
|
77
|
+
expect(evidence).toMatchObject({
|
|
78
|
+
source: "terminal",
|
|
79
|
+
schema_version: "terminal:v1.0",
|
|
80
|
+
confidence: 0.95,
|
|
81
|
+
});
|
|
82
|
+
expect(evidence.state_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("builds desktop evidence with canonical schema version", async () => {
|
|
86
|
+
const provider = new OpenClawDesktopAccessibilityEvidenceProvider(() => ({
|
|
87
|
+
appName: "Firefox",
|
|
88
|
+
windowTitle: "GitHub",
|
|
89
|
+
focusedRole: "button",
|
|
90
|
+
focusedName: "Submit",
|
|
91
|
+
uiTreeText: "window > form > button:Submit",
|
|
92
|
+
observedAt: "2026-02-20T08:01:00.000Z",
|
|
93
|
+
confidence: 0.9,
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const evidence = await buildDesktopEvidenceFromProvider(provider);
|
|
97
|
+
|
|
98
|
+
expect(evidence).toMatchObject({
|
|
99
|
+
source: "desktop_accessibility",
|
|
100
|
+
schema_version: "desktop:v1.0",
|
|
101
|
+
confidence: 0.9,
|
|
102
|
+
});
|
|
103
|
+
expect(evidence.state_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("produces stable hashes for equivalent terminal inputs", async () => {
|
|
107
|
+
// Same content with different whitespace/ANSI codes
|
|
108
|
+
const provider1 = new OpenClawTerminalEvidenceProvider(() => ({
|
|
109
|
+
sessionId: "s-1",
|
|
110
|
+
command: " npm test ",
|
|
111
|
+
transcript: "\x1b[32mOK\x1b[0m",
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
const provider2 = new OpenClawTerminalEvidenceProvider(() => ({
|
|
115
|
+
sessionId: "s-1",
|
|
116
|
+
command: "npm test",
|
|
117
|
+
transcript: "OK",
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const evidence1 = await buildTerminalEvidenceFromProvider(provider1);
|
|
121
|
+
const evidence2 = await buildTerminalEvidenceFromProvider(provider2);
|
|
122
|
+
|
|
123
|
+
// Canonical hashing should produce identical hashes
|
|
124
|
+
expect(evidence1.state_hash).toBe(evidence2.state_hash);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("produces stable hashes for equivalent desktop inputs", async () => {
|
|
128
|
+
// Same content with different whitespace
|
|
129
|
+
const provider1 = new OpenClawDesktopAccessibilityEvidenceProvider(() => ({
|
|
130
|
+
appName: " Firefox ",
|
|
131
|
+
windowTitle: " GitHub ",
|
|
132
|
+
focusedRole: "BUTTON",
|
|
133
|
+
focusedName: " Submit ",
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
const provider2 = new OpenClawDesktopAccessibilityEvidenceProvider(() => ({
|
|
137
|
+
appName: "Firefox",
|
|
138
|
+
windowTitle: "GitHub",
|
|
139
|
+
focusedRole: "button",
|
|
140
|
+
focusedName: "Submit",
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
const evidence1 = await buildDesktopEvidenceFromProvider(provider1);
|
|
144
|
+
const evidence2 = await buildDesktopEvidenceFromProvider(provider2);
|
|
145
|
+
|
|
146
|
+
// Canonical hashing should produce identical hashes
|
|
147
|
+
expect(evidence1.state_hash).toBe(evidence2.state_hash);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("passes raw transcript for canonical hashing in SDK", async () => {
|
|
151
|
+
const provider = new OpenClawTerminalEvidenceProvider(() => ({
|
|
152
|
+
sessionId: "s-1",
|
|
153
|
+
transcript: "\x1b[31mERROR\x1b[0m: something went wrong",
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
const snapshot = await provider.captureTerminalSnapshot();
|
|
157
|
+
|
|
158
|
+
// transcript_hash now contains raw text (hashing is done by SDK when useCanonicalHash=true)
|
|
159
|
+
expect(snapshot.transcript_hash).toEqual(expect.any(String));
|
|
160
|
+
// Raw transcript with ANSI codes is 36 chars
|
|
161
|
+
expect(snapshot.transcript_hash).toBe("\x1b[31mERROR\x1b[0m: something went wrong");
|
|
162
|
+
|
|
163
|
+
// Verify the final evidence hash is canonical (sha256-prefixed)
|
|
164
|
+
const evidence = await buildTerminalEvidenceFromProvider(provider);
|
|
165
|
+
expect(evidence.state_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { HookEnvelope, OpenClawHooks } from "../src/openclaw-hooks.js";
|
|
3
|
+
|
|
4
|
+
describe("OpenClawHooks", () => {
|
|
5
|
+
it("renders hook context shape", () => {
|
|
6
|
+
const env = new HookEnvelope({
|
|
7
|
+
toolName: "cmd.run",
|
|
8
|
+
args: { command: "echo hi" },
|
|
9
|
+
sessionId: "s1",
|
|
10
|
+
source: "trusted_ui",
|
|
11
|
+
tenantId: "t1",
|
|
12
|
+
userId: "u1",
|
|
13
|
+
traceId: "tr1",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(env.context()).toMatchObject({
|
|
17
|
+
source: "trusted_ui",
|
|
18
|
+
tenant_id: "t1",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("routes cmd hooks through the shell guard", async () => {
|
|
23
|
+
const calls: string[] = [];
|
|
24
|
+
const hooks = new OpenClawHooks({
|
|
25
|
+
runShell: async ({ execute, args }) => {
|
|
26
|
+
calls.push("shell");
|
|
27
|
+
return execute(args);
|
|
28
|
+
},
|
|
29
|
+
readFile: async ({ execute, args }) => execute(args),
|
|
30
|
+
httpRequest: async ({ execute, args }) => execute(args),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const result = await hooks.onCmdRun(
|
|
34
|
+
new HookEnvelope({
|
|
35
|
+
toolName: "cmd.run",
|
|
36
|
+
args: { command: "echo hi" },
|
|
37
|
+
sessionId: "s1",
|
|
38
|
+
source: "trusted_ui",
|
|
39
|
+
}),
|
|
40
|
+
async (args) => args,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({ command: "echo hi" });
|
|
44
|
+
expect(calls).toEqual(["shell"]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { registerOpenClawPredicateTools } from "../src/openclaw-plugin-api.js";
|
|
3
|
+
|
|
4
|
+
interface RegisteredTool {
|
|
5
|
+
name: string;
|
|
6
|
+
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("registerOpenClawPredicateTools", () => {
|
|
10
|
+
it("registers cmd/fs/http tools via registerTool API", async () => {
|
|
11
|
+
const registered: RegisteredTool[] = [];
|
|
12
|
+
const api = {
|
|
13
|
+
registerTool: (tool: RegisteredTool) => {
|
|
14
|
+
registered.push(tool);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const calls: string[] = [];
|
|
18
|
+
|
|
19
|
+
registerOpenClawPredicateTools(api, {
|
|
20
|
+
executeCmdRun: async (args) => {
|
|
21
|
+
calls.push(`cmd:${String(args.command ?? "")}`);
|
|
22
|
+
return { ok: true };
|
|
23
|
+
},
|
|
24
|
+
executeFsReadFile: async (args) => {
|
|
25
|
+
calls.push(`fs:${String(args.path ?? "")}`);
|
|
26
|
+
return { ok: true };
|
|
27
|
+
},
|
|
28
|
+
executeHttpRequest: async (args) => {
|
|
29
|
+
calls.push(`http:${String(args.url ?? "")}`);
|
|
30
|
+
return { ok: true };
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(registered.map((tool) => tool.name)).toEqual([
|
|
35
|
+
"predicate_cmd_run",
|
|
36
|
+
"predicate_fs_read_file",
|
|
37
|
+
"predicate_http_request",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
await registered[0].execute("run-1", { command: "echo hi" });
|
|
41
|
+
await registered[1].execute("run-2", { path: "/tmp/demo" });
|
|
42
|
+
await registered[2].execute("run-3", { url: "https://example.com" });
|
|
43
|
+
|
|
44
|
+
expect(calls).toEqual([
|
|
45
|
+
"cmd:echo hi",
|
|
46
|
+
"fs:/tmp/demo",
|
|
47
|
+
"http:https://example.com",
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ActionDeniedError,
|
|
4
|
+
GuardedProvider,
|
|
5
|
+
SidecarUnavailableError,
|
|
6
|
+
} from "../src/provider.js";
|
|
7
|
+
|
|
8
|
+
describe("GuardedProvider", () => {
|
|
9
|
+
it("builds deterministic intent hash", () => {
|
|
10
|
+
const first = GuardedProvider.intentHash({ cmd: "ls", flags: ["-la"] });
|
|
11
|
+
const second = GuardedProvider.intentHash({ flags: ["-la"], cmd: "ls" });
|
|
12
|
+
|
|
13
|
+
expect(first).toBe(second);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("fails closed when sidecar is unavailable", async () => {
|
|
17
|
+
const provider = new GuardedProvider({
|
|
18
|
+
principal: "p1",
|
|
19
|
+
config: { failClosed: true },
|
|
20
|
+
authorityClient: {
|
|
21
|
+
authorize: async () => {
|
|
22
|
+
throw new SidecarUnavailableError("down");
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await expect(
|
|
28
|
+
provider.guardOrThrow({
|
|
29
|
+
action: "shell.execute",
|
|
30
|
+
resource: "echo hi",
|
|
31
|
+
args: { command: "echo hi" },
|
|
32
|
+
}),
|
|
33
|
+
).rejects.toBeInstanceOf(SidecarUnavailableError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns null in fail-open mode when sidecar is unavailable", async () => {
|
|
37
|
+
const provider = new GuardedProvider({
|
|
38
|
+
principal: "p1",
|
|
39
|
+
config: { failClosed: false },
|
|
40
|
+
authorityClient: {
|
|
41
|
+
authorize: async () => {
|
|
42
|
+
throw new SidecarUnavailableError("down");
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await expect(
|
|
48
|
+
provider.guardOrThrow({
|
|
49
|
+
action: "shell.execute",
|
|
50
|
+
resource: "echo hi",
|
|
51
|
+
args: { command: "echo hi" },
|
|
52
|
+
}),
|
|
53
|
+
).resolves.toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("emits decision telemetry for allow and deny", async () => {
|
|
57
|
+
const events: Array<{ outcome: string; reason?: string }> = [];
|
|
58
|
+
const provider = new GuardedProvider({
|
|
59
|
+
principal: "p1",
|
|
60
|
+
telemetry: {
|
|
61
|
+
onDecision: (event) => {
|
|
62
|
+
events.push({ outcome: event.outcome, reason: event.reason });
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
authorityClient: {
|
|
66
|
+
authorize: async (request) => ({
|
|
67
|
+
allow: request.action !== "fs.read",
|
|
68
|
+
reason: request.action === "fs.read" ? "explicit_deny" : "allowed",
|
|
69
|
+
mandateId: request.action === "fs.read" ? undefined : "mnd_ok",
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
provider.guardOrThrow({
|
|
76
|
+
action: "shell.execute",
|
|
77
|
+
resource: "echo hi",
|
|
78
|
+
args: { command: "echo hi" },
|
|
79
|
+
context: { source: "trusted_ui" },
|
|
80
|
+
}),
|
|
81
|
+
).resolves.toBe("mnd_ok");
|
|
82
|
+
|
|
83
|
+
await expect(
|
|
84
|
+
provider.guardOrThrow({
|
|
85
|
+
action: "fs.read",
|
|
86
|
+
resource: "/etc/passwd",
|
|
87
|
+
args: { path: "/etc/passwd" },
|
|
88
|
+
context: { source: "untrusted_dm" },
|
|
89
|
+
}),
|
|
90
|
+
).rejects.toBeInstanceOf(ActionDeniedError);
|
|
91
|
+
|
|
92
|
+
expect(events).toEqual([
|
|
93
|
+
{ outcome: "allow", reason: "allowed" },
|
|
94
|
+
{ outcome: "deny", reason: "explicit_deny" },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("sends canonical wire request fields to authority client", async () => {
|
|
99
|
+
let captured: Record<string, unknown> | undefined;
|
|
100
|
+
const provider = new GuardedProvider({
|
|
101
|
+
principal: "agent:openclaw-local",
|
|
102
|
+
authorityClient: {
|
|
103
|
+
authorize: async (request) => {
|
|
104
|
+
captured = request as unknown as Record<string, unknown>;
|
|
105
|
+
return { allow: true, reason: "allowed", mandateId: "mnd_ok" };
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await provider.guardOrThrow({
|
|
111
|
+
action: "shell.execute",
|
|
112
|
+
resource: "echo hi",
|
|
113
|
+
args: { command: "echo hi", flags: ["-n"] },
|
|
114
|
+
context: { source: "trusted_ui", session_id: "s1" },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(captured).toBeDefined();
|
|
118
|
+
expect(captured?.principal).toBe("agent:openclaw-local");
|
|
119
|
+
expect(captured?.action).toBe("shell.execute");
|
|
120
|
+
expect(captured?.resource).toBe("echo hi");
|
|
121
|
+
expect(captured?.intent_hash).toEqual(expect.any(String));
|
|
122
|
+
expect(captured?.labels).toEqual(["source:trusted_ui"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("exports decision audit events with correlation context", async () => {
|
|
126
|
+
const exported: Array<Record<string, unknown>> = [];
|
|
127
|
+
const provider = new GuardedProvider({
|
|
128
|
+
principal: "agent:openclaw-local",
|
|
129
|
+
auditExporter: {
|
|
130
|
+
exportDecision: async (event) => {
|
|
131
|
+
exported.push(event as unknown as Record<string, unknown>);
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
authorityClient: {
|
|
135
|
+
authorize: async () => ({
|
|
136
|
+
allow: true,
|
|
137
|
+
reason: "allowed",
|
|
138
|
+
mandateId: "mnd_123",
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await provider.guardOrThrow({
|
|
144
|
+
action: "shell.execute",
|
|
145
|
+
resource: "echo hi",
|
|
146
|
+
args: { command: "echo hi" },
|
|
147
|
+
context: {
|
|
148
|
+
source: "trusted_ui",
|
|
149
|
+
session_id: "session-1",
|
|
150
|
+
tenant_id: "tenant-1",
|
|
151
|
+
trace_id: "trace-1",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(exported).toHaveLength(1);
|
|
156
|
+
expect(exported[0]).toMatchObject({
|
|
157
|
+
principal: "agent:openclaw-local",
|
|
158
|
+
action: "shell.execute",
|
|
159
|
+
outcome: "allow",
|
|
160
|
+
mandateId: "mnd_123",
|
|
161
|
+
sessionId: "session-1",
|
|
162
|
+
tenantId: "tenant-1",
|
|
163
|
+
traceId: "trace-1",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not block authorization if audit export fails", async () => {
|
|
168
|
+
const provider = new GuardedProvider({
|
|
169
|
+
principal: "agent:openclaw-local",
|
|
170
|
+
auditExporter: {
|
|
171
|
+
exportDecision: async () => {
|
|
172
|
+
throw new Error("audit export unavailable");
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
authorityClient: {
|
|
176
|
+
authorize: async () => ({
|
|
177
|
+
allow: true,
|
|
178
|
+
reason: "allowed",
|
|
179
|
+
mandateId: "mnd_456",
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await expect(
|
|
185
|
+
provider.guardOrThrow({
|
|
186
|
+
action: "shell.execute",
|
|
187
|
+
resource: "echo ok",
|
|
188
|
+
args: { command: "echo ok" },
|
|
189
|
+
context: { source: "trusted_ui" },
|
|
190
|
+
}),
|
|
191
|
+
).resolves.toBe("mnd_456");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("preserves deny reason and redacts sensitive resource in audit export", async () => {
|
|
195
|
+
const exported: Array<Record<string, unknown>> = [];
|
|
196
|
+
const provider = new GuardedProvider({
|
|
197
|
+
principal: "agent:openclaw-local",
|
|
198
|
+
auditExporter: {
|
|
199
|
+
exportDecision: async (event) => {
|
|
200
|
+
exported.push(event as unknown as Record<string, unknown>);
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
authorityClient: {
|
|
204
|
+
authorize: async () => ({
|
|
205
|
+
allow: false,
|
|
206
|
+
reason: "deny_sensitive_read_from_untrusted_context",
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await expect(
|
|
212
|
+
provider.guardOrThrow({
|
|
213
|
+
action: "fs.read",
|
|
214
|
+
resource: "/Users/demo/.ssh/id_rsa",
|
|
215
|
+
args: { path: "/Users/demo/.ssh/id_rsa" },
|
|
216
|
+
context: { source: "untrusted_dm" },
|
|
217
|
+
}),
|
|
218
|
+
).rejects.toBeInstanceOf(ActionDeniedError);
|
|
219
|
+
|
|
220
|
+
expect(exported).toHaveLength(1);
|
|
221
|
+
expect(exported[0]).toMatchObject({
|
|
222
|
+
outcome: "deny",
|
|
223
|
+
reason: "deny_sensitive_read_from_untrusted_context",
|
|
224
|
+
resource: "[REDACTED]",
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|