predicate-claw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.github/workflows/release.yml +76 -0
  2. package/.github/workflows/tests.yml +34 -0
  3. package/.markdownlint.yaml +5 -0
  4. package/.pre-commit-config.yaml +100 -0
  5. package/README.md +405 -0
  6. package/dist/src/adapter.d.ts +17 -0
  7. package/dist/src/adapter.js +36 -0
  8. package/dist/src/authority-client.d.ts +21 -0
  9. package/dist/src/authority-client.js +22 -0
  10. package/dist/src/circuit-breaker.d.ts +86 -0
  11. package/dist/src/circuit-breaker.js +174 -0
  12. package/dist/src/config.d.ts +8 -0
  13. package/dist/src/config.js +7 -0
  14. package/dist/src/control-plane-sync.d.ts +57 -0
  15. package/dist/src/control-plane-sync.js +99 -0
  16. package/dist/src/errors.d.ts +6 -0
  17. package/dist/src/errors.js +6 -0
  18. package/dist/src/index.d.ts +12 -0
  19. package/dist/src/index.js +12 -0
  20. package/dist/src/non-web-evidence.d.ts +46 -0
  21. package/dist/src/non-web-evidence.js +54 -0
  22. package/dist/src/openclaw-hooks.d.ts +27 -0
  23. package/dist/src/openclaw-hooks.js +54 -0
  24. package/dist/src/openclaw-plugin-api.d.ts +18 -0
  25. package/dist/src/openclaw-plugin-api.js +17 -0
  26. package/dist/src/provider.d.ts +48 -0
  27. package/dist/src/provider.js +154 -0
  28. package/dist/src/runtime-integration.d.ts +20 -0
  29. package/dist/src/runtime-integration.js +43 -0
  30. package/dist/src/web-evidence.d.ts +48 -0
  31. package/dist/src/web-evidence.js +49 -0
  32. package/dist/tests/adapter.test.d.ts +1 -0
  33. package/dist/tests/adapter.test.js +63 -0
  34. package/dist/tests/audit-event-e2e.test.d.ts +1 -0
  35. package/dist/tests/audit-event-e2e.test.js +209 -0
  36. package/dist/tests/authority-client.test.d.ts +1 -0
  37. package/dist/tests/authority-client.test.js +46 -0
  38. package/dist/tests/circuit-breaker.test.d.ts +1 -0
  39. package/dist/tests/circuit-breaker.test.js +200 -0
  40. package/dist/tests/control-plane-sync.test.d.ts +1 -0
  41. package/dist/tests/control-plane-sync.test.js +90 -0
  42. package/dist/tests/hack-vs-fix-demo.test.d.ts +1 -0
  43. package/dist/tests/hack-vs-fix-demo.test.js +36 -0
  44. package/dist/tests/jwks-rotation.test.d.ts +1 -0
  45. package/dist/tests/jwks-rotation.test.js +232 -0
  46. package/dist/tests/load-latency.test.d.ts +1 -0
  47. package/dist/tests/load-latency.test.js +175 -0
  48. package/dist/tests/multi-tenant-isolation.test.d.ts +1 -0
  49. package/dist/tests/multi-tenant-isolation.test.js +146 -0
  50. package/dist/tests/non-web-evidence.test.d.ts +1 -0
  51. package/dist/tests/non-web-evidence.test.js +139 -0
  52. package/dist/tests/openclaw-hooks.test.d.ts +1 -0
  53. package/dist/tests/openclaw-hooks.test.js +38 -0
  54. package/dist/tests/openclaw-plugin-api.test.d.ts +1 -0
  55. package/dist/tests/openclaw-plugin-api.test.js +40 -0
  56. package/dist/tests/provider.test.d.ts +1 -0
  57. package/dist/tests/provider.test.js +190 -0
  58. package/dist/tests/runtime-integration.test.d.ts +1 -0
  59. package/dist/tests/runtime-integration.test.js +57 -0
  60. package/dist/tests/web-evidence.test.d.ts +1 -0
  61. package/dist/tests/web-evidence.test.js +89 -0
  62. package/docs/MIGRATION_GUIDE.md +405 -0
  63. package/docs/OPERATIONAL_RUNBOOK.md +389 -0
  64. package/docs/PRODUCTION_READINESS.md +134 -0
  65. package/docs/SLO_THRESHOLDS.md +193 -0
  66. package/examples/README.md +171 -0
  67. package/examples/docker/Dockerfile.test +16 -0
  68. package/examples/docker/README.md +48 -0
  69. package/examples/docker/docker-compose.test.yml +16 -0
  70. package/examples/non-web-evidence-demo.ts +184 -0
  71. package/examples/openclaw-plugin-smoke/index.ts +30 -0
  72. package/examples/openclaw-plugin-smoke/openclaw.plugin.json +11 -0
  73. package/examples/openclaw-plugin-smoke/package.json +9 -0
  74. package/examples/openclaw_integration_example.py +41 -0
  75. package/examples/policy/README.md +165 -0
  76. package/examples/policy/approved-hosts.yaml +137 -0
  77. package/examples/policy/dev-workflow.yaml +206 -0
  78. package/examples/policy/policy.example.yaml +17 -0
  79. package/examples/policy/production-strict.yaml +97 -0
  80. package/examples/policy/sensitive-paths.yaml +114 -0
  81. package/examples/policy/source-trust.yaml +129 -0
  82. package/examples/policy/workspace-isolation.yaml +51 -0
  83. package/examples/runtime_registry_example.py +75 -0
  84. package/package.json +27 -0
  85. package/pyproject.toml +41 -0
  86. package/src/adapter.ts +45 -0
  87. package/src/authority-client.ts +50 -0
  88. package/src/circuit-breaker.ts +245 -0
  89. package/src/config.ts +15 -0
  90. package/src/control-plane-sync.ts +159 -0
  91. package/src/errors.ts +5 -0
  92. package/src/index.ts +12 -0
  93. package/src/non-web-evidence.ts +116 -0
  94. package/src/openclaw-hooks.ts +76 -0
  95. package/src/openclaw-plugin-api.ts +51 -0
  96. package/src/openclaw_predicate_provider/__init__.py +16 -0
  97. package/src/openclaw_predicate_provider/__main__.py +5 -0
  98. package/src/openclaw_predicate_provider/adapter.py +84 -0
  99. package/src/openclaw_predicate_provider/agentidentity_backend.py +78 -0
  100. package/src/openclaw_predicate_provider/cli.py +160 -0
  101. package/src/openclaw_predicate_provider/config.py +42 -0
  102. package/src/openclaw_predicate_provider/errors.py +13 -0
  103. package/src/openclaw_predicate_provider/integrations/__init__.py +5 -0
  104. package/src/openclaw_predicate_provider/integrations/openclaw_runtime.py +74 -0
  105. package/src/openclaw_predicate_provider/models.py +19 -0
  106. package/src/openclaw_predicate_provider/openclaw_hooks.py +75 -0
  107. package/src/openclaw_predicate_provider/provider.py +69 -0
  108. package/src/openclaw_predicate_provider/py.typed +1 -0
  109. package/src/openclaw_predicate_provider/sidecar.py +59 -0
  110. package/src/provider.ts +220 -0
  111. package/src/runtime-integration.ts +68 -0
  112. package/src/web-evidence.ts +95 -0
  113. package/tests/adapter.test.ts +76 -0
  114. package/tests/audit-event-e2e.test.ts +258 -0
  115. package/tests/authority-client.test.ts +52 -0
  116. package/tests/circuit-breaker.test.ts +266 -0
  117. package/tests/conftest.py +9 -0
  118. package/tests/control-plane-sync.test.ts +114 -0
  119. package/tests/hack-vs-fix-demo.test.ts +44 -0
  120. package/tests/jwks-rotation.test.ts +274 -0
  121. package/tests/load-latency.test.ts +214 -0
  122. package/tests/multi-tenant-isolation.test.ts +183 -0
  123. package/tests/non-web-evidence.test.ts +168 -0
  124. package/tests/openclaw-hooks.test.ts +46 -0
  125. package/tests/openclaw-plugin-api.test.ts +50 -0
  126. package/tests/provider.test.ts +227 -0
  127. package/tests/runtime-integration.test.ts +70 -0
  128. package/tests/test_adapter.py +46 -0
  129. package/tests/test_cli.py +26 -0
  130. package/tests/test_openclaw_hooks.py +53 -0
  131. package/tests/test_provider.py +59 -0
  132. package/tests/test_runtime_integration.py +77 -0
  133. package/tests/test_sidecar_client.py +198 -0
  134. package/tests/web-evidence.test.ts +113 -0
  135. package/tsconfig.json +14 -0
  136. package/vitest.config.ts +7 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ exclude: ["dist/**", "node_modules/**"],
6
+ },
7
+ });