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
package/src/provider.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { AuthorizationRequest } from "@predicatesystems/authority";
|
|
3
|
+
import {
|
|
4
|
+
type AuthorityAdapter,
|
|
5
|
+
createDefaultAuthorityAdapter,
|
|
6
|
+
} from "./authority-client.js";
|
|
7
|
+
import { defaultProviderConfig, type ProviderConfig } from "./config.js";
|
|
8
|
+
import { ActionDeniedError, SidecarUnavailableError } from "./errors.js";
|
|
9
|
+
|
|
10
|
+
export interface GuardRequest {
|
|
11
|
+
action: string;
|
|
12
|
+
resource: string;
|
|
13
|
+
args: Record<string, unknown>;
|
|
14
|
+
context?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GuardedProviderOptions {
|
|
18
|
+
principal: string;
|
|
19
|
+
config?: Partial<ProviderConfig>;
|
|
20
|
+
authorityClient?: AuthorityAdapter;
|
|
21
|
+
telemetry?: GuardTelemetry;
|
|
22
|
+
auditExporter?: DecisionAuditExporter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DecisionTelemetryEvent {
|
|
26
|
+
principal: string;
|
|
27
|
+
action: string;
|
|
28
|
+
resource: string;
|
|
29
|
+
outcome: "allow" | "deny" | "error";
|
|
30
|
+
reason?: string;
|
|
31
|
+
mandateId?: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
tenantId?: string;
|
|
34
|
+
userId?: string;
|
|
35
|
+
traceId?: string;
|
|
36
|
+
source?: string;
|
|
37
|
+
timestamp: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GuardTelemetry {
|
|
41
|
+
onDecision?(event: DecisionTelemetryEvent): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DecisionAuditExporter {
|
|
45
|
+
exportDecision(event: DecisionTelemetryEvent): Promise<void> | void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class GuardedProvider {
|
|
49
|
+
private readonly principal: string;
|
|
50
|
+
private readonly config: ProviderConfig;
|
|
51
|
+
private readonly authorityClient: AuthorityAdapter;
|
|
52
|
+
private readonly telemetry?: GuardTelemetry;
|
|
53
|
+
private readonly auditExporter?: DecisionAuditExporter;
|
|
54
|
+
|
|
55
|
+
constructor(options: GuardedProviderOptions) {
|
|
56
|
+
this.principal = options.principal;
|
|
57
|
+
this.config = { ...defaultProviderConfig, ...(options.config ?? {}) };
|
|
58
|
+
this.authorityClient =
|
|
59
|
+
options.authorityClient ?? createDefaultAuthorityAdapter(this.config);
|
|
60
|
+
this.telemetry = options.telemetry;
|
|
61
|
+
this.auditExporter = options.auditExporter;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static intentHash(args: Record<string, unknown>): string {
|
|
65
|
+
const encoded = stableJson(args);
|
|
66
|
+
return crypto.createHash("sha256").update(encoded).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async authorize(request: GuardRequest): Promise<string | null> {
|
|
70
|
+
const wireRequest: AuthorizationRequest = {
|
|
71
|
+
principal: this.principal,
|
|
72
|
+
action: request.action,
|
|
73
|
+
resource: request.resource,
|
|
74
|
+
intent_hash: GuardedProvider.intentHash(request.args),
|
|
75
|
+
labels: labelsFromContext(request.context),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const decision = await this.authorityClient.authorize(wireRequest);
|
|
80
|
+
if (decision.allow) {
|
|
81
|
+
await this.emitDecisionEvent({
|
|
82
|
+
principal: this.principal,
|
|
83
|
+
action: request.action,
|
|
84
|
+
resource: redactResource(request.action, request.resource),
|
|
85
|
+
outcome: "allow",
|
|
86
|
+
reason: decision.reason,
|
|
87
|
+
mandateId: decision.mandateId,
|
|
88
|
+
...contextFields(request.context),
|
|
89
|
+
timestamp: new Date().toISOString(),
|
|
90
|
+
});
|
|
91
|
+
return decision.mandateId ?? null;
|
|
92
|
+
}
|
|
93
|
+
await this.emitDecisionEvent({
|
|
94
|
+
principal: this.principal,
|
|
95
|
+
action: request.action,
|
|
96
|
+
resource: redactResource(request.action, request.resource),
|
|
97
|
+
outcome: "deny",
|
|
98
|
+
reason: decision.reason ?? "denied_by_policy",
|
|
99
|
+
mandateId: decision.mandateId,
|
|
100
|
+
...contextFields(request.context),
|
|
101
|
+
timestamp: new Date().toISOString(),
|
|
102
|
+
});
|
|
103
|
+
throw new ActionDeniedError(decision.reason ?? "denied_by_policy");
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof ActionDeniedError) {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
if (error instanceof SidecarUnavailableError) {
|
|
109
|
+
await this.emitDecisionEvent({
|
|
110
|
+
principal: this.principal,
|
|
111
|
+
action: request.action,
|
|
112
|
+
resource: redactResource(request.action, request.resource),
|
|
113
|
+
outcome: "error",
|
|
114
|
+
reason: error.message,
|
|
115
|
+
...contextFields(request.context),
|
|
116
|
+
timestamp: new Date().toISOString(),
|
|
117
|
+
});
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
await this.emitDecisionEvent({
|
|
121
|
+
principal: this.principal,
|
|
122
|
+
action: request.action,
|
|
123
|
+
resource: redactResource(request.action, request.resource),
|
|
124
|
+
outcome: "error",
|
|
125
|
+
reason: "Predicate sidecar unavailable",
|
|
126
|
+
...contextFields(request.context),
|
|
127
|
+
timestamp: new Date().toISOString(),
|
|
128
|
+
});
|
|
129
|
+
throw new SidecarUnavailableError("Predicate sidecar unavailable");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async guardOrThrow(request: GuardRequest): Promise<string | null> {
|
|
134
|
+
try {
|
|
135
|
+
return await this.authorize(request);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (
|
|
138
|
+
error instanceof SidecarUnavailableError &&
|
|
139
|
+
this.config.failClosed === false
|
|
140
|
+
) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async emitDecisionEvent(event: DecisionTelemetryEvent): Promise<void> {
|
|
148
|
+
this.telemetry?.onDecision?.(event);
|
|
149
|
+
if (!this.auditExporter) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
await this.auditExporter.exportDecision(event);
|
|
154
|
+
} catch {
|
|
155
|
+
// Best-effort export; authorization path must not fail on telemetry sink issues.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function labelsFromContext(
|
|
161
|
+
context: Record<string, unknown> | undefined,
|
|
162
|
+
): string[] {
|
|
163
|
+
if (!context) return [];
|
|
164
|
+
const labels: string[] = [];
|
|
165
|
+
const source = context.source;
|
|
166
|
+
if (typeof source === "string" && source.length > 0) {
|
|
167
|
+
labels.push(`source:${source}`);
|
|
168
|
+
}
|
|
169
|
+
return labels;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function contextFields(
|
|
173
|
+
context: Record<string, unknown> | undefined,
|
|
174
|
+
): Pick<
|
|
175
|
+
DecisionTelemetryEvent,
|
|
176
|
+
"sessionId" | "tenantId" | "userId" | "traceId" | "source"
|
|
177
|
+
> {
|
|
178
|
+
return {
|
|
179
|
+
sessionId:
|
|
180
|
+
typeof context?.session_id === "string" ? context.session_id : undefined,
|
|
181
|
+
tenantId:
|
|
182
|
+
typeof context?.tenant_id === "string" ? context.tenant_id : undefined,
|
|
183
|
+
userId: typeof context?.user_id === "string" ? context.user_id : undefined,
|
|
184
|
+
traceId:
|
|
185
|
+
typeof context?.trace_id === "string" ? context.trace_id : undefined,
|
|
186
|
+
source: typeof context?.source === "string" ? context.source : undefined,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function redactResource(action: string, resource: string): string {
|
|
191
|
+
if (action === "fs.read" || action === "fs.write") {
|
|
192
|
+
const lowered = resource.toLowerCase();
|
|
193
|
+
if (
|
|
194
|
+
lowered.includes("/.ssh/") ||
|
|
195
|
+
lowered.includes("/etc/") ||
|
|
196
|
+
lowered.includes("id_rsa") ||
|
|
197
|
+
lowered.includes("credentials")
|
|
198
|
+
) {
|
|
199
|
+
return "[REDACTED]";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return resource;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stableJson(value: unknown): string {
|
|
206
|
+
if (Array.isArray(value)) {
|
|
207
|
+
return `[${value.map((v) => stableJson(v)).join(",")}]`;
|
|
208
|
+
}
|
|
209
|
+
if (value && typeof value === "object") {
|
|
210
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(
|
|
211
|
+
([a], [b]) => a.localeCompare(b),
|
|
212
|
+
);
|
|
213
|
+
return `{${entries
|
|
214
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`)
|
|
215
|
+
.join(",")}}`;
|
|
216
|
+
}
|
|
217
|
+
return JSON.stringify(value);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export { ActionDeniedError, SidecarUnavailableError } from "./errors.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { HookEnvelope, type OpenClawHooks } from "./openclaw-hooks.js";
|
|
2
|
+
|
|
3
|
+
type ToolHandler = (args: Record<string, unknown>) => Promise<unknown>;
|
|
4
|
+
|
|
5
|
+
export interface ToolRegistry {
|
|
6
|
+
get(toolName: string): ToolHandler;
|
|
7
|
+
set(toolName: string, handler: ToolHandler): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RuntimeIntegratorOptions {
|
|
11
|
+
hooks: Pick<OpenClawHooks, "onCmdRun" | "onFsRead" | "onHttpRequest">;
|
|
12
|
+
contextBuilder?: (toolName: string, args: Record<string, unknown>) => HookEnvelope;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class OpenClawRuntimeIntegrator {
|
|
16
|
+
private readonly hooks: RuntimeIntegratorOptions["hooks"];
|
|
17
|
+
private readonly contextBuilder: (
|
|
18
|
+
toolName: string,
|
|
19
|
+
args: Record<string, unknown>,
|
|
20
|
+
) => HookEnvelope;
|
|
21
|
+
|
|
22
|
+
constructor(options: RuntimeIntegratorOptions) {
|
|
23
|
+
this.hooks = options.hooks;
|
|
24
|
+
this.contextBuilder = options.contextBuilder ?? defaultContextBuilder;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
register(registry: ToolRegistry): void {
|
|
28
|
+
this.wrapCmdRun(registry);
|
|
29
|
+
this.wrapFsRead(registry);
|
|
30
|
+
this.wrapHttpRequest(registry);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private wrapCmdRun(registry: ToolRegistry): void {
|
|
34
|
+
const original = registry.get("cmd.run");
|
|
35
|
+
registry.set("cmd.run", async (args: Record<string, unknown>) => {
|
|
36
|
+
const envelope = this.contextBuilder("cmd.run", args);
|
|
37
|
+
return this.hooks.onCmdRun(envelope, original);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private wrapFsRead(registry: ToolRegistry): void {
|
|
42
|
+
const original = registry.get("fs.readFile");
|
|
43
|
+
registry.set("fs.readFile", async (args: Record<string, unknown>) => {
|
|
44
|
+
const envelope = this.contextBuilder("fs.readFile", args);
|
|
45
|
+
return this.hooks.onFsRead(envelope, original);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private wrapHttpRequest(registry: ToolRegistry): void {
|
|
50
|
+
const original = registry.get("http.request");
|
|
51
|
+
registry.set("http.request", async (args: Record<string, unknown>) => {
|
|
52
|
+
const envelope = this.contextBuilder("http.request", args);
|
|
53
|
+
return this.hooks.onHttpRequest(envelope, original);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultContextBuilder(
|
|
59
|
+
toolName: string,
|
|
60
|
+
args: Record<string, unknown>,
|
|
61
|
+
): HookEnvelope {
|
|
62
|
+
return new HookEnvelope({
|
|
63
|
+
toolName,
|
|
64
|
+
args,
|
|
65
|
+
sessionId: "unknown-session",
|
|
66
|
+
source: "unknown-source",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
buildWebStateEvidence,
|
|
4
|
+
buildWebStateEvidenceFromRuntimeSnapshot,
|
|
5
|
+
type RuntimeSnapshotLike,
|
|
6
|
+
type StateEvidence,
|
|
7
|
+
type WebStateSnapshot,
|
|
8
|
+
} from "@predicatesystems/authority";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Runtime context captured from OpenClaw web agent execution environment.
|
|
12
|
+
* Maps to ts-predicate-authority WebStateSnapshot contract.
|
|
13
|
+
*/
|
|
14
|
+
export interface WebRuntimeContext {
|
|
15
|
+
url?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
domHtml?: string;
|
|
18
|
+
domHash?: string;
|
|
19
|
+
visibleText?: string;
|
|
20
|
+
visibleTextHash?: string;
|
|
21
|
+
eventId?: string;
|
|
22
|
+
observedAt?: string;
|
|
23
|
+
dominantGroupKey?: string;
|
|
24
|
+
snapshotTimestamp?: string;
|
|
25
|
+
confidence?: number;
|
|
26
|
+
confidenceReasons?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Provider interface for web state evidence capture.
|
|
31
|
+
* Implementations should capture browser/DOM state from the agent runtime.
|
|
32
|
+
*/
|
|
33
|
+
export interface WebEvidenceProvider {
|
|
34
|
+
captureWebSnapshot(): Promise<WebStateSnapshot>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* OpenClaw-specific web evidence provider.
|
|
39
|
+
* Captures web state from the agent runtime and maps to TS SDK contract.
|
|
40
|
+
*/
|
|
41
|
+
export class OpenClawWebEvidenceProvider implements WebEvidenceProvider {
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly capture: () =>
|
|
44
|
+
| Promise<WebRuntimeContext>
|
|
45
|
+
| WebRuntimeContext,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
async captureWebSnapshot(): Promise<WebStateSnapshot> {
|
|
49
|
+
const runtime = await this.capture();
|
|
50
|
+
return {
|
|
51
|
+
url: runtime.url,
|
|
52
|
+
title: runtime.title,
|
|
53
|
+
dom_hash: runtime.domHash ?? sha256(runtime.domHtml ?? ""),
|
|
54
|
+
visible_text_hash:
|
|
55
|
+
runtime.visibleTextHash ?? sha256(runtime.visibleText ?? ""),
|
|
56
|
+
event_id: runtime.eventId,
|
|
57
|
+
observed_at: runtime.observedAt ?? new Date().toISOString(),
|
|
58
|
+
dominant_group_key: runtime.dominantGroupKey,
|
|
59
|
+
snapshot_timestamp: runtime.snapshotTimestamp,
|
|
60
|
+
confidence: runtime.confidence,
|
|
61
|
+
confidence_reasons: runtime.confidenceReasons,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build StateEvidence from an OpenClaw web evidence provider.
|
|
68
|
+
*/
|
|
69
|
+
export async function buildWebEvidenceFromProvider(
|
|
70
|
+
provider: WebEvidenceProvider,
|
|
71
|
+
options?: { schemaVersion?: string },
|
|
72
|
+
): Promise<StateEvidence> {
|
|
73
|
+
const snapshot = await provider.captureWebSnapshot();
|
|
74
|
+
return buildWebStateEvidence({
|
|
75
|
+
snapshot,
|
|
76
|
+
schemaVersion: options?.schemaVersion ?? "v1",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Convenience adapter for predicate-runtime snapshot output.
|
|
82
|
+
* Maps RuntimeSnapshotLike to StateEvidence directly.
|
|
83
|
+
*/
|
|
84
|
+
export function buildWebEvidenceFromRuntimeSnapshot(
|
|
85
|
+
snapshot: RuntimeSnapshotLike,
|
|
86
|
+
options?: { schemaVersion?: string },
|
|
87
|
+
): StateEvidence {
|
|
88
|
+
return buildWebStateEvidenceFromRuntimeSnapshot(snapshot, {
|
|
89
|
+
schemaVersion: options?.schemaVersion ?? "v1",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sha256(input: string): string {
|
|
94
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
95
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ToolAdapter } from "../src/adapter.js";
|
|
3
|
+
import { ActionDeniedError } from "../src/errors.js";
|
|
4
|
+
|
|
5
|
+
describe("ToolAdapter", () => {
|
|
6
|
+
it("maps cmd.run to shell.execute", async () => {
|
|
7
|
+
const seen: Array<{ action: string; resource: string }> = [];
|
|
8
|
+
const adapter = new ToolAdapter({
|
|
9
|
+
guardOrThrow: async ({ action, resource }) => {
|
|
10
|
+
seen.push({ action, resource });
|
|
11
|
+
return "mnd_test";
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const result = await adapter.runShell({
|
|
16
|
+
args: { command: "echo hi" },
|
|
17
|
+
context: { source: "trusted_ui" },
|
|
18
|
+
execute: async (args) => args,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(result).toEqual({ command: "echo hi" });
|
|
22
|
+
expect(seen).toEqual([{ action: "shell.execute", resource: "echo hi" }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("bubbles deny errors", async () => {
|
|
26
|
+
const adapter = new ToolAdapter({
|
|
27
|
+
guardOrThrow: async () => {
|
|
28
|
+
throw new ActionDeniedError("denied_by_policy");
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await expect(
|
|
33
|
+
adapter.readFile({
|
|
34
|
+
args: { path: "/etc/passwd" },
|
|
35
|
+
context: { source: "untrusted_dm" },
|
|
36
|
+
execute: async (args) => args,
|
|
37
|
+
}),
|
|
38
|
+
).rejects.toBeInstanceOf(ActionDeniedError);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("maps fs.readFile to fs.read", async () => {
|
|
42
|
+
const seen: Array<{ action: string; resource: string }> = [];
|
|
43
|
+
const adapter = new ToolAdapter({
|
|
44
|
+
guardOrThrow: async ({ action, resource }) => {
|
|
45
|
+
seen.push({ action, resource });
|
|
46
|
+
return "mnd_test";
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await adapter.readFile({
|
|
51
|
+
args: { path: "/tmp/demo.txt" },
|
|
52
|
+
context: { source: "trusted_ui" },
|
|
53
|
+
execute: async (args) => args,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(seen).toEqual([{ action: "fs.read", resource: "/tmp/demo.txt" }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("maps http.request to net.http", async () => {
|
|
60
|
+
const seen: Array<{ action: string; resource: string }> = [];
|
|
61
|
+
const adapter = new ToolAdapter({
|
|
62
|
+
guardOrThrow: async ({ action, resource }) => {
|
|
63
|
+
seen.push({ action, resource });
|
|
64
|
+
return "mnd_test";
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await adapter.httpRequest({
|
|
69
|
+
args: { url: "https://example.com" },
|
|
70
|
+
context: { source: "trusted_ui" },
|
|
71
|
+
execute: async (args) => args,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(seen).toEqual([{ action: "net.http", resource: "https://example.com" }]);
|
|
75
|
+
});
|
|
76
|
+
});
|