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,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,5 @@
1
+ """Integration adapters for external runtimes."""
2
+
3
+ from .openclaw_runtime import OpenClawRuntimeIntegrator
4
+
5
+ __all__ = ["OpenClawRuntimeIntegrator"]
@@ -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,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")