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,160 @@
|
|
|
1
|
+
"""CLI utilities for provider configuration and sidecar smoke checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .config import ProviderConfig
|
|
11
|
+
from .errors import ActionDeniedError, SidecarUnavailableError
|
|
12
|
+
from .provider import GuardedProvider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_json_arg(raw: str, label: str) -> dict[str, Any]:
|
|
16
|
+
try:
|
|
17
|
+
value = json.loads(raw)
|
|
18
|
+
except json.JSONDecodeError as exc:
|
|
19
|
+
raise ValueError(f"Invalid JSON for {label}: {exc}") from exc
|
|
20
|
+
if not isinstance(value, dict):
|
|
21
|
+
raise ValueError(f"{label} must be a JSON object")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_config(args: argparse.Namespace) -> ProviderConfig:
|
|
26
|
+
return ProviderConfig(
|
|
27
|
+
sidecar_authorize_url=args.sidecar_url,
|
|
28
|
+
request_timeout_ms=args.timeout_ms,
|
|
29
|
+
fail_closed=not args.fail_open,
|
|
30
|
+
authorization_backend=args.authorization_backend,
|
|
31
|
+
agentidentity_policy_file=args.agentidentity_policy_file,
|
|
32
|
+
agentidentity_signing_key=args.agentidentity_signing_key,
|
|
33
|
+
agentidentity_ttl_seconds=args.agentidentity_ttl_seconds,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cmd_validate_config(args: argparse.Namespace) -> int:
|
|
38
|
+
config = _build_config(args)
|
|
39
|
+
print("Configuration valid")
|
|
40
|
+
print(f"sidecar_authorize_url={config.sidecar_authorize_url}")
|
|
41
|
+
print(f"request_timeout_ms={config.request_timeout_ms}")
|
|
42
|
+
print(f"fail_closed={config.fail_closed}")
|
|
43
|
+
print(f"authorization_backend={config.authorization_backend}")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _cmd_smoke_authorize_async(args: argparse.Namespace) -> int:
|
|
48
|
+
config = _build_config(args)
|
|
49
|
+
provider = GuardedProvider(principal=args.principal, config=config)
|
|
50
|
+
context = _parse_json_arg(args.context_json, "context")
|
|
51
|
+
payload = _parse_json_arg(args.args_json, "args")
|
|
52
|
+
try:
|
|
53
|
+
mandate_id = await provider.guard_or_raise(
|
|
54
|
+
action=args.action,
|
|
55
|
+
resource=args.resource,
|
|
56
|
+
args=payload,
|
|
57
|
+
context=context,
|
|
58
|
+
)
|
|
59
|
+
except ActionDeniedError as exc:
|
|
60
|
+
print(f"DENY: {exc}")
|
|
61
|
+
return 2
|
|
62
|
+
except SidecarUnavailableError as exc:
|
|
63
|
+
print(f"ERROR: {exc}")
|
|
64
|
+
return 3
|
|
65
|
+
|
|
66
|
+
print("ALLOW")
|
|
67
|
+
print(f"mandate_id={mandate_id}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cmd_smoke_authorize(args: argparse.Namespace) -> int:
|
|
72
|
+
return asyncio.run(_cmd_smoke_authorize_async(args))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
76
|
+
parser = argparse.ArgumentParser(
|
|
77
|
+
prog="openclaw-predicate-provider",
|
|
78
|
+
description="CLI for OpenClaw Predicate provider scaffolding.",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--sidecar-url",
|
|
82
|
+
default="http://127.0.0.1:4000/v1/authorize",
|
|
83
|
+
help="Predicate sidecar authorize URL.",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--timeout-ms",
|
|
87
|
+
type=int,
|
|
88
|
+
default=300,
|
|
89
|
+
help="Request timeout in milliseconds.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--fail-open",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Disable fail-closed mode (for debugging only).",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--authorization-backend",
|
|
98
|
+
choices=["http_sidecar", "agentidentity_local"],
|
|
99
|
+
default="http_sidecar",
|
|
100
|
+
help="Authorization backend implementation.",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--agentidentity-policy-file",
|
|
104
|
+
default=None,
|
|
105
|
+
help="Policy file path for agentidentity_local backend.",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--agentidentity-signing-key",
|
|
109
|
+
default=None,
|
|
110
|
+
help="Signing key for agentidentity_local backend.",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--agentidentity-ttl-seconds",
|
|
114
|
+
type=int,
|
|
115
|
+
default=300,
|
|
116
|
+
help="Mandate TTL for agentidentity_local backend bootstrap.",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
120
|
+
|
|
121
|
+
validate = subparsers.add_parser(
|
|
122
|
+
"validate-config",
|
|
123
|
+
help="Validate and print effective configuration.",
|
|
124
|
+
)
|
|
125
|
+
validate.set_defaults(handler=_cmd_validate_config)
|
|
126
|
+
|
|
127
|
+
smoke = subparsers.add_parser(
|
|
128
|
+
"smoke-authorize",
|
|
129
|
+
help="Run one authorization check against sidecar.",
|
|
130
|
+
)
|
|
131
|
+
smoke.add_argument("--principal", default="openclaw-agent-local")
|
|
132
|
+
smoke.add_argument("--action", default="shell.execute")
|
|
133
|
+
smoke.add_argument("--resource", default="echo hello")
|
|
134
|
+
smoke.add_argument(
|
|
135
|
+
"--args-json",
|
|
136
|
+
default='{"command":"echo hello"}',
|
|
137
|
+
help="JSON object for action arguments.",
|
|
138
|
+
)
|
|
139
|
+
smoke.add_argument(
|
|
140
|
+
"--context-json",
|
|
141
|
+
default='{"source":"trusted_ui","session_id":"smoke"}',
|
|
142
|
+
help="JSON object for context values.",
|
|
143
|
+
)
|
|
144
|
+
smoke.set_defaults(handler=_cmd_smoke_authorize)
|
|
145
|
+
|
|
146
|
+
return parser
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main() -> int:
|
|
150
|
+
parser = build_parser()
|
|
151
|
+
args = parser.parse_args()
|
|
152
|
+
try:
|
|
153
|
+
return int(args.handler(args))
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
print(f"ERROR: {exc}")
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Configuration models for OpenClaw Predicate provider."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProviderConfig(BaseModel):
|
|
9
|
+
"""Runtime settings for sidecar authorization checks."""
|
|
10
|
+
|
|
11
|
+
sidecar_authorize_url: str = Field(
|
|
12
|
+
default="http://127.0.0.1:4000/v1/authorize",
|
|
13
|
+
description="Predicate sidecar authorize endpoint.",
|
|
14
|
+
)
|
|
15
|
+
request_timeout_ms: int = Field(
|
|
16
|
+
default=300,
|
|
17
|
+
ge=50,
|
|
18
|
+
le=10000,
|
|
19
|
+
description="Authorization request timeout in milliseconds.",
|
|
20
|
+
)
|
|
21
|
+
fail_closed: bool = Field(
|
|
22
|
+
default=True,
|
|
23
|
+
description="Block action if sidecar check fails unexpectedly.",
|
|
24
|
+
)
|
|
25
|
+
authorization_backend: Literal["http_sidecar", "agentidentity_local"] = Field(
|
|
26
|
+
default="http_sidecar",
|
|
27
|
+
description="Authorization execution backend.",
|
|
28
|
+
)
|
|
29
|
+
agentidentity_policy_file: str | None = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="Optional policy file for AgentIdentity local client bootstrap.",
|
|
32
|
+
)
|
|
33
|
+
agentidentity_signing_key: str | None = Field(
|
|
34
|
+
default=None,
|
|
35
|
+
description="Optional signing key for AgentIdentity local client bootstrap.",
|
|
36
|
+
)
|
|
37
|
+
agentidentity_ttl_seconds: int = Field(
|
|
38
|
+
default=300,
|
|
39
|
+
ge=30,
|
|
40
|
+
le=86400,
|
|
41
|
+
description="TTL used when bootstrapping AgentIdentity local client.",
|
|
42
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Error types surfaced by provider guard logic."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GuardError(RuntimeError):
|
|
5
|
+
"""Base guard error."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ActionDeniedError(GuardError):
|
|
9
|
+
"""Raised when policy denies a tool action."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SidecarUnavailableError(GuardError):
|
|
13
|
+
"""Raised when sidecar cannot be reached in fail-closed mode."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Runtime registration scaffold for OpenClaw tool hook integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Awaitable, Callable, Protocol
|
|
6
|
+
|
|
7
|
+
from ..openclaw_hooks import HookEnvelope, OpenClawHooks
|
|
8
|
+
|
|
9
|
+
AsyncToolHandler = Callable[[dict[str, Any]], Awaitable[Any]]
|
|
10
|
+
ContextBuilder = Callable[[str, dict[str, Any]], HookEnvelope]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolRegistryProtocol(Protocol):
|
|
14
|
+
"""Minimal tool registry protocol expected from OpenClaw runtime."""
|
|
15
|
+
|
|
16
|
+
def get(self, tool_name: str) -> AsyncToolHandler:
|
|
17
|
+
"""Return the current handler for a tool name."""
|
|
18
|
+
|
|
19
|
+
def set(self, tool_name: str, handler: AsyncToolHandler) -> None:
|
|
20
|
+
"""Replace a handler for a tool name."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _default_context_builder(tool_name: str, args: dict[str, Any]) -> HookEnvelope:
|
|
24
|
+
return HookEnvelope(
|
|
25
|
+
tool_name=tool_name,
|
|
26
|
+
args=args,
|
|
27
|
+
session_id="unknown-session",
|
|
28
|
+
source="unknown-source",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OpenClawRuntimeIntegrator:
|
|
33
|
+
"""Registers guarded wrappers around OpenClaw runtime tool handlers."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
hooks: OpenClawHooks,
|
|
38
|
+
context_builder: ContextBuilder | None = None,
|
|
39
|
+
):
|
|
40
|
+
self._hooks = hooks
|
|
41
|
+
self._context_builder = context_builder or _default_context_builder
|
|
42
|
+
|
|
43
|
+
def register(self, registry: ToolRegistryProtocol) -> None:
|
|
44
|
+
"""Wrap runtime handlers for sensitive tools with Predicate checks."""
|
|
45
|
+
self._wrap_cmd_run(registry)
|
|
46
|
+
self._wrap_fs_read(registry)
|
|
47
|
+
self._wrap_http_request(registry)
|
|
48
|
+
|
|
49
|
+
def _wrap_cmd_run(self, registry: ToolRegistryProtocol) -> None:
|
|
50
|
+
original = registry.get("cmd.run")
|
|
51
|
+
|
|
52
|
+
async def guarded(args: dict[str, Any]) -> Any:
|
|
53
|
+
envelope = self._context_builder("cmd.run", args)
|
|
54
|
+
return await self._hooks.on_cmd_run(envelope, original)
|
|
55
|
+
|
|
56
|
+
registry.set("cmd.run", guarded)
|
|
57
|
+
|
|
58
|
+
def _wrap_fs_read(self, registry: ToolRegistryProtocol) -> None:
|
|
59
|
+
original = registry.get("fs.readFile")
|
|
60
|
+
|
|
61
|
+
async def guarded(args: dict[str, Any]) -> Any:
|
|
62
|
+
envelope = self._context_builder("fs.readFile", args)
|
|
63
|
+
return await self._hooks.on_fs_read(envelope, original)
|
|
64
|
+
|
|
65
|
+
registry.set("fs.readFile", guarded)
|
|
66
|
+
|
|
67
|
+
def _wrap_http_request(self, registry: ToolRegistryProtocol) -> None:
|
|
68
|
+
original = registry.get("http.request")
|
|
69
|
+
|
|
70
|
+
async def guarded(args: dict[str, Any]) -> Any:
|
|
71
|
+
envelope = self._context_builder("http.request", args)
|
|
72
|
+
return await self._hooks.on_http_request(envelope, original)
|
|
73
|
+
|
|
74
|
+
registry.set("http.request", guarded)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Request/response contracts for sidecar authorization."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthorizationRequest(BaseModel):
|
|
9
|
+
principal: str
|
|
10
|
+
action: str
|
|
11
|
+
resource: str
|
|
12
|
+
intent_hash: str
|
|
13
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthorizationDecision(BaseModel):
|
|
17
|
+
allow: bool
|
|
18
|
+
reason: str | None = None
|
|
19
|
+
mandate_id: str | None = None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Thin OpenClaw hook interface for guarded tool execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Awaitable, Callable, Protocol
|
|
7
|
+
|
|
8
|
+
from .adapter import ToolAdapter
|
|
9
|
+
|
|
10
|
+
ToolExecutor = Callable[[dict[str, Any]], Awaitable[Any]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenClawHookContext(Protocol):
|
|
14
|
+
"""Protocol for hook context expected from OpenClaw runtime."""
|
|
15
|
+
|
|
16
|
+
session_id: str
|
|
17
|
+
source: str
|
|
18
|
+
tenant_id: str | None
|
|
19
|
+
user_id: str | None
|
|
20
|
+
trace_id: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class HookEnvelope:
|
|
25
|
+
"""Normalized hook payload for adapter consumption."""
|
|
26
|
+
|
|
27
|
+
tool_name: str
|
|
28
|
+
args: dict[str, Any]
|
|
29
|
+
session_id: str
|
|
30
|
+
source: str
|
|
31
|
+
tenant_id: str | None = None
|
|
32
|
+
user_id: str | None = None
|
|
33
|
+
trace_id: str | None = None
|
|
34
|
+
|
|
35
|
+
def context(self) -> dict[str, Any]:
|
|
36
|
+
"""Render policy context claims sent to Predicate."""
|
|
37
|
+
return {
|
|
38
|
+
"session_id": self.session_id,
|
|
39
|
+
"source": self.source,
|
|
40
|
+
"tenant_id": self.tenant_id,
|
|
41
|
+
"user_id": self.user_id,
|
|
42
|
+
"trace_id": self.trace_id,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OpenClawHooks:
|
|
47
|
+
"""Hook surface matching likely OpenClaw tool callback wiring."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, adapter: ToolAdapter):
|
|
50
|
+
self._adapter = adapter
|
|
51
|
+
|
|
52
|
+
async def on_cmd_run(self, envelope: HookEnvelope, execute: ToolExecutor) -> Any:
|
|
53
|
+
return await self._adapter.run_shell(
|
|
54
|
+
args=envelope.args,
|
|
55
|
+
context=envelope.context(),
|
|
56
|
+
execute=execute,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def on_fs_read(self, envelope: HookEnvelope, execute: ToolExecutor) -> Any:
|
|
60
|
+
return await self._adapter.read_file(
|
|
61
|
+
args=envelope.args,
|
|
62
|
+
context=envelope.context(),
|
|
63
|
+
execute=execute,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def on_http_request(
|
|
67
|
+
self,
|
|
68
|
+
envelope: HookEnvelope,
|
|
69
|
+
execute: ToolExecutor,
|
|
70
|
+
) -> Any:
|
|
71
|
+
return await self._adapter.http_request(
|
|
72
|
+
args=envelope.args,
|
|
73
|
+
context=envelope.context(),
|
|
74
|
+
execute=execute,
|
|
75
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Guard wrapper used to enforce Predicate checks around tool calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .config import ProviderConfig
|
|
10
|
+
from .errors import ActionDeniedError, SidecarUnavailableError
|
|
11
|
+
from .models import AuthorizationRequest
|
|
12
|
+
from .sidecar import SidecarClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GuardedProvider:
|
|
16
|
+
"""Entry point for OpenClaw tool-guard integration."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, principal: str, config: ProviderConfig | None = None):
|
|
19
|
+
self._principal = principal
|
|
20
|
+
self._config = config or ProviderConfig()
|
|
21
|
+
self._sidecar = SidecarClient(self._config)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def _intent_hash(args: dict[str, Any]) -> str:
|
|
25
|
+
encoded = json.dumps(args, separators=(",", ":"), sort_keys=True)
|
|
26
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
27
|
+
|
|
28
|
+
async def authorize(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
action: str,
|
|
32
|
+
resource: str,
|
|
33
|
+
args: dict[str, Any],
|
|
34
|
+
context: dict[str, Any],
|
|
35
|
+
) -> str | None:
|
|
36
|
+
"""Authorize a tool call and return optional mandate id."""
|
|
37
|
+
request = AuthorizationRequest(
|
|
38
|
+
principal=self._principal,
|
|
39
|
+
action=action,
|
|
40
|
+
resource=resource,
|
|
41
|
+
intent_hash=self._intent_hash(args),
|
|
42
|
+
context=context,
|
|
43
|
+
)
|
|
44
|
+
decision = await self._sidecar.authorize(request)
|
|
45
|
+
|
|
46
|
+
if decision.allow:
|
|
47
|
+
return decision.mandate_id
|
|
48
|
+
raise ActionDeniedError(decision.reason or "denied_by_policy")
|
|
49
|
+
|
|
50
|
+
async def guard_or_raise(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
action: str,
|
|
54
|
+
resource: str,
|
|
55
|
+
args: dict[str, Any],
|
|
56
|
+
context: dict[str, Any],
|
|
57
|
+
) -> str | None:
|
|
58
|
+
"""Fail-closed wrapper used by sensitive tool adapters."""
|
|
59
|
+
try:
|
|
60
|
+
return await self.authorize(
|
|
61
|
+
action=action,
|
|
62
|
+
resource=resource,
|
|
63
|
+
args=args,
|
|
64
|
+
context=context,
|
|
65
|
+
)
|
|
66
|
+
except SidecarUnavailableError:
|
|
67
|
+
if self._config.fail_closed:
|
|
68
|
+
raise
|
|
69
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""HTTP client wrapper for Predicate sidecar authorization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .agentidentity_backend import AgentIdentityLocalClient
|
|
8
|
+
from .config import ProviderConfig
|
|
9
|
+
from .errors import SidecarUnavailableError
|
|
10
|
+
from .models import AuthorizationDecision, AuthorizationRequest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SidecarClient:
|
|
14
|
+
"""Small client used by provider guard checks."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: ProviderConfig):
|
|
17
|
+
self._config = config
|
|
18
|
+
self._local_backend = (
|
|
19
|
+
AgentIdentityLocalClient(config)
|
|
20
|
+
if config.authorization_backend == "agentidentity_local"
|
|
21
|
+
else None
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
async def authorize(self, request: AuthorizationRequest) -> AuthorizationDecision:
|
|
25
|
+
if self._local_backend is not None:
|
|
26
|
+
return self._local_backend.authorize(request)
|
|
27
|
+
return await self._authorize_http(request)
|
|
28
|
+
|
|
29
|
+
async def _authorize_http(self, request: AuthorizationRequest) -> AuthorizationDecision:
|
|
30
|
+
timeout_s = self._config.request_timeout_ms / 1000.0
|
|
31
|
+
try:
|
|
32
|
+
async with httpx.AsyncClient(timeout=timeout_s) as client:
|
|
33
|
+
resp = await client.post(
|
|
34
|
+
self._config.sidecar_authorize_url,
|
|
35
|
+
json=request.model_dump(),
|
|
36
|
+
)
|
|
37
|
+
except httpx.HTTPError as exc:
|
|
38
|
+
raise SidecarUnavailableError("Predicate sidecar unavailable") from exc
|
|
39
|
+
|
|
40
|
+
if resp.status_code == 200:
|
|
41
|
+
body = resp.json() if resp.content else {}
|
|
42
|
+
return AuthorizationDecision(
|
|
43
|
+
allow=True,
|
|
44
|
+
reason=body.get("reason"),
|
|
45
|
+
mandate_id=body.get("mandate_id"),
|
|
46
|
+
)
|
|
47
|
+
if resp.status_code == 403:
|
|
48
|
+
body = resp.json()
|
|
49
|
+
return AuthorizationDecision(
|
|
50
|
+
allow=False,
|
|
51
|
+
reason=body.get("reason", "denied_by_policy"),
|
|
52
|
+
mandate_id=body.get("mandate_id"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if self._config.fail_closed:
|
|
56
|
+
raise SidecarUnavailableError(
|
|
57
|
+
f"Unexpected sidecar response status: {resp.status_code}"
|
|
58
|
+
)
|
|
59
|
+
return AuthorizationDecision(allow=True, reason="fail_open_override")
|