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,18 @@
|
|
|
1
|
+
interface RegisteredTool {
|
|
2
|
+
name: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
parameters?: unknown;
|
|
5
|
+
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
6
|
+
}
|
|
7
|
+
interface OpenClawPluginApi {
|
|
8
|
+
registerTool(tool: RegisteredTool, options?: {
|
|
9
|
+
optional?: boolean;
|
|
10
|
+
}): void;
|
|
11
|
+
}
|
|
12
|
+
interface PredicateToolHandlers {
|
|
13
|
+
executeCmdRun(args: Record<string, unknown>): Promise<unknown>;
|
|
14
|
+
executeFsReadFile(args: Record<string, unknown>): Promise<unknown>;
|
|
15
|
+
executeHttpRequest(args: Record<string, unknown>): Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
export declare function registerOpenClawPredicateTools(api: OpenClawPluginApi, handlers: PredicateToolHandlers): void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function registerOpenClawPredicateTools(api, handlers) {
|
|
2
|
+
api.registerTool({
|
|
3
|
+
name: "predicate_cmd_run",
|
|
4
|
+
description: "Guarded shell execution routed through Predicate Authority.",
|
|
5
|
+
execute: async (_id, params) => handlers.executeCmdRun(params),
|
|
6
|
+
}, { optional: true });
|
|
7
|
+
api.registerTool({
|
|
8
|
+
name: "predicate_fs_read_file",
|
|
9
|
+
description: "Guarded file read routed through Predicate Authority.",
|
|
10
|
+
execute: async (_id, params) => handlers.executeFsReadFile(params),
|
|
11
|
+
}, { optional: true });
|
|
12
|
+
api.registerTool({
|
|
13
|
+
name: "predicate_http_request",
|
|
14
|
+
description: "Guarded HTTP request routed through Predicate Authority.",
|
|
15
|
+
execute: async (_id, params) => handlers.executeHttpRequest(params),
|
|
16
|
+
}, { optional: true });
|
|
17
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type AuthorityAdapter } from "./authority-client.js";
|
|
2
|
+
import { type ProviderConfig } from "./config.js";
|
|
3
|
+
export interface GuardRequest {
|
|
4
|
+
action: string;
|
|
5
|
+
resource: string;
|
|
6
|
+
args: Record<string, unknown>;
|
|
7
|
+
context?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface GuardedProviderOptions {
|
|
10
|
+
principal: string;
|
|
11
|
+
config?: Partial<ProviderConfig>;
|
|
12
|
+
authorityClient?: AuthorityAdapter;
|
|
13
|
+
telemetry?: GuardTelemetry;
|
|
14
|
+
auditExporter?: DecisionAuditExporter;
|
|
15
|
+
}
|
|
16
|
+
export interface DecisionTelemetryEvent {
|
|
17
|
+
principal: string;
|
|
18
|
+
action: string;
|
|
19
|
+
resource: string;
|
|
20
|
+
outcome: "allow" | "deny" | "error";
|
|
21
|
+
reason?: string;
|
|
22
|
+
mandateId?: string;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
tenantId?: string;
|
|
25
|
+
userId?: string;
|
|
26
|
+
traceId?: string;
|
|
27
|
+
source?: string;
|
|
28
|
+
timestamp: string;
|
|
29
|
+
}
|
|
30
|
+
export interface GuardTelemetry {
|
|
31
|
+
onDecision?(event: DecisionTelemetryEvent): void;
|
|
32
|
+
}
|
|
33
|
+
export interface DecisionAuditExporter {
|
|
34
|
+
exportDecision(event: DecisionTelemetryEvent): Promise<void> | void;
|
|
35
|
+
}
|
|
36
|
+
export declare class GuardedProvider {
|
|
37
|
+
private readonly principal;
|
|
38
|
+
private readonly config;
|
|
39
|
+
private readonly authorityClient;
|
|
40
|
+
private readonly telemetry?;
|
|
41
|
+
private readonly auditExporter?;
|
|
42
|
+
constructor(options: GuardedProviderOptions);
|
|
43
|
+
static intentHash(args: Record<string, unknown>): string;
|
|
44
|
+
authorize(request: GuardRequest): Promise<string | null>;
|
|
45
|
+
guardOrThrow(request: GuardRequest): Promise<string | null>;
|
|
46
|
+
private emitDecisionEvent;
|
|
47
|
+
}
|
|
48
|
+
export { ActionDeniedError, SidecarUnavailableError } from "./errors.js";
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { createDefaultAuthorityAdapter, } from "./authority-client.js";
|
|
3
|
+
import { defaultProviderConfig } from "./config.js";
|
|
4
|
+
import { ActionDeniedError, SidecarUnavailableError } from "./errors.js";
|
|
5
|
+
export class GuardedProvider {
|
|
6
|
+
principal;
|
|
7
|
+
config;
|
|
8
|
+
authorityClient;
|
|
9
|
+
telemetry;
|
|
10
|
+
auditExporter;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.principal = options.principal;
|
|
13
|
+
this.config = { ...defaultProviderConfig, ...(options.config ?? {}) };
|
|
14
|
+
this.authorityClient =
|
|
15
|
+
options.authorityClient ?? createDefaultAuthorityAdapter(this.config);
|
|
16
|
+
this.telemetry = options.telemetry;
|
|
17
|
+
this.auditExporter = options.auditExporter;
|
|
18
|
+
}
|
|
19
|
+
static intentHash(args) {
|
|
20
|
+
const encoded = stableJson(args);
|
|
21
|
+
return crypto.createHash("sha256").update(encoded).digest("hex");
|
|
22
|
+
}
|
|
23
|
+
async authorize(request) {
|
|
24
|
+
const wireRequest = {
|
|
25
|
+
principal: this.principal,
|
|
26
|
+
action: request.action,
|
|
27
|
+
resource: request.resource,
|
|
28
|
+
intent_hash: GuardedProvider.intentHash(request.args),
|
|
29
|
+
labels: labelsFromContext(request.context),
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
const decision = await this.authorityClient.authorize(wireRequest);
|
|
33
|
+
if (decision.allow) {
|
|
34
|
+
await this.emitDecisionEvent({
|
|
35
|
+
principal: this.principal,
|
|
36
|
+
action: request.action,
|
|
37
|
+
resource: redactResource(request.action, request.resource),
|
|
38
|
+
outcome: "allow",
|
|
39
|
+
reason: decision.reason,
|
|
40
|
+
mandateId: decision.mandateId,
|
|
41
|
+
...contextFields(request.context),
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
});
|
|
44
|
+
return decision.mandateId ?? null;
|
|
45
|
+
}
|
|
46
|
+
await this.emitDecisionEvent({
|
|
47
|
+
principal: this.principal,
|
|
48
|
+
action: request.action,
|
|
49
|
+
resource: redactResource(request.action, request.resource),
|
|
50
|
+
outcome: "deny",
|
|
51
|
+
reason: decision.reason ?? "denied_by_policy",
|
|
52
|
+
mandateId: decision.mandateId,
|
|
53
|
+
...contextFields(request.context),
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
});
|
|
56
|
+
throw new ActionDeniedError(decision.reason ?? "denied_by_policy");
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof ActionDeniedError) {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
if (error instanceof SidecarUnavailableError) {
|
|
63
|
+
await this.emitDecisionEvent({
|
|
64
|
+
principal: this.principal,
|
|
65
|
+
action: request.action,
|
|
66
|
+
resource: redactResource(request.action, request.resource),
|
|
67
|
+
outcome: "error",
|
|
68
|
+
reason: error.message,
|
|
69
|
+
...contextFields(request.context),
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
});
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
await this.emitDecisionEvent({
|
|
75
|
+
principal: this.principal,
|
|
76
|
+
action: request.action,
|
|
77
|
+
resource: redactResource(request.action, request.resource),
|
|
78
|
+
outcome: "error",
|
|
79
|
+
reason: "Predicate sidecar unavailable",
|
|
80
|
+
...contextFields(request.context),
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
});
|
|
83
|
+
throw new SidecarUnavailableError("Predicate sidecar unavailable");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async guardOrThrow(request) {
|
|
87
|
+
try {
|
|
88
|
+
return await this.authorize(request);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof SidecarUnavailableError &&
|
|
92
|
+
this.config.failClosed === false) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async emitDecisionEvent(event) {
|
|
99
|
+
this.telemetry?.onDecision?.(event);
|
|
100
|
+
if (!this.auditExporter) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await this.auditExporter.exportDecision(event);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Best-effort export; authorization path must not fail on telemetry sink issues.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function labelsFromContext(context) {
|
|
112
|
+
if (!context)
|
|
113
|
+
return [];
|
|
114
|
+
const labels = [];
|
|
115
|
+
const source = context.source;
|
|
116
|
+
if (typeof source === "string" && source.length > 0) {
|
|
117
|
+
labels.push(`source:${source}`);
|
|
118
|
+
}
|
|
119
|
+
return labels;
|
|
120
|
+
}
|
|
121
|
+
function contextFields(context) {
|
|
122
|
+
return {
|
|
123
|
+
sessionId: typeof context?.session_id === "string" ? context.session_id : undefined,
|
|
124
|
+
tenantId: typeof context?.tenant_id === "string" ? context.tenant_id : undefined,
|
|
125
|
+
userId: typeof context?.user_id === "string" ? context.user_id : undefined,
|
|
126
|
+
traceId: typeof context?.trace_id === "string" ? context.trace_id : undefined,
|
|
127
|
+
source: typeof context?.source === "string" ? context.source : undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function redactResource(action, resource) {
|
|
131
|
+
if (action === "fs.read" || action === "fs.write") {
|
|
132
|
+
const lowered = resource.toLowerCase();
|
|
133
|
+
if (lowered.includes("/.ssh/") ||
|
|
134
|
+
lowered.includes("/etc/") ||
|
|
135
|
+
lowered.includes("id_rsa") ||
|
|
136
|
+
lowered.includes("credentials")) {
|
|
137
|
+
return "[REDACTED]";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return resource;
|
|
141
|
+
}
|
|
142
|
+
function stableJson(value) {
|
|
143
|
+
if (Array.isArray(value)) {
|
|
144
|
+
return `[${value.map((v) => stableJson(v)).join(",")}]`;
|
|
145
|
+
}
|
|
146
|
+
if (value && typeof value === "object") {
|
|
147
|
+
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
148
|
+
return `{${entries
|
|
149
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`)
|
|
150
|
+
.join(",")}}`;
|
|
151
|
+
}
|
|
152
|
+
return JSON.stringify(value);
|
|
153
|
+
}
|
|
154
|
+
export { ActionDeniedError, SidecarUnavailableError } from "./errors.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { HookEnvelope, type OpenClawHooks } from "./openclaw-hooks.js";
|
|
2
|
+
type ToolHandler = (args: Record<string, unknown>) => Promise<unknown>;
|
|
3
|
+
export interface ToolRegistry {
|
|
4
|
+
get(toolName: string): ToolHandler;
|
|
5
|
+
set(toolName: string, handler: ToolHandler): void;
|
|
6
|
+
}
|
|
7
|
+
export interface RuntimeIntegratorOptions {
|
|
8
|
+
hooks: Pick<OpenClawHooks, "onCmdRun" | "onFsRead" | "onHttpRequest">;
|
|
9
|
+
contextBuilder?: (toolName: string, args: Record<string, unknown>) => HookEnvelope;
|
|
10
|
+
}
|
|
11
|
+
export declare class OpenClawRuntimeIntegrator {
|
|
12
|
+
private readonly hooks;
|
|
13
|
+
private readonly contextBuilder;
|
|
14
|
+
constructor(options: RuntimeIntegratorOptions);
|
|
15
|
+
register(registry: ToolRegistry): void;
|
|
16
|
+
private wrapCmdRun;
|
|
17
|
+
private wrapFsRead;
|
|
18
|
+
private wrapHttpRequest;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HookEnvelope } from "./openclaw-hooks.js";
|
|
2
|
+
export class OpenClawRuntimeIntegrator {
|
|
3
|
+
hooks;
|
|
4
|
+
contextBuilder;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.hooks = options.hooks;
|
|
7
|
+
this.contextBuilder = options.contextBuilder ?? defaultContextBuilder;
|
|
8
|
+
}
|
|
9
|
+
register(registry) {
|
|
10
|
+
this.wrapCmdRun(registry);
|
|
11
|
+
this.wrapFsRead(registry);
|
|
12
|
+
this.wrapHttpRequest(registry);
|
|
13
|
+
}
|
|
14
|
+
wrapCmdRun(registry) {
|
|
15
|
+
const original = registry.get("cmd.run");
|
|
16
|
+
registry.set("cmd.run", async (args) => {
|
|
17
|
+
const envelope = this.contextBuilder("cmd.run", args);
|
|
18
|
+
return this.hooks.onCmdRun(envelope, original);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
wrapFsRead(registry) {
|
|
22
|
+
const original = registry.get("fs.readFile");
|
|
23
|
+
registry.set("fs.readFile", async (args) => {
|
|
24
|
+
const envelope = this.contextBuilder("fs.readFile", args);
|
|
25
|
+
return this.hooks.onFsRead(envelope, original);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
wrapHttpRequest(registry) {
|
|
29
|
+
const original = registry.get("http.request");
|
|
30
|
+
registry.set("http.request", async (args) => {
|
|
31
|
+
const envelope = this.contextBuilder("http.request", args);
|
|
32
|
+
return this.hooks.onHttpRequest(envelope, original);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function defaultContextBuilder(toolName, args) {
|
|
37
|
+
return new HookEnvelope({
|
|
38
|
+
toolName,
|
|
39
|
+
args,
|
|
40
|
+
sessionId: "unknown-session",
|
|
41
|
+
source: "unknown-source",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type RuntimeSnapshotLike, type StateEvidence, type WebStateSnapshot } from "@predicatesystems/authority";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime context captured from OpenClaw web agent execution environment.
|
|
4
|
+
* Maps to ts-predicate-authority WebStateSnapshot contract.
|
|
5
|
+
*/
|
|
6
|
+
export interface WebRuntimeContext {
|
|
7
|
+
url?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
domHtml?: string;
|
|
10
|
+
domHash?: string;
|
|
11
|
+
visibleText?: string;
|
|
12
|
+
visibleTextHash?: string;
|
|
13
|
+
eventId?: string;
|
|
14
|
+
observedAt?: string;
|
|
15
|
+
dominantGroupKey?: string;
|
|
16
|
+
snapshotTimestamp?: string;
|
|
17
|
+
confidence?: number;
|
|
18
|
+
confidenceReasons?: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Provider interface for web state evidence capture.
|
|
22
|
+
* Implementations should capture browser/DOM state from the agent runtime.
|
|
23
|
+
*/
|
|
24
|
+
export interface WebEvidenceProvider {
|
|
25
|
+
captureWebSnapshot(): Promise<WebStateSnapshot>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* OpenClaw-specific web evidence provider.
|
|
29
|
+
* Captures web state from the agent runtime and maps to TS SDK contract.
|
|
30
|
+
*/
|
|
31
|
+
export declare class OpenClawWebEvidenceProvider implements WebEvidenceProvider {
|
|
32
|
+
private readonly capture;
|
|
33
|
+
constructor(capture: () => Promise<WebRuntimeContext> | WebRuntimeContext);
|
|
34
|
+
captureWebSnapshot(): Promise<WebStateSnapshot>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build StateEvidence from an OpenClaw web evidence provider.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildWebEvidenceFromProvider(provider: WebEvidenceProvider, options?: {
|
|
40
|
+
schemaVersion?: string;
|
|
41
|
+
}): Promise<StateEvidence>;
|
|
42
|
+
/**
|
|
43
|
+
* Convenience adapter for predicate-runtime snapshot output.
|
|
44
|
+
* Maps RuntimeSnapshotLike to StateEvidence directly.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildWebEvidenceFromRuntimeSnapshot(snapshot: RuntimeSnapshotLike, options?: {
|
|
47
|
+
schemaVersion?: string;
|
|
48
|
+
}): StateEvidence;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { buildWebStateEvidence, buildWebStateEvidenceFromRuntimeSnapshot, } from "@predicatesystems/authority";
|
|
3
|
+
/**
|
|
4
|
+
* OpenClaw-specific web evidence provider.
|
|
5
|
+
* Captures web state from the agent runtime and maps to TS SDK contract.
|
|
6
|
+
*/
|
|
7
|
+
export class OpenClawWebEvidenceProvider {
|
|
8
|
+
capture;
|
|
9
|
+
constructor(capture) {
|
|
10
|
+
this.capture = capture;
|
|
11
|
+
}
|
|
12
|
+
async captureWebSnapshot() {
|
|
13
|
+
const runtime = await this.capture();
|
|
14
|
+
return {
|
|
15
|
+
url: runtime.url,
|
|
16
|
+
title: runtime.title,
|
|
17
|
+
dom_hash: runtime.domHash ?? sha256(runtime.domHtml ?? ""),
|
|
18
|
+
visible_text_hash: runtime.visibleTextHash ?? sha256(runtime.visibleText ?? ""),
|
|
19
|
+
event_id: runtime.eventId,
|
|
20
|
+
observed_at: runtime.observedAt ?? new Date().toISOString(),
|
|
21
|
+
dominant_group_key: runtime.dominantGroupKey,
|
|
22
|
+
snapshot_timestamp: runtime.snapshotTimestamp,
|
|
23
|
+
confidence: runtime.confidence,
|
|
24
|
+
confidence_reasons: runtime.confidenceReasons,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build StateEvidence from an OpenClaw web evidence provider.
|
|
30
|
+
*/
|
|
31
|
+
export async function buildWebEvidenceFromProvider(provider, options) {
|
|
32
|
+
const snapshot = await provider.captureWebSnapshot();
|
|
33
|
+
return buildWebStateEvidence({
|
|
34
|
+
snapshot,
|
|
35
|
+
schemaVersion: options?.schemaVersion ?? "v1",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Convenience adapter for predicate-runtime snapshot output.
|
|
40
|
+
* Maps RuntimeSnapshotLike to StateEvidence directly.
|
|
41
|
+
*/
|
|
42
|
+
export function buildWebEvidenceFromRuntimeSnapshot(snapshot, options) {
|
|
43
|
+
return buildWebStateEvidenceFromRuntimeSnapshot(snapshot, {
|
|
44
|
+
schemaVersion: options?.schemaVersion ?? "v1",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function sha256(input) {
|
|
48
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ToolAdapter } from "../src/adapter.js";
|
|
3
|
+
import { ActionDeniedError } from "../src/errors.js";
|
|
4
|
+
describe("ToolAdapter", () => {
|
|
5
|
+
it("maps cmd.run to shell.execute", async () => {
|
|
6
|
+
const seen = [];
|
|
7
|
+
const adapter = new ToolAdapter({
|
|
8
|
+
guardOrThrow: async ({ action, resource }) => {
|
|
9
|
+
seen.push({ action, resource });
|
|
10
|
+
return "mnd_test";
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
const result = await adapter.runShell({
|
|
14
|
+
args: { command: "echo hi" },
|
|
15
|
+
context: { source: "trusted_ui" },
|
|
16
|
+
execute: async (args) => args,
|
|
17
|
+
});
|
|
18
|
+
expect(result).toEqual({ command: "echo hi" });
|
|
19
|
+
expect(seen).toEqual([{ action: "shell.execute", resource: "echo hi" }]);
|
|
20
|
+
});
|
|
21
|
+
it("bubbles deny errors", async () => {
|
|
22
|
+
const adapter = new ToolAdapter({
|
|
23
|
+
guardOrThrow: async () => {
|
|
24
|
+
throw new ActionDeniedError("denied_by_policy");
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
await expect(adapter.readFile({
|
|
28
|
+
args: { path: "/etc/passwd" },
|
|
29
|
+
context: { source: "untrusted_dm" },
|
|
30
|
+
execute: async (args) => args,
|
|
31
|
+
})).rejects.toBeInstanceOf(ActionDeniedError);
|
|
32
|
+
});
|
|
33
|
+
it("maps fs.readFile to fs.read", async () => {
|
|
34
|
+
const seen = [];
|
|
35
|
+
const adapter = new ToolAdapter({
|
|
36
|
+
guardOrThrow: async ({ action, resource }) => {
|
|
37
|
+
seen.push({ action, resource });
|
|
38
|
+
return "mnd_test";
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
await adapter.readFile({
|
|
42
|
+
args: { path: "/tmp/demo.txt" },
|
|
43
|
+
context: { source: "trusted_ui" },
|
|
44
|
+
execute: async (args) => args,
|
|
45
|
+
});
|
|
46
|
+
expect(seen).toEqual([{ action: "fs.read", resource: "/tmp/demo.txt" }]);
|
|
47
|
+
});
|
|
48
|
+
it("maps http.request to net.http", async () => {
|
|
49
|
+
const seen = [];
|
|
50
|
+
const adapter = new ToolAdapter({
|
|
51
|
+
guardOrThrow: async ({ action, resource }) => {
|
|
52
|
+
seen.push({ action, resource });
|
|
53
|
+
return "mnd_test";
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
await adapter.httpRequest({
|
|
57
|
+
args: { url: "https://example.com" },
|
|
58
|
+
context: { source: "trusted_ui" },
|
|
59
|
+
execute: async (args) => args,
|
|
60
|
+
});
|
|
61
|
+
expect(seen).toEqual([{ action: "net.http", resource: "https://example.com" }]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { GuardedProvider, ActionDeniedError, } from "../src/provider.js";
|
|
3
|
+
/**
|
|
4
|
+
* End-to-end audit event visibility tests.
|
|
5
|
+
*
|
|
6
|
+
* These tests verify that decision events flow correctly from the provider
|
|
7
|
+
* through to audit exporters in a format compatible with control-plane
|
|
8
|
+
* audit pipelines.
|
|
9
|
+
*/
|
|
10
|
+
describe("audit event visibility (e2e)", () => {
|
|
11
|
+
it("exports allow decisions with full context to audit sink", async () => {
|
|
12
|
+
const exportedEvents = [];
|
|
13
|
+
const mockClient = {
|
|
14
|
+
authorize: vi.fn().mockResolvedValue({
|
|
15
|
+
allow: true,
|
|
16
|
+
reason: "policy_matched",
|
|
17
|
+
mandateId: "mandate-12345",
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
const auditExporter = {
|
|
21
|
+
exportDecision: async (event) => {
|
|
22
|
+
exportedEvents.push(event);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const provider = new GuardedProvider({
|
|
26
|
+
principal: "agent:e2e-test-agent",
|
|
27
|
+
authorityClient: mockClient,
|
|
28
|
+
auditExporter,
|
|
29
|
+
});
|
|
30
|
+
await provider.authorize({
|
|
31
|
+
action: "shell.execute",
|
|
32
|
+
resource: "npm install lodash",
|
|
33
|
+
args: { cmd: "npm install lodash" },
|
|
34
|
+
context: {
|
|
35
|
+
tenant_id: "tenant-prod",
|
|
36
|
+
session_id: "sess-e2e-001",
|
|
37
|
+
user_id: "user-developer",
|
|
38
|
+
trace_id: "trace-e2e-xyz",
|
|
39
|
+
source: "trusted_ui",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
// Verify event was exported
|
|
43
|
+
expect(exportedEvents).toHaveLength(1);
|
|
44
|
+
const event = exportedEvents[0];
|
|
45
|
+
// Verify control-plane compatible fields
|
|
46
|
+
expect(event.principal).toBe("agent:e2e-test-agent");
|
|
47
|
+
expect(event.action).toBe("shell.execute");
|
|
48
|
+
expect(event.resource).toBe("npm install lodash");
|
|
49
|
+
expect(event.outcome).toBe("allow");
|
|
50
|
+
expect(event.reason).toBe("policy_matched");
|
|
51
|
+
expect(event.mandateId).toBe("mandate-12345");
|
|
52
|
+
// Verify tenant/session context
|
|
53
|
+
expect(event.tenantId).toBe("tenant-prod");
|
|
54
|
+
expect(event.sessionId).toBe("sess-e2e-001");
|
|
55
|
+
expect(event.userId).toBe("user-developer");
|
|
56
|
+
expect(event.traceId).toBe("trace-e2e-xyz");
|
|
57
|
+
expect(event.source).toBe("trusted_ui");
|
|
58
|
+
// Verify timestamp is ISO format
|
|
59
|
+
expect(event.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
60
|
+
});
|
|
61
|
+
it("exports deny decisions with redacted sensitive resources", async () => {
|
|
62
|
+
const exportedEvents = [];
|
|
63
|
+
const mockClient = {
|
|
64
|
+
authorize: vi.fn().mockResolvedValue({
|
|
65
|
+
allow: false,
|
|
66
|
+
reason: "sensitive_path_blocked",
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
const auditExporter = {
|
|
70
|
+
exportDecision: async (event) => {
|
|
71
|
+
exportedEvents.push(event);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const provider = new GuardedProvider({
|
|
75
|
+
principal: "agent:sensitive-test",
|
|
76
|
+
authorityClient: mockClient,
|
|
77
|
+
auditExporter,
|
|
78
|
+
});
|
|
79
|
+
await expect(provider.authorize({
|
|
80
|
+
action: "fs.read",
|
|
81
|
+
resource: "/home/user/.ssh/id_rsa",
|
|
82
|
+
args: { path: "/home/user/.ssh/id_rsa" },
|
|
83
|
+
context: { tenant_id: "tenant-sec" },
|
|
84
|
+
})).rejects.toThrow(ActionDeniedError);
|
|
85
|
+
expect(exportedEvents).toHaveLength(1);
|
|
86
|
+
const event = exportedEvents[0];
|
|
87
|
+
expect(event.outcome).toBe("deny");
|
|
88
|
+
expect(event.reason).toBe("sensitive_path_blocked");
|
|
89
|
+
// Resource should be redacted for sensitive paths
|
|
90
|
+
expect(event.resource).toBe("[REDACTED]");
|
|
91
|
+
});
|
|
92
|
+
it("exports error events when sidecar is unavailable", async () => {
|
|
93
|
+
const exportedEvents = [];
|
|
94
|
+
const mockClient = {
|
|
95
|
+
authorize: vi.fn().mockRejectedValue(new Error("Connection refused")),
|
|
96
|
+
};
|
|
97
|
+
const auditExporter = {
|
|
98
|
+
exportDecision: async (event) => {
|
|
99
|
+
exportedEvents.push(event);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const provider = new GuardedProvider({
|
|
103
|
+
principal: "agent:error-test",
|
|
104
|
+
authorityClient: mockClient,
|
|
105
|
+
auditExporter,
|
|
106
|
+
});
|
|
107
|
+
await expect(provider.authorize({
|
|
108
|
+
action: "net.http",
|
|
109
|
+
resource: "https://api.example.com",
|
|
110
|
+
args: { url: "https://api.example.com" },
|
|
111
|
+
context: { tenant_id: "tenant-error" },
|
|
112
|
+
})).rejects.toThrow();
|
|
113
|
+
expect(exportedEvents).toHaveLength(1);
|
|
114
|
+
const event = exportedEvents[0];
|
|
115
|
+
expect(event.outcome).toBe("error");
|
|
116
|
+
expect(event.tenantId).toBe("tenant-error");
|
|
117
|
+
});
|
|
118
|
+
it("handles audit exporter failures gracefully (best-effort)", async () => {
|
|
119
|
+
let callCount = 0;
|
|
120
|
+
const mockClient = {
|
|
121
|
+
authorize: vi.fn().mockResolvedValue({ allow: true }),
|
|
122
|
+
};
|
|
123
|
+
const failingExporter = {
|
|
124
|
+
exportDecision: async () => {
|
|
125
|
+
callCount++;
|
|
126
|
+
throw new Error("Audit sink unavailable");
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const provider = new GuardedProvider({
|
|
130
|
+
principal: "agent:failing-audit",
|
|
131
|
+
authorityClient: mockClient,
|
|
132
|
+
auditExporter: failingExporter,
|
|
133
|
+
});
|
|
134
|
+
// Should not throw even though exporter fails
|
|
135
|
+
const result = await provider.authorize({
|
|
136
|
+
action: "fs.read",
|
|
137
|
+
resource: "/workspace/safe-file.txt",
|
|
138
|
+
args: { path: "/workspace/safe-file.txt" },
|
|
139
|
+
});
|
|
140
|
+
// Authorization should succeed despite audit failure
|
|
141
|
+
expect(result).toBeNull(); // No mandate ID returned in this case
|
|
142
|
+
expect(callCount).toBe(1); // Exporter was called
|
|
143
|
+
});
|
|
144
|
+
it("chains multiple decision events with consistent trace context", async () => {
|
|
145
|
+
const exportedEvents = [];
|
|
146
|
+
const mockClient = {
|
|
147
|
+
authorize: vi
|
|
148
|
+
.fn()
|
|
149
|
+
.mockResolvedValueOnce({ allow: true, mandateId: "m1" })
|
|
150
|
+
.mockResolvedValueOnce({ allow: true, mandateId: "m2" })
|
|
151
|
+
.mockResolvedValueOnce({ allow: false, reason: "rate_limited" }),
|
|
152
|
+
};
|
|
153
|
+
const auditExporter = {
|
|
154
|
+
exportDecision: async (event) => {
|
|
155
|
+
exportedEvents.push(event);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const provider = new GuardedProvider({
|
|
159
|
+
principal: "agent:chained-ops",
|
|
160
|
+
authorityClient: mockClient,
|
|
161
|
+
auditExporter,
|
|
162
|
+
});
|
|
163
|
+
const sharedContext = {
|
|
164
|
+
tenant_id: "tenant-chain",
|
|
165
|
+
session_id: "sess-chain",
|
|
166
|
+
trace_id: "trace-chain-root",
|
|
167
|
+
};
|
|
168
|
+
// First operation - allowed
|
|
169
|
+
await provider.authorize({
|
|
170
|
+
action: "fs.read",
|
|
171
|
+
resource: "/workspace/config.json",
|
|
172
|
+
args: { path: "/workspace/config.json" },
|
|
173
|
+
context: sharedContext,
|
|
174
|
+
});
|
|
175
|
+
// Second operation - allowed
|
|
176
|
+
await provider.authorize({
|
|
177
|
+
action: "net.http",
|
|
178
|
+
resource: "https://api.internal/fetch",
|
|
179
|
+
args: { url: "https://api.internal/fetch" },
|
|
180
|
+
context: sharedContext,
|
|
181
|
+
});
|
|
182
|
+
// Third operation - denied
|
|
183
|
+
try {
|
|
184
|
+
await provider.authorize({
|
|
185
|
+
action: "shell.execute",
|
|
186
|
+
resource: "curl external.com",
|
|
187
|
+
args: { cmd: "curl external.com" },
|
|
188
|
+
context: sharedContext,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Expected denial
|
|
193
|
+
}
|
|
194
|
+
expect(exportedEvents).toHaveLength(3);
|
|
195
|
+
// All events should share the same trace context
|
|
196
|
+
for (const event of exportedEvents) {
|
|
197
|
+
expect(event.tenantId).toBe("tenant-chain");
|
|
198
|
+
expect(event.sessionId).toBe("sess-chain");
|
|
199
|
+
expect(event.traceId).toBe("trace-chain-root");
|
|
200
|
+
}
|
|
201
|
+
// Verify outcomes
|
|
202
|
+
expect(exportedEvents[0].outcome).toBe("allow");
|
|
203
|
+
expect(exportedEvents[0].mandateId).toBe("m1");
|
|
204
|
+
expect(exportedEvents[1].outcome).toBe("allow");
|
|
205
|
+
expect(exportedEvents[1].mandateId).toBe("m2");
|
|
206
|
+
expect(exportedEvents[2].outcome).toBe("deny");
|
|
207
|
+
expect(exportedEvents[2].reason).toBe("rate_limited");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|