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,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { HookEnvelope } from "../src/openclaw-hooks.js";
|
|
3
|
+
import { OpenClawRuntimeIntegrator } from "../src/runtime-integration.js";
|
|
4
|
+
|
|
5
|
+
class Registry {
|
|
6
|
+
private handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
7
|
+
"cmd.run": async (args) => ({ tool: "cmd.run", args }),
|
|
8
|
+
"fs.readFile": async (args) => ({ tool: "fs.readFile", args }),
|
|
9
|
+
"http.request": async (args) => ({ tool: "http.request", args }),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
get(toolName: string) {
|
|
13
|
+
return this.handlers[toolName];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(toolName: string, handler: (args: Record<string, unknown>) => Promise<unknown>) {
|
|
17
|
+
this.handlers[toolName] = handler;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
invoke(toolName: string, args: Record<string, unknown>) {
|
|
21
|
+
return this.handlers[toolName](args);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("OpenClawRuntimeIntegrator", () => {
|
|
26
|
+
it("wraps sensitive handlers and routes through hooks", async () => {
|
|
27
|
+
const seen: string[] = [];
|
|
28
|
+
const registry = new Registry();
|
|
29
|
+
const hooks = {
|
|
30
|
+
onCmdRun: async (envelope: HookEnvelope, execute: (args: Record<string, unknown>) => Promise<unknown>) => {
|
|
31
|
+
seen.push(`cmd:${envelope.toolName}`);
|
|
32
|
+
return execute(envelope.args);
|
|
33
|
+
},
|
|
34
|
+
onFsRead: async (envelope: HookEnvelope, execute: (args: Record<string, unknown>) => Promise<unknown>) => {
|
|
35
|
+
seen.push(`fs:${envelope.toolName}`);
|
|
36
|
+
return execute(envelope.args);
|
|
37
|
+
},
|
|
38
|
+
onHttpRequest: async (
|
|
39
|
+
envelope: HookEnvelope,
|
|
40
|
+
execute: (args: Record<string, unknown>) => Promise<unknown>,
|
|
41
|
+
) => {
|
|
42
|
+
seen.push(`http:${envelope.toolName}`);
|
|
43
|
+
return execute(envelope.args);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const integrator = new OpenClawRuntimeIntegrator({
|
|
48
|
+
hooks,
|
|
49
|
+
contextBuilder: (toolName, args) =>
|
|
50
|
+
new HookEnvelope({
|
|
51
|
+
toolName,
|
|
52
|
+
args,
|
|
53
|
+
sessionId: "s1",
|
|
54
|
+
source: "trusted_ui",
|
|
55
|
+
tenantId: "t1",
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
integrator.register(registry);
|
|
60
|
+
|
|
61
|
+
const cmd = await registry.invoke("cmd.run", { command: "echo hi" });
|
|
62
|
+
const fs = await registry.invoke("fs.readFile", { path: "/tmp/demo" });
|
|
63
|
+
const http = await registry.invoke("http.request", { url: "https://example.com" });
|
|
64
|
+
|
|
65
|
+
expect(cmd).toMatchObject({ tool: "cmd.run" });
|
|
66
|
+
expect(fs).toMatchObject({ tool: "fs.readFile" });
|
|
67
|
+
expect(http).toMatchObject({ tool: "http.request" });
|
|
68
|
+
expect(seen).toEqual(["cmd:cmd.run", "fs:fs.readFile", "http:http.request"]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from openclaw_predicate_provider.adapter import ToolAdapter
|
|
4
|
+
from openclaw_predicate_provider.errors import ActionDeniedError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _AllowGuard:
|
|
8
|
+
async def guard_or_raise(self, **_: object) -> str:
|
|
9
|
+
return "mnd_test"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _DenyGuard:
|
|
13
|
+
async def guard_or_raise(self, **_: object) -> str:
|
|
14
|
+
raise ActionDeniedError("denied_by_policy")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _echo(args: dict[str, object]) -> dict[str, object]:
|
|
18
|
+
return args
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_run_shell_uses_expected_action_and_resource() -> None:
|
|
22
|
+
adapter = ToolAdapter(_AllowGuard()) # type: ignore[arg-type]
|
|
23
|
+
result = asyncio.run(
|
|
24
|
+
adapter.run_shell(
|
|
25
|
+
args={"command": "echo hi"},
|
|
26
|
+
context={"source": "trusted_ui"},
|
|
27
|
+
execute=_echo,
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
assert result["command"] == "echo hi"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_denied_guard_bubbles_exception() -> None:
|
|
34
|
+
adapter = ToolAdapter(_DenyGuard()) # type: ignore[arg-type]
|
|
35
|
+
try:
|
|
36
|
+
asyncio.run(
|
|
37
|
+
adapter.read_file(
|
|
38
|
+
args={"path": "/etc/passwd"},
|
|
39
|
+
context={"source": "untrusted_dm"},
|
|
40
|
+
execute=_echo,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
except ActionDeniedError as exc:
|
|
44
|
+
assert "denied" in str(exc)
|
|
45
|
+
return
|
|
46
|
+
raise AssertionError("Expected ActionDeniedError")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from openclaw_predicate_provider.cli import build_parser
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_validate_config_command_parses() -> None:
|
|
5
|
+
parser = build_parser()
|
|
6
|
+
args = parser.parse_args(["validate-config"])
|
|
7
|
+
assert args.command == "validate-config"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_smoke_authorize_command_parses_defaults() -> None:
|
|
11
|
+
parser = build_parser()
|
|
12
|
+
args = parser.parse_args(["smoke-authorize"])
|
|
13
|
+
assert args.command == "smoke-authorize"
|
|
14
|
+
assert args.action == "shell.execute"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_validate_config_accepts_backend_switch() -> None:
|
|
18
|
+
parser = build_parser()
|
|
19
|
+
args = parser.parse_args(
|
|
20
|
+
[
|
|
21
|
+
"--authorization-backend",
|
|
22
|
+
"agentidentity_local",
|
|
23
|
+
"validate-config",
|
|
24
|
+
]
|
|
25
|
+
)
|
|
26
|
+
assert args.authorization_backend == "agentidentity_local"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from openclaw_predicate_provider.openclaw_hooks import HookEnvelope, OpenClawHooks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _AdapterStub:
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
self.calls: list[tuple[str, dict, dict]] = []
|
|
9
|
+
|
|
10
|
+
async def run_shell(self, *, args, context, execute): # noqa: ANN001
|
|
11
|
+
self.calls.append(("shell", args, context))
|
|
12
|
+
return await execute(args)
|
|
13
|
+
|
|
14
|
+
async def read_file(self, *, args, context, execute): # noqa: ANN001
|
|
15
|
+
self.calls.append(("fs.read", args, context))
|
|
16
|
+
return await execute(args)
|
|
17
|
+
|
|
18
|
+
async def http_request(self, *, args, context, execute): # noqa: ANN001
|
|
19
|
+
self.calls.append(("net.http", args, context))
|
|
20
|
+
return await execute(args)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _echo(args):
|
|
24
|
+
return args
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_hook_envelope_context_shape() -> None:
|
|
28
|
+
env = HookEnvelope(
|
|
29
|
+
tool_name="cmd.run",
|
|
30
|
+
args={"command": "echo hi"},
|
|
31
|
+
session_id="s1",
|
|
32
|
+
source="trusted_ui",
|
|
33
|
+
tenant_id="t1",
|
|
34
|
+
user_id="u1",
|
|
35
|
+
trace_id="tr1",
|
|
36
|
+
)
|
|
37
|
+
ctx = env.context()
|
|
38
|
+
assert ctx["source"] == "trusted_ui"
|
|
39
|
+
assert ctx["tenant_id"] == "t1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_on_cmd_run_routes_to_shell_guard() -> None:
|
|
43
|
+
adapter = _AdapterStub()
|
|
44
|
+
hooks = OpenClawHooks(adapter) # type: ignore[arg-type]
|
|
45
|
+
env = HookEnvelope(
|
|
46
|
+
tool_name="cmd.run",
|
|
47
|
+
args={"command": "echo hi"},
|
|
48
|
+
session_id="s1",
|
|
49
|
+
source="trusted_ui",
|
|
50
|
+
)
|
|
51
|
+
result = asyncio.run(hooks.on_cmd_run(env, _echo))
|
|
52
|
+
assert result["command"] == "echo hi"
|
|
53
|
+
assert adapter.calls[0][0] == "shell"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from openclaw_predicate_provider.config import ProviderConfig
|
|
4
|
+
from openclaw_predicate_provider.errors import SidecarUnavailableError
|
|
5
|
+
from openclaw_predicate_provider.provider import GuardedProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_intent_hash_is_deterministic() -> None:
|
|
9
|
+
payload_a = {"cmd": "ls", "flags": ["-la"]}
|
|
10
|
+
payload_b = {"flags": ["-la"], "cmd": "ls"}
|
|
11
|
+
|
|
12
|
+
first = GuardedProvider._intent_hash(payload_a)
|
|
13
|
+
second = GuardedProvider._intent_hash(payload_b)
|
|
14
|
+
|
|
15
|
+
assert first == second
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _UnavailableSidecar:
|
|
19
|
+
async def authorize(self, request): # noqa: ANN001
|
|
20
|
+
raise SidecarUnavailableError("down")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_guard_or_raise_fails_closed_on_sidecar_unavailable() -> None:
|
|
24
|
+
provider = GuardedProvider(
|
|
25
|
+
principal="p1",
|
|
26
|
+
config=ProviderConfig(fail_closed=True),
|
|
27
|
+
)
|
|
28
|
+
provider._sidecar = _UnavailableSidecar() # type: ignore[assignment]
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
asyncio.run(
|
|
32
|
+
provider.guard_or_raise(
|
|
33
|
+
action="shell.execute",
|
|
34
|
+
resource="echo hi",
|
|
35
|
+
args={"command": "echo hi"},
|
|
36
|
+
context={"source": "trusted_ui"},
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
except SidecarUnavailableError:
|
|
40
|
+
return
|
|
41
|
+
raise AssertionError("Expected SidecarUnavailableError in fail-closed mode")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_guard_or_raise_allows_none_on_fail_open() -> None:
|
|
45
|
+
provider = GuardedProvider(
|
|
46
|
+
principal="p1",
|
|
47
|
+
config=ProviderConfig(fail_closed=False),
|
|
48
|
+
)
|
|
49
|
+
provider._sidecar = _UnavailableSidecar() # type: ignore[assignment]
|
|
50
|
+
|
|
51
|
+
result = asyncio.run(
|
|
52
|
+
provider.guard_or_raise(
|
|
53
|
+
action="shell.execute",
|
|
54
|
+
resource="echo hi",
|
|
55
|
+
args={"command": "echo hi"},
|
|
56
|
+
context={"source": "trusted_ui"},
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
assert result is None
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from openclaw_predicate_provider.integrations import OpenClawRuntimeIntegrator
|
|
4
|
+
from openclaw_predicate_provider.openclaw_hooks import HookEnvelope
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Registry:
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self._handlers = {
|
|
10
|
+
"cmd.run": self._cmd,
|
|
11
|
+
"fs.readFile": self._fs,
|
|
12
|
+
"http.request": self._http,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async def _cmd(self, args):
|
|
16
|
+
return {"tool": "cmd.run", "args": args}
|
|
17
|
+
|
|
18
|
+
async def _fs(self, args):
|
|
19
|
+
return {"tool": "fs.readFile", "args": args}
|
|
20
|
+
|
|
21
|
+
async def _http(self, args):
|
|
22
|
+
return {"tool": "http.request", "args": args}
|
|
23
|
+
|
|
24
|
+
def get(self, tool_name):
|
|
25
|
+
return self._handlers[tool_name]
|
|
26
|
+
|
|
27
|
+
def set(self, tool_name, handler):
|
|
28
|
+
self._handlers[tool_name] = handler
|
|
29
|
+
|
|
30
|
+
async def invoke(self, tool_name, args):
|
|
31
|
+
return await self._handlers[tool_name](args)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _HooksStub:
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.seen = []
|
|
37
|
+
|
|
38
|
+
async def on_cmd_run(self, envelope, execute):
|
|
39
|
+
self.seen.append(("cmd", envelope.tool_name, envelope.source))
|
|
40
|
+
return await execute(envelope.args)
|
|
41
|
+
|
|
42
|
+
async def on_fs_read(self, envelope, execute):
|
|
43
|
+
self.seen.append(("fs", envelope.tool_name, envelope.source))
|
|
44
|
+
return await execute(envelope.args)
|
|
45
|
+
|
|
46
|
+
async def on_http_request(self, envelope, execute):
|
|
47
|
+
self.seen.append(("http", envelope.tool_name, envelope.source))
|
|
48
|
+
return await execute(envelope.args)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _context_builder(tool_name: str, args: dict) -> HookEnvelope:
|
|
52
|
+
return HookEnvelope(
|
|
53
|
+
tool_name=tool_name,
|
|
54
|
+
args=args,
|
|
55
|
+
session_id="s1",
|
|
56
|
+
source="trusted_ui",
|
|
57
|
+
tenant_id="t1",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_runtime_integrator_wraps_and_routes_handlers() -> None:
|
|
62
|
+
registry = _Registry()
|
|
63
|
+
hooks = _HooksStub()
|
|
64
|
+
integrator = OpenClawRuntimeIntegrator(
|
|
65
|
+
hooks=hooks, # type: ignore[arg-type]
|
|
66
|
+
context_builder=_context_builder,
|
|
67
|
+
)
|
|
68
|
+
integrator.register(registry)
|
|
69
|
+
|
|
70
|
+
cmd = asyncio.run(registry.invoke("cmd.run", {"command": "echo hi"}))
|
|
71
|
+
fs = asyncio.run(registry.invoke("fs.readFile", {"path": "/tmp/demo"}))
|
|
72
|
+
http = asyncio.run(registry.invoke("http.request", {"url": "https://e.com"}))
|
|
73
|
+
|
|
74
|
+
assert cmd["tool"] == "cmd.run"
|
|
75
|
+
assert fs["tool"] == "fs.readFile"
|
|
76
|
+
assert http["tool"] == "http.request"
|
|
77
|
+
assert [x[0] for x in hooks.seen] == ["cmd", "fs", "http"]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from openclaw_predicate_provider.config import ProviderConfig
|
|
7
|
+
from openclaw_predicate_provider.errors import SidecarUnavailableError
|
|
8
|
+
from openclaw_predicate_provider.models import AuthorizationRequest
|
|
9
|
+
from openclaw_predicate_provider.sidecar import SidecarClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _DeniedResponseClient:
|
|
13
|
+
def __init__(self, timeout: float): # noqa: ARG002
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
async def __aenter__(self):
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
async def post(self, url: str, json: dict): # noqa: ARG002
|
|
23
|
+
return httpx.Response(
|
|
24
|
+
status_code=403,
|
|
25
|
+
json={"reason": "explicit_deny", "mandate_id": "mnd123"},
|
|
26
|
+
request=httpx.Request("POST", url),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _AllowedResponseClient:
|
|
31
|
+
def __init__(self, timeout: float): # noqa: ARG002
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
async def __aenter__(self):
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
async def post(self, url: str, json: dict): # noqa: ARG002
|
|
41
|
+
return httpx.Response(
|
|
42
|
+
status_code=200,
|
|
43
|
+
json={"reason": "allowed", "mandate_id": "mnd_ok"},
|
|
44
|
+
request=httpx.Request("POST", url),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _UnexpectedStatusClient:
|
|
49
|
+
def __init__(self, timeout: float): # noqa: ARG002
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
async def __aenter__(self):
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
async def post(self, url: str, json: dict): # noqa: ARG002
|
|
59
|
+
return httpx.Response(
|
|
60
|
+
status_code=502,
|
|
61
|
+
json={"error": "bad_gateway"},
|
|
62
|
+
request=httpx.Request("POST", url),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _DeniedEmptyPayloadClient:
|
|
67
|
+
def __init__(self, timeout: float): # noqa: ARG002
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
async def __aenter__(self):
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
async def post(self, url: str, json: dict): # noqa: ARG002
|
|
77
|
+
return httpx.Response(
|
|
78
|
+
status_code=403,
|
|
79
|
+
json={},
|
|
80
|
+
request=httpx.Request("POST", url),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _request() -> AuthorizationRequest:
|
|
85
|
+
return AuthorizationRequest(
|
|
86
|
+
principal="openclaw-agent-local",
|
|
87
|
+
action="shell.execute",
|
|
88
|
+
resource="echo hi",
|
|
89
|
+
intent_hash="abc123",
|
|
90
|
+
context={"source": "untrusted_dm"},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_sidecar_403_propagates_reason_and_mandate_id() -> None:
|
|
95
|
+
request = _request()
|
|
96
|
+
client = SidecarClient(ProviderConfig())
|
|
97
|
+
|
|
98
|
+
with patch(
|
|
99
|
+
"openclaw_predicate_provider.sidecar.httpx.AsyncClient",
|
|
100
|
+
_DeniedResponseClient,
|
|
101
|
+
):
|
|
102
|
+
decision = asyncio.run(client.authorize(request))
|
|
103
|
+
|
|
104
|
+
assert decision.allow is False
|
|
105
|
+
assert decision.reason == "explicit_deny"
|
|
106
|
+
assert decision.mandate_id == "mnd123"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_sidecar_200_propagates_allow_and_mandate_id() -> None:
|
|
110
|
+
request = _request()
|
|
111
|
+
client = SidecarClient(ProviderConfig())
|
|
112
|
+
|
|
113
|
+
with patch(
|
|
114
|
+
"openclaw_predicate_provider.sidecar.httpx.AsyncClient",
|
|
115
|
+
_AllowedResponseClient,
|
|
116
|
+
):
|
|
117
|
+
decision = asyncio.run(client.authorize(request))
|
|
118
|
+
|
|
119
|
+
assert decision.allow is True
|
|
120
|
+
assert decision.reason == "allowed"
|
|
121
|
+
assert decision.mandate_id == "mnd_ok"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_unexpected_status_fails_closed_by_default() -> None:
|
|
125
|
+
request = _request()
|
|
126
|
+
client = SidecarClient(ProviderConfig(fail_closed=True))
|
|
127
|
+
|
|
128
|
+
with patch(
|
|
129
|
+
"openclaw_predicate_provider.sidecar.httpx.AsyncClient",
|
|
130
|
+
_UnexpectedStatusClient,
|
|
131
|
+
):
|
|
132
|
+
try:
|
|
133
|
+
asyncio.run(client.authorize(request))
|
|
134
|
+
except SidecarUnavailableError:
|
|
135
|
+
return
|
|
136
|
+
raise AssertionError("Expected SidecarUnavailableError for fail-closed mode")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_unexpected_status_can_fail_open_when_enabled() -> None:
|
|
140
|
+
request = _request()
|
|
141
|
+
client = SidecarClient(ProviderConfig(fail_closed=False))
|
|
142
|
+
|
|
143
|
+
with patch(
|
|
144
|
+
"openclaw_predicate_provider.sidecar.httpx.AsyncClient",
|
|
145
|
+
_UnexpectedStatusClient,
|
|
146
|
+
):
|
|
147
|
+
decision = asyncio.run(client.authorize(request))
|
|
148
|
+
|
|
149
|
+
assert decision.allow is True
|
|
150
|
+
assert decision.reason == "fail_open_override"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_403_empty_payload_uses_default_deny_reason() -> None:
|
|
154
|
+
request = _request()
|
|
155
|
+
client = SidecarClient(ProviderConfig())
|
|
156
|
+
|
|
157
|
+
with patch(
|
|
158
|
+
"openclaw_predicate_provider.sidecar.httpx.AsyncClient",
|
|
159
|
+
_DeniedEmptyPayloadClient,
|
|
160
|
+
):
|
|
161
|
+
decision = asyncio.run(client.authorize(request))
|
|
162
|
+
|
|
163
|
+
assert decision.allow is False
|
|
164
|
+
assert decision.reason == "denied_by_policy"
|
|
165
|
+
assert decision.mandate_id is None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _LocalBackendStub:
|
|
169
|
+
def __init__(self, config: ProviderConfig): # noqa: ARG002
|
|
170
|
+
self.called = False
|
|
171
|
+
|
|
172
|
+
def authorize(self, request: AuthorizationRequest):
|
|
173
|
+
self.called = True
|
|
174
|
+
return type(
|
|
175
|
+
"D",
|
|
176
|
+
(),
|
|
177
|
+
{"allow": False, "reason": "explicit_deny", "mandate_id": "mnd_local"},
|
|
178
|
+
)()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_agentidentity_local_backend_is_selected() -> None:
|
|
182
|
+
request = AuthorizationRequest(
|
|
183
|
+
principal="openclaw-agent-local",
|
|
184
|
+
action="shell.execute",
|
|
185
|
+
resource="echo hi",
|
|
186
|
+
intent_hash="abc123",
|
|
187
|
+
context={"source": "untrusted_dm"},
|
|
188
|
+
)
|
|
189
|
+
with patch(
|
|
190
|
+
"openclaw_predicate_provider.sidecar.AgentIdentityLocalClient",
|
|
191
|
+
_LocalBackendStub,
|
|
192
|
+
):
|
|
193
|
+
client = SidecarClient(ProviderConfig(authorization_backend="agentidentity_local"))
|
|
194
|
+
decision = asyncio.run(client.authorize(request))
|
|
195
|
+
|
|
196
|
+
assert decision.allow is False
|
|
197
|
+
assert decision.reason == "explicit_deny"
|
|
198
|
+
assert decision.mandate_id == "mnd_local"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
buildWebEvidenceFromProvider,
|
|
5
|
+
buildWebEvidenceFromRuntimeSnapshot,
|
|
6
|
+
OpenClawWebEvidenceProvider,
|
|
7
|
+
type WebRuntimeContext,
|
|
8
|
+
} from "../src/web-evidence.js";
|
|
9
|
+
|
|
10
|
+
function sha256(input: string): string {
|
|
11
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("web evidence providers", () => {
|
|
15
|
+
it("builds web state evidence from OpenClaw runtime context", async () => {
|
|
16
|
+
const mockContext: WebRuntimeContext = {
|
|
17
|
+
url: "https://example.com/dashboard",
|
|
18
|
+
title: "Dashboard - Example App",
|
|
19
|
+
domHtml: "<html><body><h1>Dashboard</h1></body></html>",
|
|
20
|
+
visibleText: "Dashboard",
|
|
21
|
+
eventId: "evt-123",
|
|
22
|
+
observedAt: "2026-02-20T12:00:00Z",
|
|
23
|
+
dominantGroupKey: "main-content",
|
|
24
|
+
confidence: 0.95,
|
|
25
|
+
confidenceReasons: ["stable_dom", "no_pending_requests"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const provider = new OpenClawWebEvidenceProvider(() => mockContext);
|
|
29
|
+
const evidence = await buildWebEvidenceFromProvider(provider);
|
|
30
|
+
|
|
31
|
+
expect(evidence.source).toBe("browser");
|
|
32
|
+
expect(evidence.schema_version).toBe("v1");
|
|
33
|
+
expect(evidence.state_hash).toBeDefined();
|
|
34
|
+
expect(typeof evidence.state_hash).toBe("string");
|
|
35
|
+
expect(evidence.confidence).toBe(0.95);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("computes dom_hash when domHtml provided without domHash", async () => {
|
|
39
|
+
const domHtml = "<html><body>Test</body></html>";
|
|
40
|
+
const expectedHash = sha256(domHtml);
|
|
41
|
+
|
|
42
|
+
const provider = new OpenClawWebEvidenceProvider(() => ({
|
|
43
|
+
url: "https://example.com",
|
|
44
|
+
domHtml,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const snapshot = await provider.captureWebSnapshot();
|
|
48
|
+
expect(snapshot.dom_hash).toBe(expectedHash);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("computes visible_text_hash when visibleText provided without hash", async () => {
|
|
52
|
+
const visibleText = "Hello World";
|
|
53
|
+
const expectedHash = sha256(visibleText);
|
|
54
|
+
|
|
55
|
+
const provider = new OpenClawWebEvidenceProvider(() => ({
|
|
56
|
+
url: "https://example.com",
|
|
57
|
+
visibleText,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const snapshot = await provider.captureWebSnapshot();
|
|
61
|
+
expect(snapshot.visible_text_hash).toBe(expectedHash);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("uses provided hashes when available", async () => {
|
|
65
|
+
const precomputedDomHash = "abc123";
|
|
66
|
+
const precomputedTextHash = "def456";
|
|
67
|
+
|
|
68
|
+
const provider = new OpenClawWebEvidenceProvider(() => ({
|
|
69
|
+
url: "https://example.com",
|
|
70
|
+
domHash: precomputedDomHash,
|
|
71
|
+
visibleTextHash: precomputedTextHash,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
const snapshot = await provider.captureWebSnapshot();
|
|
75
|
+
expect(snapshot.dom_hash).toBe(precomputedDomHash);
|
|
76
|
+
expect(snapshot.visible_text_hash).toBe(precomputedTextHash);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("builds evidence from predicate-runtime snapshot format", () => {
|
|
80
|
+
const runtimeSnapshot = {
|
|
81
|
+
url: "https://example.com/page",
|
|
82
|
+
timestamp: "2026-02-20T12:00:00Z",
|
|
83
|
+
dominant_group_key: "content-area",
|
|
84
|
+
diagnostics: {
|
|
85
|
+
confidence: 0.88,
|
|
86
|
+
reasons: ["dom_stable"],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const evidence = buildWebEvidenceFromRuntimeSnapshot(runtimeSnapshot);
|
|
91
|
+
|
|
92
|
+
expect(evidence.source).toBe("browser");
|
|
93
|
+
expect(evidence.schema_version).toBe("v1");
|
|
94
|
+
expect(evidence.confidence).toBe(0.88);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("handles async capture functions", async () => {
|
|
98
|
+
const asyncCapture = async (): Promise<WebRuntimeContext> => {
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
100
|
+
return {
|
|
101
|
+
url: "https://async.example.com",
|
|
102
|
+
title: "Async Page",
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const provider = new OpenClawWebEvidenceProvider(asyncCapture);
|
|
107
|
+
const snapshot = await provider.captureWebSnapshot();
|
|
108
|
+
|
|
109
|
+
expect(snapshot.url).toBe("https://async.example.com");
|
|
110
|
+
expect(snapshot.title).toBe("Async Page");
|
|
111
|
+
expect(snapshot.observed_at).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
|
14
|
+
}
|