vizier-guard 0.3.0__tar.gz
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.
- vizier_guard-0.3.0/PKG-INFO +145 -0
- vizier_guard-0.3.0/README.md +112 -0
- vizier_guard-0.3.0/pyproject.toml +47 -0
- vizier_guard-0.3.0/setup.cfg +4 -0
- vizier_guard-0.3.0/src/vizier/__init__.py +43 -0
- vizier_guard-0.3.0/src/vizier/canonical.py +35 -0
- vizier_guard-0.3.0/src/vizier/client.py +153 -0
- vizier_guard-0.3.0/src/vizier/decorators.py +96 -0
- vizier_guard-0.3.0/src/vizier/integrations/__init__.py +4 -0
- vizier_guard-0.3.0/src/vizier/integrations/crewai.py +53 -0
- vizier_guard-0.3.0/src/vizier/integrations/langchain.py +77 -0
- vizier_guard-0.3.0/src/vizier/models.py +177 -0
- vizier_guard-0.3.0/src/vizier_guard.egg-info/PKG-INFO +145 -0
- vizier_guard-0.3.0/src/vizier_guard.egg-info/SOURCES.txt +18 -0
- vizier_guard-0.3.0/src/vizier_guard.egg-info/dependency_links.txt +1 -0
- vizier_guard-0.3.0/src/vizier_guard.egg-info/requires.txt +12 -0
- vizier_guard-0.3.0/src/vizier_guard.egg-info/top_level.txt +1 -0
- vizier_guard-0.3.0/tests/test_canonical.py +31 -0
- vizier_guard-0.3.0/tests/test_decorators.py +75 -0
- vizier_guard-0.3.0/tests/test_models.py +59 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vizier-guard
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Deterministic authorization and audit guard for AI agent actions
|
|
5
|
+
Author-email: Vassiliy Lakhonin <vassiliy.lakhonin@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/vassiliylakhonin/vizier
|
|
8
|
+
Project-URL: Documentation, https://vizier.vassiliy-lakhonin.workers.dev/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/vassiliylakhonin/vizier
|
|
10
|
+
Keywords: ai,agents,security,authorization,guardrails,langchain,mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Provides-Extra: pydantic
|
|
26
|
+
Requires-Dist: pydantic>=2.0; extra == "pydantic"
|
|
27
|
+
Provides-Extra: httpx
|
|
28
|
+
Requires-Dist: httpx>=0.24.0; extra == "httpx"
|
|
29
|
+
Provides-Extra: langchain
|
|
30
|
+
Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
33
|
+
|
|
34
|
+
# Vizier Guard (Python SDK)
|
|
35
|
+
|
|
36
|
+
Deterministic Authorization & Audit Firewall for AI Agent Actions.
|
|
37
|
+
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](pyproject.toml)
|
|
40
|
+
|
|
41
|
+
Vizier is an ultra-low-latency (sub-2ms) deterministic policy kernel that evaluates proposed AI agent actions before they cause external side-effects (payments, database mutations, external messages, code execution, worker deployments).
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install vizier-guard
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Zero external dependencies required out of the box (uses Python standard library).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 30-Second Quickstart: `@vizier_guard` Decorator
|
|
56
|
+
|
|
57
|
+
Wrap any dangerous tool or Python function with deterministic policy rules:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from vizier import VizierClient, vizier_guard
|
|
61
|
+
|
|
62
|
+
client = VizierClient(
|
|
63
|
+
base_url="https://vizier.vassiliy-lakhonin.workers.dev",
|
|
64
|
+
api_key="your-vizier-api-key"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@vizier_guard(
|
|
68
|
+
client=client,
|
|
69
|
+
action_type="purchase",
|
|
70
|
+
max_amount=500.0,
|
|
71
|
+
currency="USD",
|
|
72
|
+
allowed_targets=["approved-hotel.com", "supplier-corp.com"]
|
|
73
|
+
)
|
|
74
|
+
def book_hotel(amount: float, target: str):
|
|
75
|
+
# This only runs if Vizier returns ALLOW
|
|
76
|
+
print(f"Booking confirmed at {target} for ${amount}")
|
|
77
|
+
return {"status": "booked", "amount": amount}
|
|
78
|
+
|
|
79
|
+
# Allowed: $350 <= $500 to approved target
|
|
80
|
+
book_hotel(amount=350.0, target="approved-hotel.com")
|
|
81
|
+
|
|
82
|
+
# Blocked: $1200 > $500 (raises ActionBlockedError)
|
|
83
|
+
book_hotel(amount=1200.0, target="approved-hotel.com")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## LangChain Integration
|
|
89
|
+
|
|
90
|
+
Protect any LangChain `BaseTool` from agent hallucinations or runaway spending:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from vizier import VizierClient
|
|
94
|
+
from vizier.integrations.langchain import VizierLangChainToolGuard
|
|
95
|
+
from langchain_community.tools import DuckDuckGoSearchRun
|
|
96
|
+
|
|
97
|
+
client = VizierClient(api_key="...")
|
|
98
|
+
|
|
99
|
+
# Wrap your tool
|
|
100
|
+
safe_search = VizierLangChainToolGuard(
|
|
101
|
+
tool=DuckDuckGoSearchRun(),
|
|
102
|
+
client=client,
|
|
103
|
+
allowed_actions=["search"],
|
|
104
|
+
max_amount=0.0
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Pass safe_search into your LangChain or LangGraph agent
|
|
108
|
+
agent = create_react_agent(llm, tools=[safe_search])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## CrewAI Integration
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from vizier.integrations.crewai import VizierCrewAIToolGuard
|
|
117
|
+
|
|
118
|
+
guarded_tool = VizierCrewAIToolGuard(
|
|
119
|
+
tool=my_dangerous_payment_tool,
|
|
120
|
+
max_amount=250.0,
|
|
121
|
+
agent_id="finance_agent"
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Standalone Verification
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from vizier import VizierClient
|
|
131
|
+
|
|
132
|
+
vizier = VizierClient(api_key="...")
|
|
133
|
+
|
|
134
|
+
decision = vizier.check(
|
|
135
|
+
action_type="deploy_worker",
|
|
136
|
+
target="worker:payment-service",
|
|
137
|
+
parameters={"git_commit": "abcdef123..."},
|
|
138
|
+
allowed_actions=["deploy_worker"]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if decision.is_allowed:
|
|
142
|
+
print(f"Action permitted! Receipt ID: {decision.receipt.id}")
|
|
143
|
+
else:
|
|
144
|
+
print(f"Action rejected ({decision.decision}): {decision.explanation}")
|
|
145
|
+
```
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Vizier Guard (Python SDK)
|
|
2
|
+
|
|
3
|
+
Deterministic Authorization & Audit Firewall for AI Agent Actions.
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](pyproject.toml)
|
|
7
|
+
|
|
8
|
+
Vizier is an ultra-low-latency (sub-2ms) deterministic policy kernel that evaluates proposed AI agent actions before they cause external side-effects (payments, database mutations, external messages, code execution, worker deployments).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install vizier-guard
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Zero external dependencies required out of the box (uses Python standard library).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 30-Second Quickstart: `@vizier_guard` Decorator
|
|
23
|
+
|
|
24
|
+
Wrap any dangerous tool or Python function with deterministic policy rules:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from vizier import VizierClient, vizier_guard
|
|
28
|
+
|
|
29
|
+
client = VizierClient(
|
|
30
|
+
base_url="https://vizier.vassiliy-lakhonin.workers.dev",
|
|
31
|
+
api_key="your-vizier-api-key"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@vizier_guard(
|
|
35
|
+
client=client,
|
|
36
|
+
action_type="purchase",
|
|
37
|
+
max_amount=500.0,
|
|
38
|
+
currency="USD",
|
|
39
|
+
allowed_targets=["approved-hotel.com", "supplier-corp.com"]
|
|
40
|
+
)
|
|
41
|
+
def book_hotel(amount: float, target: str):
|
|
42
|
+
# This only runs if Vizier returns ALLOW
|
|
43
|
+
print(f"Booking confirmed at {target} for ${amount}")
|
|
44
|
+
return {"status": "booked", "amount": amount}
|
|
45
|
+
|
|
46
|
+
# Allowed: $350 <= $500 to approved target
|
|
47
|
+
book_hotel(amount=350.0, target="approved-hotel.com")
|
|
48
|
+
|
|
49
|
+
# Blocked: $1200 > $500 (raises ActionBlockedError)
|
|
50
|
+
book_hotel(amount=1200.0, target="approved-hotel.com")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## LangChain Integration
|
|
56
|
+
|
|
57
|
+
Protect any LangChain `BaseTool` from agent hallucinations or runaway spending:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from vizier import VizierClient
|
|
61
|
+
from vizier.integrations.langchain import VizierLangChainToolGuard
|
|
62
|
+
from langchain_community.tools import DuckDuckGoSearchRun
|
|
63
|
+
|
|
64
|
+
client = VizierClient(api_key="...")
|
|
65
|
+
|
|
66
|
+
# Wrap your tool
|
|
67
|
+
safe_search = VizierLangChainToolGuard(
|
|
68
|
+
tool=DuckDuckGoSearchRun(),
|
|
69
|
+
client=client,
|
|
70
|
+
allowed_actions=["search"],
|
|
71
|
+
max_amount=0.0
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Pass safe_search into your LangChain or LangGraph agent
|
|
75
|
+
agent = create_react_agent(llm, tools=[safe_search])
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## CrewAI Integration
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from vizier.integrations.crewai import VizierCrewAIToolGuard
|
|
84
|
+
|
|
85
|
+
guarded_tool = VizierCrewAIToolGuard(
|
|
86
|
+
tool=my_dangerous_payment_tool,
|
|
87
|
+
max_amount=250.0,
|
|
88
|
+
agent_id="finance_agent"
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Standalone Verification
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from vizier import VizierClient
|
|
98
|
+
|
|
99
|
+
vizier = VizierClient(api_key="...")
|
|
100
|
+
|
|
101
|
+
decision = vizier.check(
|
|
102
|
+
action_type="deploy_worker",
|
|
103
|
+
target="worker:payment-service",
|
|
104
|
+
parameters={"git_commit": "abcdef123..."},
|
|
105
|
+
allowed_actions=["deploy_worker"]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if decision.is_allowed:
|
|
109
|
+
print(f"Action permitted! Receipt ID: {decision.receipt.id}")
|
|
110
|
+
else:
|
|
111
|
+
print(f"Action rejected ({decision.decision}): {decision.explanation}")
|
|
112
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vizier-guard"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Deterministic authorization and audit guard for AI agent actions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Vassiliy Lakhonin", email = "vassiliy.lakhonin@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
28
|
+
]
|
|
29
|
+
keywords = ["ai", "agents", "security", "authorization", "guardrails", "langchain", "mcp"]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
pydantic = ["pydantic>=2.0"]
|
|
34
|
+
httpx = ["httpx>=0.24.0"]
|
|
35
|
+
langchain = ["langchain-core>=0.1.0"]
|
|
36
|
+
test = [
|
|
37
|
+
"pytest>=7.0.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/vassiliylakhonin/vizier"
|
|
42
|
+
Documentation = "https://vizier.vassiliy-lakhonin.workers.dev/docs"
|
|
43
|
+
Repository = "https://github.com/vassiliylakhonin/vizier"
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
pythonpath = ["src"]
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vizier: Deterministic Authorization & Audit Kernel for AI Agents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .canonical import canonicalize, sha256_canonical_json
|
|
6
|
+
from .client import VizierClient, VizierError
|
|
7
|
+
from .decorators import vizier_guard, ActionBlockedError
|
|
8
|
+
from .models import (
|
|
9
|
+
Action,
|
|
10
|
+
Agent,
|
|
11
|
+
Authority,
|
|
12
|
+
AuthorityConstraints,
|
|
13
|
+
Context,
|
|
14
|
+
Decision,
|
|
15
|
+
PolicyResult,
|
|
16
|
+
Principal,
|
|
17
|
+
Receipt,
|
|
18
|
+
ReceiptGrant,
|
|
19
|
+
VerificationRequest,
|
|
20
|
+
VerificationResponse,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.3.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"VizierClient",
|
|
26
|
+
"VizierError",
|
|
27
|
+
"vizier_guard",
|
|
28
|
+
"ActionBlockedError",
|
|
29
|
+
"VerificationRequest",
|
|
30
|
+
"VerificationResponse",
|
|
31
|
+
"Decision",
|
|
32
|
+
"Agent",
|
|
33
|
+
"Principal",
|
|
34
|
+
"Action",
|
|
35
|
+
"Authority",
|
|
36
|
+
"AuthorityConstraints",
|
|
37
|
+
"Context",
|
|
38
|
+
"PolicyResult",
|
|
39
|
+
"Receipt",
|
|
40
|
+
"ReceiptGrant",
|
|
41
|
+
"canonicalize",
|
|
42
|
+
"sha256_canonical_json",
|
|
43
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
def canonicalize(value: Any) -> str:
|
|
6
|
+
"""
|
|
7
|
+
RFC 8785-like deterministic JSON canonicalization matching Vizier TS kernel:
|
|
8
|
+
- Booleans, strings, nulls, and numbers are serialized cleanly
|
|
9
|
+
- Float values with integral value format without trailing .0 (matching ECMAScript JSON.stringify)
|
|
10
|
+
- Arrays preserve order with canonical elements
|
|
11
|
+
- Dictionaries sort keys lexicographically
|
|
12
|
+
"""
|
|
13
|
+
if value is None:
|
|
14
|
+
return "null"
|
|
15
|
+
if isinstance(value, bool):
|
|
16
|
+
return "true" if value else "false"
|
|
17
|
+
if isinstance(value, (int, float)):
|
|
18
|
+
# ECMAScript JSON.stringify formats integral numbers without .0
|
|
19
|
+
if isinstance(value, float) and value.is_integer():
|
|
20
|
+
return str(int(value))
|
|
21
|
+
return json.dumps(value, separators=(",", ":"))
|
|
22
|
+
if isinstance(value, str):
|
|
23
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
24
|
+
if isinstance(value, (list, tuple)):
|
|
25
|
+
return "[" + ",".join(canonicalize(item) for item in value) + "]"
|
|
26
|
+
if isinstance(value, dict):
|
|
27
|
+
sorted_keys = sorted(value.keys())
|
|
28
|
+
items = [f"{json.dumps(k, ensure_ascii=False)}:{canonicalize(value[k])}" for k in sorted_keys]
|
|
29
|
+
return "{" + ",".join(items) + "}"
|
|
30
|
+
raise TypeError(f"Value of type {type(value)} is not canonicalizable JSON.")
|
|
31
|
+
|
|
32
|
+
def sha256_canonical_json(value: Any) -> str:
|
|
33
|
+
"""Compute SHA-256 hex digest of canonicalized JSON."""
|
|
34
|
+
raw = canonicalize(value).encode("utf-8")
|
|
35
|
+
return hashlib.sha256(raw).hexdigest()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import urllib.request
|
|
4
|
+
import urllib.error
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
Action,
|
|
9
|
+
Agent,
|
|
10
|
+
Authority,
|
|
11
|
+
AuthorityConstraints,
|
|
12
|
+
Context,
|
|
13
|
+
Principal,
|
|
14
|
+
VerificationRequest,
|
|
15
|
+
VerificationResponse,
|
|
16
|
+
)
|
|
17
|
+
from .canonical import sha256_canonical_json
|
|
18
|
+
|
|
19
|
+
class VizierError(Exception):
|
|
20
|
+
def __init__(self, message: str, status: int = 0, code: str = "ERROR", details: Any = None):
|
|
21
|
+
super().__init__(f"[{code}] {message} (HTTP {status})")
|
|
22
|
+
self.message = message
|
|
23
|
+
self.status = status
|
|
24
|
+
self.code = code
|
|
25
|
+
self.details = details
|
|
26
|
+
|
|
27
|
+
class VizierClient:
|
|
28
|
+
"""
|
|
29
|
+
Official Vizier Authorization & Governance Client for AI Agents.
|
|
30
|
+
Zero external dependencies required (uses Python standard library).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str = "https://vizier.vassiliy-lakhonin.workers.dev",
|
|
36
|
+
api_key: Optional[str] = None,
|
|
37
|
+
timeout: float = 5.0,
|
|
38
|
+
):
|
|
39
|
+
self.base_url = base_url.rstrip("/")
|
|
40
|
+
self.api_key = api_key
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
|
|
43
|
+
def _request(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
44
|
+
url = f"{self.base_url}{path}"
|
|
45
|
+
headers = {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"User-Agent": "vizier-guard-python/0.3.0",
|
|
48
|
+
}
|
|
49
|
+
if self.api_key:
|
|
50
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
51
|
+
|
|
52
|
+
payload_bytes = json.dumps(data).encode("utf-8")
|
|
53
|
+
req = urllib.request.Request(url, data=payload_bytes, headers=headers, method="POST")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
57
|
+
body = response.read().decode("utf-8")
|
|
58
|
+
return json.loads(body)
|
|
59
|
+
except urllib.error.HTTPError as err:
|
|
60
|
+
try:
|
|
61
|
+
body = err.read().decode("utf-8")
|
|
62
|
+
err_data = json.loads(body)
|
|
63
|
+
err_info = err_data.get("error", {})
|
|
64
|
+
msg = err_info.get("message", "Request failed")
|
|
65
|
+
code = err_info.get("code", "REQUEST_FAILED")
|
|
66
|
+
details = err_info.get("details")
|
|
67
|
+
except Exception:
|
|
68
|
+
msg = err.reason
|
|
69
|
+
code = "HTTP_ERROR"
|
|
70
|
+
details = None
|
|
71
|
+
raise VizierError(message=msg, status=err.code, code=code, details=details) from err
|
|
72
|
+
except urllib.error.URLError as err:
|
|
73
|
+
raise VizierError(message=str(err.reason), status=0, code="CONNECTION_FAILED") from err
|
|
74
|
+
except Exception as err:
|
|
75
|
+
raise VizierError(message=str(err), status=0, code="UNKNOWN_ERROR") from err
|
|
76
|
+
|
|
77
|
+
def verify(
|
|
78
|
+
self,
|
|
79
|
+
request: Union[VerificationRequest, Dict[str, Any]],
|
|
80
|
+
verify_receipt_hash: bool = True,
|
|
81
|
+
) -> VerificationResponse:
|
|
82
|
+
"""
|
|
83
|
+
Submit a proposed agent action to Vizier for deterministic authorization.
|
|
84
|
+
Returns VerificationResponse with decision: ALLOW, REVIEW, or BLOCK.
|
|
85
|
+
"""
|
|
86
|
+
payload: Dict[str, Any]
|
|
87
|
+
if isinstance(request, VerificationRequest):
|
|
88
|
+
payload = request.to_dict()
|
|
89
|
+
elif isinstance(request, dict):
|
|
90
|
+
payload = request
|
|
91
|
+
else:
|
|
92
|
+
raise TypeError("request must be VerificationRequest or dict")
|
|
93
|
+
|
|
94
|
+
# Force source="rest" for client calls
|
|
95
|
+
if "context" not in payload or payload["context"] is None:
|
|
96
|
+
payload["context"] = {"source": "rest"}
|
|
97
|
+
else:
|
|
98
|
+
payload["context"]["source"] = "rest"
|
|
99
|
+
|
|
100
|
+
raw_response = self._request("/v1/verify", payload)
|
|
101
|
+
resp = VerificationResponse.from_dict(raw_response)
|
|
102
|
+
|
|
103
|
+
# Non-repudiation integrity check
|
|
104
|
+
if verify_receipt_hash:
|
|
105
|
+
expected_hash = sha256_canonical_json(payload)
|
|
106
|
+
if resp.receipt.request_hash != expected_hash:
|
|
107
|
+
raise VizierError(
|
|
108
|
+
message=f"Receipt hash mismatch: expected {expected_hash}, got {resp.receipt.request_hash}",
|
|
109
|
+
status=200,
|
|
110
|
+
code="HASH_MISMATCH",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return resp
|
|
114
|
+
|
|
115
|
+
def check(
|
|
116
|
+
self,
|
|
117
|
+
action_type: str,
|
|
118
|
+
target: str,
|
|
119
|
+
parameters: Optional[Dict[str, Any]] = None,
|
|
120
|
+
allowed_actions: Optional[List[str]] = None,
|
|
121
|
+
max_amount: Optional[float] = None,
|
|
122
|
+
currency: Optional[str] = None,
|
|
123
|
+
allowed_targets: Optional[List[str]] = None,
|
|
124
|
+
blocked_targets: Optional[List[str]] = None,
|
|
125
|
+
agent_id: str = "agent",
|
|
126
|
+
principal_id: str = "principal",
|
|
127
|
+
is_reversible: Optional[bool] = None,
|
|
128
|
+
grant: Optional[str] = None,
|
|
129
|
+
) -> VerificationResponse:
|
|
130
|
+
"""
|
|
131
|
+
Convenience method: verify an action in a single line of code.
|
|
132
|
+
"""
|
|
133
|
+
req = VerificationRequest(
|
|
134
|
+
agent=Agent(id=agent_id),
|
|
135
|
+
principal=Principal(id=principal_id),
|
|
136
|
+
action=Action(
|
|
137
|
+
type=action_type,
|
|
138
|
+
target=target,
|
|
139
|
+
parameters=parameters or {},
|
|
140
|
+
is_reversible=is_reversible,
|
|
141
|
+
),
|
|
142
|
+
authority=Authority(
|
|
143
|
+
allowed_actions=allowed_actions or [action_type],
|
|
144
|
+
constraints=AuthorityConstraints(
|
|
145
|
+
max_amount=max_amount,
|
|
146
|
+
currency=currency,
|
|
147
|
+
allowed_targets=allowed_targets,
|
|
148
|
+
blocked_targets=blocked_targets,
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
grant=grant,
|
|
152
|
+
)
|
|
153
|
+
return self.verify(req)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import functools
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
from .client import VizierClient
|
|
7
|
+
from .models import VerificationResponse
|
|
8
|
+
|
|
9
|
+
class ActionBlockedError(Exception):
|
|
10
|
+
def __init__(self, response: VerificationResponse):
|
|
11
|
+
super().__init__(
|
|
12
|
+
f"Action blocked by Vizier ({response.decision}): {response.explanation}. Reason codes: {response.reason_codes}"
|
|
13
|
+
)
|
|
14
|
+
self.response = response
|
|
15
|
+
|
|
16
|
+
def vizier_guard(
|
|
17
|
+
client: Optional[VizierClient] = None,
|
|
18
|
+
action_type: Optional[str] = None,
|
|
19
|
+
target: Optional[Union[str, Callable[..., str]]] = None,
|
|
20
|
+
allowed_actions: Optional[List[str]] = None,
|
|
21
|
+
max_amount: Optional[Union[float, Callable[..., Optional[float]]]] = None,
|
|
22
|
+
currency: Optional[str] = None,
|
|
23
|
+
allowed_targets: Optional[List[str]] = None,
|
|
24
|
+
blocked_targets: Optional[List[str]] = None,
|
|
25
|
+
agent_id: str = "agent",
|
|
26
|
+
principal_id: str = "principal",
|
|
27
|
+
on_block: str = "raise", # "raise" or "callback"
|
|
28
|
+
on_block_callback: Optional[Callable[[VerificationResponse], Any]] = None,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Decorator to protect any Python function / agent tool with Vizier deterministic authorization.
|
|
32
|
+
Before the function executes, Vizier checks the proposed action, parameters, and constraints.
|
|
33
|
+
If ALLOW, the function executes.
|
|
34
|
+
If BLOCK or REVIEW, execution is halted immediately.
|
|
35
|
+
"""
|
|
36
|
+
viz_client = client or VizierClient()
|
|
37
|
+
|
|
38
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
39
|
+
act_type = action_type or fn.__name__
|
|
40
|
+
|
|
41
|
+
@functools.wraps(fn)
|
|
42
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
43
|
+
# Bind arguments to function signature
|
|
44
|
+
sig = inspect.signature(fn)
|
|
45
|
+
bound = sig.bind(*args, **kwargs)
|
|
46
|
+
bound.apply_defaults()
|
|
47
|
+
params = dict(bound.arguments)
|
|
48
|
+
|
|
49
|
+
# Resolve target
|
|
50
|
+
if callable(target):
|
|
51
|
+
resolved_target = target(*args, **kwargs)
|
|
52
|
+
elif isinstance(target, str):
|
|
53
|
+
resolved_target = target
|
|
54
|
+
else:
|
|
55
|
+
resolved_target = params.get("target") or params.get("destination") or "default_target"
|
|
56
|
+
|
|
57
|
+
# Resolve max amount if dynamic
|
|
58
|
+
resolved_max_amount: Optional[float]
|
|
59
|
+
if callable(max_amount):
|
|
60
|
+
resolved_max_amount = max_amount(*args, **kwargs)
|
|
61
|
+
else:
|
|
62
|
+
resolved_max_amount = max_amount
|
|
63
|
+
|
|
64
|
+
# Check if amount and currency are inside parameters
|
|
65
|
+
amount = params.get("amount") or params.get("price")
|
|
66
|
+
curr = params.get("currency") or currency
|
|
67
|
+
if curr and "currency" not in params:
|
|
68
|
+
params["currency"] = curr
|
|
69
|
+
|
|
70
|
+
verification = viz_client.check(
|
|
71
|
+
action_type=act_type,
|
|
72
|
+
target=str(resolved_target),
|
|
73
|
+
parameters=params,
|
|
74
|
+
allowed_actions=allowed_actions or [act_type],
|
|
75
|
+
max_amount=resolved_max_amount,
|
|
76
|
+
currency=curr,
|
|
77
|
+
allowed_targets=allowed_targets,
|
|
78
|
+
blocked_targets=blocked_targets,
|
|
79
|
+
agent_id=agent_id,
|
|
80
|
+
principal_id=principal_id,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if not verification.is_allowed:
|
|
84
|
+
if on_block == "callback" and on_block_callback:
|
|
85
|
+
return on_block_callback(verification)
|
|
86
|
+
raise ActionBlockedError(verification)
|
|
87
|
+
|
|
88
|
+
# Store the receipt in a context attribute if possible
|
|
89
|
+
result = fn(*args, **kwargs)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
# Attach reference to verification check on the wrapper
|
|
93
|
+
wrapper.__vizier_client__ = viz_client # type: ignore
|
|
94
|
+
return wrapper
|
|
95
|
+
|
|
96
|
+
return decorator
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from ..client import VizierClient
|
|
5
|
+
from ..models import VerificationResponse
|
|
6
|
+
|
|
7
|
+
class VizierCrewAIToolGuard:
|
|
8
|
+
"""
|
|
9
|
+
CrewAI Tool wrapper. Protects agents in CrewAI from unauthorized or out-of-budget actions.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
tool: Any,
|
|
15
|
+
client: Optional[VizierClient] = None,
|
|
16
|
+
max_amount: Optional[float] = None,
|
|
17
|
+
allowed_targets: Optional[List[str]] = None,
|
|
18
|
+
agent_id: str = "crewai_agent",
|
|
19
|
+
principal_id: str = "principal",
|
|
20
|
+
):
|
|
21
|
+
self.tool = tool
|
|
22
|
+
self.client = client or VizierClient()
|
|
23
|
+
self.max_amount = max_amount
|
|
24
|
+
self.allowed_targets = allowed_targets
|
|
25
|
+
self.agent_id = agent_id
|
|
26
|
+
self.principal_id = principal_id
|
|
27
|
+
|
|
28
|
+
self.name = getattr(tool, "name", "guarded_tool")
|
|
29
|
+
self.description = getattr(tool, "description", "")
|
|
30
|
+
|
|
31
|
+
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
|
32
|
+
params: Dict[str, Any] = kwargs if kwargs else (args[0] if args and isinstance(args[0], dict) else {"input": args})
|
|
33
|
+
target = str(params.get("target") or self.name)
|
|
34
|
+
|
|
35
|
+
res = self.client.check(
|
|
36
|
+
action_type=self.name,
|
|
37
|
+
target=target,
|
|
38
|
+
parameters=params,
|
|
39
|
+
allowed_actions=[self.name],
|
|
40
|
+
max_amount=self.max_amount,
|
|
41
|
+
allowed_targets=self.allowed_targets,
|
|
42
|
+
agent_id=self.agent_id,
|
|
43
|
+
principal_id=self.principal_id,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if not res.is_allowed:
|
|
47
|
+
return f"Action blocked by Vizier ({res.decision}): {res.explanation}. Reason codes: {res.reason_codes}"
|
|
48
|
+
|
|
49
|
+
if hasattr(self.tool, "_run"):
|
|
50
|
+
return self.tool._run(*args, **kwargs)
|
|
51
|
+
if callable(self.tool):
|
|
52
|
+
return self.tool(*args, **kwargs)
|
|
53
|
+
return "Tool executed"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from ..client import VizierClient
|
|
5
|
+
from ..decorators import ActionBlockedError
|
|
6
|
+
from ..models import VerificationResponse
|
|
7
|
+
|
|
8
|
+
class VizierLangChainToolGuard:
|
|
9
|
+
"""
|
|
10
|
+
Wrapper for LangChain BaseTool instances.
|
|
11
|
+
Intercepts tool calls, verifies with Vizier, and blocks execution on BLOCK or REVIEW.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
tool: Any,
|
|
17
|
+
client: Optional[VizierClient] = None,
|
|
18
|
+
allowed_actions: Optional[List[str]] = None,
|
|
19
|
+
max_amount: Optional[float] = None,
|
|
20
|
+
currency: Optional[str] = None,
|
|
21
|
+
allowed_targets: Optional[List[str]] = None,
|
|
22
|
+
blocked_targets: Optional[List[str]] = None,
|
|
23
|
+
agent_id: str = "langchain_agent",
|
|
24
|
+
principal_id: str = "principal",
|
|
25
|
+
):
|
|
26
|
+
self.tool = tool
|
|
27
|
+
self.client = client or VizierClient()
|
|
28
|
+
self.allowed_actions = allowed_actions or [getattr(tool, "name", "tool")]
|
|
29
|
+
self.max_amount = max_amount
|
|
30
|
+
self.currency = currency
|
|
31
|
+
self.allowed_targets = allowed_targets
|
|
32
|
+
self.blocked_targets = blocked_targets
|
|
33
|
+
self.agent_id = agent_id
|
|
34
|
+
self.principal_id = principal_id
|
|
35
|
+
|
|
36
|
+
# Preserve LangChain tool metadata
|
|
37
|
+
self.name = getattr(tool, "name", "guarded_tool")
|
|
38
|
+
self.description = getattr(tool, "description", "")
|
|
39
|
+
self.args_schema = getattr(tool, "args_schema", None)
|
|
40
|
+
|
|
41
|
+
def _verify(self, tool_input: Any) -> VerificationResponse:
|
|
42
|
+
params: Dict[str, Any]
|
|
43
|
+
if isinstance(tool_input, dict):
|
|
44
|
+
params = tool_input
|
|
45
|
+
else:
|
|
46
|
+
params = {"input": tool_input}
|
|
47
|
+
|
|
48
|
+
target = str(params.get("target") or params.get("destination") or self.name)
|
|
49
|
+
return self.client.check(
|
|
50
|
+
action_type=self.name,
|
|
51
|
+
target=target,
|
|
52
|
+
parameters=params,
|
|
53
|
+
allowed_actions=self.allowed_actions,
|
|
54
|
+
max_amount=self.max_amount,
|
|
55
|
+
currency=self.currency,
|
|
56
|
+
allowed_targets=self.allowed_targets,
|
|
57
|
+
blocked_targets=self.blocked_targets,
|
|
58
|
+
agent_id=self.agent_id,
|
|
59
|
+
principal_id=self.principal_id,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def run(self, *args: Any, **kwargs: Any) -> Any:
|
|
63
|
+
tool_input = kwargs if kwargs else (args[0] if args else {})
|
|
64
|
+
res = self._verify(tool_input)
|
|
65
|
+
if not res.is_allowed:
|
|
66
|
+
return f"Error: Tool execution blocked by security policy ({res.decision}): {res.explanation}"
|
|
67
|
+
return self.tool.run(*args, **kwargs)
|
|
68
|
+
|
|
69
|
+
async def arun(self, *args: Any, **kwargs: Any) -> Any:
|
|
70
|
+
tool_input = kwargs if kwargs else (args[0] if args else {})
|
|
71
|
+
res = self._verify(tool_input)
|
|
72
|
+
if not res.is_allowed:
|
|
73
|
+
return f"Error: Tool execution blocked by security policy ({res.decision}): {res.explanation}"
|
|
74
|
+
return await self.tool.arun(*args, **kwargs)
|
|
75
|
+
|
|
76
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
77
|
+
return self.run(*args, **kwargs)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field, asdict
|
|
3
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
4
|
+
|
|
5
|
+
Decision = Literal["ALLOW", "REVIEW", "BLOCK"]
|
|
6
|
+
Source = Literal["a2a", "mcp", "rest", "internal", "unknown"]
|
|
7
|
+
AuthorityProvenance = Literal["principal_signed", "trusted_integration", "unverified"]
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Agent:
|
|
11
|
+
id: str
|
|
12
|
+
owner: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Principal:
|
|
16
|
+
id: str
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Action:
|
|
20
|
+
type: str
|
|
21
|
+
target: str
|
|
22
|
+
parameters: Dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
is_reversible: Optional[bool] = None
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AuthorityConstraints:
|
|
27
|
+
max_amount: Optional[float] = None
|
|
28
|
+
currency: Optional[str] = None
|
|
29
|
+
allowed_targets: Optional[List[str]] = None
|
|
30
|
+
blocked_targets: Optional[List[str]] = None
|
|
31
|
+
allowed_sensitive_actions: Optional[List[str]] = None
|
|
32
|
+
require_review_for_irreversible: Optional[bool] = None
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Authority:
|
|
36
|
+
allowed_actions: List[str] = field(default_factory=list)
|
|
37
|
+
constraints: AuthorityConstraints = field(default_factory=AuthorityConstraints)
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Context:
|
|
41
|
+
request_id: Optional[str] = None
|
|
42
|
+
timestamp: Optional[str] = None
|
|
43
|
+
source: Source = "rest"
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class VerificationRequest:
|
|
47
|
+
agent: Agent
|
|
48
|
+
principal: Optional[Principal]
|
|
49
|
+
action: Action
|
|
50
|
+
authority: Authority
|
|
51
|
+
context: Context = field(default_factory=Context)
|
|
52
|
+
grant: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
55
|
+
d: Dict[str, Any] = {
|
|
56
|
+
"agent": {"id": self.agent.id, "owner": self.agent.owner},
|
|
57
|
+
"principal": {"id": self.principal.id} if self.principal else None,
|
|
58
|
+
"action": {
|
|
59
|
+
"type": self.action.type,
|
|
60
|
+
"target": self.action.target,
|
|
61
|
+
"parameters": self.action.parameters,
|
|
62
|
+
},
|
|
63
|
+
"authority": {
|
|
64
|
+
"allowed_actions": self.authority.allowed_actions,
|
|
65
|
+
"constraints": {},
|
|
66
|
+
},
|
|
67
|
+
"context": {
|
|
68
|
+
"request_id": self.context.request_id,
|
|
69
|
+
"timestamp": self.context.timestamp,
|
|
70
|
+
"source": self.context.source,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
if self.action.is_reversible is not None:
|
|
74
|
+
d["action"]["is_reversible"] = self.action.is_reversible
|
|
75
|
+
|
|
76
|
+
c = self.authority.constraints
|
|
77
|
+
cd = d["authority"]["constraints"]
|
|
78
|
+
if c.max_amount is not None:
|
|
79
|
+
cd["max_amount"] = c.max_amount
|
|
80
|
+
if c.currency is not None:
|
|
81
|
+
cd["currency"] = c.currency
|
|
82
|
+
if c.allowed_targets is not None:
|
|
83
|
+
cd["allowed_targets"] = c.allowed_targets
|
|
84
|
+
if c.blocked_targets is not None:
|
|
85
|
+
cd["blocked_targets"] = c.blocked_targets
|
|
86
|
+
if c.allowed_sensitive_actions is not None:
|
|
87
|
+
cd["allowed_sensitive_actions"] = c.allowed_sensitive_actions
|
|
88
|
+
if c.require_review_for_irreversible is not None:
|
|
89
|
+
cd["require_review_for_irreversible"] = c.require_review_for_irreversible
|
|
90
|
+
|
|
91
|
+
if self.grant is not None:
|
|
92
|
+
d["grant"] = self.grant
|
|
93
|
+
return d
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class PolicyResult:
|
|
97
|
+
rule_id: str
|
|
98
|
+
result: Literal["PASS", "REVIEW", "FAIL"]
|
|
99
|
+
reason_code: Optional[str]
|
|
100
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class ReceiptGrant:
|
|
104
|
+
jti: str
|
|
105
|
+
issuer: str
|
|
106
|
+
subject: str
|
|
107
|
+
key_id: str
|
|
108
|
+
expires_at: str
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Receipt:
|
|
112
|
+
id: str
|
|
113
|
+
created_at: str
|
|
114
|
+
request_hash: str
|
|
115
|
+
decision: Decision
|
|
116
|
+
risk_score: float
|
|
117
|
+
policy_rule_ids: List[str]
|
|
118
|
+
reason_codes: List[str]
|
|
119
|
+
authority_provenance: AuthorityProvenance
|
|
120
|
+
grant: Optional[ReceiptGrant] = None
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class VerificationResponse:
|
|
124
|
+
decision: Decision
|
|
125
|
+
risk_score: float
|
|
126
|
+
reason_codes: List[str]
|
|
127
|
+
explanation: str
|
|
128
|
+
policy_results: List[PolicyResult]
|
|
129
|
+
receipt: Receipt
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def is_allowed(self) -> bool:
|
|
133
|
+
return self.decision == "ALLOW"
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def is_blocked(self) -> bool:
|
|
137
|
+
return self.decision == "BLOCK"
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def requires_review(self) -> bool:
|
|
141
|
+
return self.decision == "REVIEW"
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_dict(cls, data: Dict[str, Any]) -> VerificationResponse:
|
|
145
|
+
results = [
|
|
146
|
+
PolicyResult(
|
|
147
|
+
rule_id=r["rule_id"],
|
|
148
|
+
result=r["result"],
|
|
149
|
+
reason_code=r.get("reason_code"),
|
|
150
|
+
details=r.get("details", {}),
|
|
151
|
+
)
|
|
152
|
+
for r in data.get("policy_results", [])
|
|
153
|
+
]
|
|
154
|
+
rc = data["receipt"]
|
|
155
|
+
grant_data = rc.get("grant")
|
|
156
|
+
grant = ReceiptGrant(**grant_data) if grant_data else None
|
|
157
|
+
|
|
158
|
+
receipt = Receipt(
|
|
159
|
+
id=rc["id"],
|
|
160
|
+
created_at=rc["created_at"],
|
|
161
|
+
request_hash=rc["request_hash"],
|
|
162
|
+
decision=rc["decision"],
|
|
163
|
+
risk_score=float(rc["risk_score"]),
|
|
164
|
+
policy_rule_ids=list(rc.get("policy_rule_ids", [])),
|
|
165
|
+
reason_codes=list(rc.get("reason_codes", [])),
|
|
166
|
+
authority_provenance=rc.get("authority_provenance", "unverified"),
|
|
167
|
+
grant=grant,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return cls(
|
|
171
|
+
decision=data["decision"],
|
|
172
|
+
risk_score=float(data["risk_score"]),
|
|
173
|
+
reason_codes=list(data.get("reason_codes", [])),
|
|
174
|
+
explanation=data.get("explanation", ""),
|
|
175
|
+
policy_results=results,
|
|
176
|
+
receipt=receipt,
|
|
177
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vizier-guard
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Deterministic authorization and audit guard for AI agent actions
|
|
5
|
+
Author-email: Vassiliy Lakhonin <vassiliy.lakhonin@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/vassiliylakhonin/vizier
|
|
8
|
+
Project-URL: Documentation, https://vizier.vassiliy-lakhonin.workers.dev/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/vassiliylakhonin/vizier
|
|
10
|
+
Keywords: ai,agents,security,authorization,guardrails,langchain,mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Provides-Extra: pydantic
|
|
26
|
+
Requires-Dist: pydantic>=2.0; extra == "pydantic"
|
|
27
|
+
Provides-Extra: httpx
|
|
28
|
+
Requires-Dist: httpx>=0.24.0; extra == "httpx"
|
|
29
|
+
Provides-Extra: langchain
|
|
30
|
+
Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
33
|
+
|
|
34
|
+
# Vizier Guard (Python SDK)
|
|
35
|
+
|
|
36
|
+
Deterministic Authorization & Audit Firewall for AI Agent Actions.
|
|
37
|
+
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](pyproject.toml)
|
|
40
|
+
|
|
41
|
+
Vizier is an ultra-low-latency (sub-2ms) deterministic policy kernel that evaluates proposed AI agent actions before they cause external side-effects (payments, database mutations, external messages, code execution, worker deployments).
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install vizier-guard
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Zero external dependencies required out of the box (uses Python standard library).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 30-Second Quickstart: `@vizier_guard` Decorator
|
|
56
|
+
|
|
57
|
+
Wrap any dangerous tool or Python function with deterministic policy rules:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from vizier import VizierClient, vizier_guard
|
|
61
|
+
|
|
62
|
+
client = VizierClient(
|
|
63
|
+
base_url="https://vizier.vassiliy-lakhonin.workers.dev",
|
|
64
|
+
api_key="your-vizier-api-key"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@vizier_guard(
|
|
68
|
+
client=client,
|
|
69
|
+
action_type="purchase",
|
|
70
|
+
max_amount=500.0,
|
|
71
|
+
currency="USD",
|
|
72
|
+
allowed_targets=["approved-hotel.com", "supplier-corp.com"]
|
|
73
|
+
)
|
|
74
|
+
def book_hotel(amount: float, target: str):
|
|
75
|
+
# This only runs if Vizier returns ALLOW
|
|
76
|
+
print(f"Booking confirmed at {target} for ${amount}")
|
|
77
|
+
return {"status": "booked", "amount": amount}
|
|
78
|
+
|
|
79
|
+
# Allowed: $350 <= $500 to approved target
|
|
80
|
+
book_hotel(amount=350.0, target="approved-hotel.com")
|
|
81
|
+
|
|
82
|
+
# Blocked: $1200 > $500 (raises ActionBlockedError)
|
|
83
|
+
book_hotel(amount=1200.0, target="approved-hotel.com")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## LangChain Integration
|
|
89
|
+
|
|
90
|
+
Protect any LangChain `BaseTool` from agent hallucinations or runaway spending:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from vizier import VizierClient
|
|
94
|
+
from vizier.integrations.langchain import VizierLangChainToolGuard
|
|
95
|
+
from langchain_community.tools import DuckDuckGoSearchRun
|
|
96
|
+
|
|
97
|
+
client = VizierClient(api_key="...")
|
|
98
|
+
|
|
99
|
+
# Wrap your tool
|
|
100
|
+
safe_search = VizierLangChainToolGuard(
|
|
101
|
+
tool=DuckDuckGoSearchRun(),
|
|
102
|
+
client=client,
|
|
103
|
+
allowed_actions=["search"],
|
|
104
|
+
max_amount=0.0
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Pass safe_search into your LangChain or LangGraph agent
|
|
108
|
+
agent = create_react_agent(llm, tools=[safe_search])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## CrewAI Integration
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from vizier.integrations.crewai import VizierCrewAIToolGuard
|
|
117
|
+
|
|
118
|
+
guarded_tool = VizierCrewAIToolGuard(
|
|
119
|
+
tool=my_dangerous_payment_tool,
|
|
120
|
+
max_amount=250.0,
|
|
121
|
+
agent_id="finance_agent"
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Standalone Verification
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from vizier import VizierClient
|
|
131
|
+
|
|
132
|
+
vizier = VizierClient(api_key="...")
|
|
133
|
+
|
|
134
|
+
decision = vizier.check(
|
|
135
|
+
action_type="deploy_worker",
|
|
136
|
+
target="worker:payment-service",
|
|
137
|
+
parameters={"git_commit": "abcdef123..."},
|
|
138
|
+
allowed_actions=["deploy_worker"]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if decision.is_allowed:
|
|
142
|
+
print(f"Action permitted! Receipt ID: {decision.receipt.id}")
|
|
143
|
+
else:
|
|
144
|
+
print(f"Action rejected ({decision.decision}): {decision.explanation}")
|
|
145
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/vizier/__init__.py
|
|
4
|
+
src/vizier/canonical.py
|
|
5
|
+
src/vizier/client.py
|
|
6
|
+
src/vizier/decorators.py
|
|
7
|
+
src/vizier/models.py
|
|
8
|
+
src/vizier/integrations/__init__.py
|
|
9
|
+
src/vizier/integrations/crewai.py
|
|
10
|
+
src/vizier/integrations/langchain.py
|
|
11
|
+
src/vizier_guard.egg-info/PKG-INFO
|
|
12
|
+
src/vizier_guard.egg-info/SOURCES.txt
|
|
13
|
+
src/vizier_guard.egg-info/dependency_links.txt
|
|
14
|
+
src/vizier_guard.egg-info/requires.txt
|
|
15
|
+
src/vizier_guard.egg-info/top_level.txt
|
|
16
|
+
tests/test_canonical.py
|
|
17
|
+
tests/test_decorators.py
|
|
18
|
+
tests/test_models.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vizier
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from vizier.canonical import canonicalize, sha256_canonical_json
|
|
3
|
+
|
|
4
|
+
def test_primitives():
|
|
5
|
+
assert canonicalize(None) == "null"
|
|
6
|
+
assert canonicalize(True) == "true"
|
|
7
|
+
assert canonicalize(False) == "false"
|
|
8
|
+
assert canonicalize(42) == "42"
|
|
9
|
+
assert canonicalize("hello") == '"hello"'
|
|
10
|
+
|
|
11
|
+
def test_dict_key_sorting():
|
|
12
|
+
d1 = {"z": 1, "a": 2, "m": 3}
|
|
13
|
+
d2 = {"a": 2, "m": 3, "z": 1}
|
|
14
|
+
assert canonicalize(d1) == '{"a":2,"m":3,"z":1}'
|
|
15
|
+
assert canonicalize(d1) == canonicalize(d2)
|
|
16
|
+
assert sha256_canonical_json(d1) == sha256_canonical_json(d2)
|
|
17
|
+
|
|
18
|
+
def test_nested_structures():
|
|
19
|
+
data = {
|
|
20
|
+
"user": {"name": "alice", "id": 1},
|
|
21
|
+
"tags": ["prod", "agent"],
|
|
22
|
+
"active": True
|
|
23
|
+
}
|
|
24
|
+
expected = '{"active":true,"tags":["prod","agent"],"user":{"id":1,"name":"alice"}}'
|
|
25
|
+
assert canonicalize(data) == expected
|
|
26
|
+
|
|
27
|
+
def test_hash_consistency():
|
|
28
|
+
obj = {"action": "purchase", "amount": 100}
|
|
29
|
+
h = sha256_canonical_json(obj)
|
|
30
|
+
assert len(h) == 64
|
|
31
|
+
assert h == sha256_canonical_json({"amount": 100, "action": "purchase"})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
from vizier import vizier_guard, ActionBlockedError, VerificationResponse
|
|
5
|
+
from vizier.models import Receipt, PolicyResult
|
|
6
|
+
|
|
7
|
+
def make_mock_response(decision: str, explanation: str = "ok"):
|
|
8
|
+
return VerificationResponse(
|
|
9
|
+
decision=decision,
|
|
10
|
+
risk_score=0.0 if decision == "ALLOW" else 0.8,
|
|
11
|
+
reason_codes=[] if decision == "ALLOW" else ["AMOUNT_LIMIT_EXCEEDED"],
|
|
12
|
+
explanation=explanation,
|
|
13
|
+
policy_results=[
|
|
14
|
+
PolicyResult(rule_id="amount.limit", result="PASS" if decision == "ALLOW" else "FAIL", reason_code=None)
|
|
15
|
+
],
|
|
16
|
+
receipt=Receipt(
|
|
17
|
+
id="vrf_test_123",
|
|
18
|
+
created_at="2026-09-11T00:00:00Z",
|
|
19
|
+
request_hash="a" * 64,
|
|
20
|
+
decision=decision,
|
|
21
|
+
risk_score=0.0,
|
|
22
|
+
policy_rule_ids=["amount.limit"],
|
|
23
|
+
reason_codes=[],
|
|
24
|
+
authority_provenance="trusted_integration",
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def test_decorator_allows_valid_action():
|
|
29
|
+
mock_client = MagicMock()
|
|
30
|
+
mock_client.check.return_value = make_mock_response("ALLOW")
|
|
31
|
+
|
|
32
|
+
@vizier_guard(client=mock_client, action_type="test_action", max_amount=100.0)
|
|
33
|
+
def do_work(amount: float, target: str):
|
|
34
|
+
return f"worked on {target} with {amount}"
|
|
35
|
+
|
|
36
|
+
result = do_work(50.0, target="server-1")
|
|
37
|
+
assert result == "worked on server-1 with 50.0"
|
|
38
|
+
assert mock_client.check.called
|
|
39
|
+
kwargs = mock_client.check.call_args[1]
|
|
40
|
+
assert kwargs["action_type"] == "test_action"
|
|
41
|
+
assert kwargs["target"] == "server-1"
|
|
42
|
+
assert kwargs["max_amount"] == 100.0
|
|
43
|
+
|
|
44
|
+
def test_decorator_blocks_invalid_action():
|
|
45
|
+
mock_client = MagicMock()
|
|
46
|
+
mock_client.check.return_value = make_mock_response("BLOCK", explanation="Amount exceeded limit")
|
|
47
|
+
|
|
48
|
+
@vizier_guard(client=mock_client, action_type="transfer", max_amount=100.0)
|
|
49
|
+
def transfer(amount: float, target: str):
|
|
50
|
+
return "transferred"
|
|
51
|
+
|
|
52
|
+
with pytest.raises(ActionBlockedError) as exc_info:
|
|
53
|
+
transfer(500.0, target="untrusted_account")
|
|
54
|
+
|
|
55
|
+
assert "blocked by Vizier (BLOCK)" in str(exc_info.value)
|
|
56
|
+
assert exc_info.value.response.decision == "BLOCK"
|
|
57
|
+
|
|
58
|
+
def test_decorator_callback_mode():
|
|
59
|
+
mock_client = MagicMock()
|
|
60
|
+
mock_client.check.return_value = make_mock_response("REVIEW", explanation="Manual approval required")
|
|
61
|
+
|
|
62
|
+
def handle_block(resp):
|
|
63
|
+
return {"status": "paused_for_human", "decision": resp.decision}
|
|
64
|
+
|
|
65
|
+
@vizier_guard(
|
|
66
|
+
client=mock_client,
|
|
67
|
+
action_type="delete_db",
|
|
68
|
+
on_block="callback",
|
|
69
|
+
on_block_callback=handle_block
|
|
70
|
+
)
|
|
71
|
+
def delete_data(db_name: str):
|
|
72
|
+
return "deleted"
|
|
73
|
+
|
|
74
|
+
res = delete_data("prod_users")
|
|
75
|
+
assert res == {"status": "paused_for_human", "decision": "REVIEW"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from vizier.models import (
|
|
3
|
+
Agent, Principal, Action, Authority, AuthorityConstraints,
|
|
4
|
+
VerificationRequest, VerificationResponse
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
def test_verification_request_to_dict():
|
|
8
|
+
req = VerificationRequest(
|
|
9
|
+
agent=Agent(id="ag_1", owner="corp"),
|
|
10
|
+
principal=Principal(id="corp"),
|
|
11
|
+
action=Action(type="deploy", target="worker:1", parameters={"v": 2}),
|
|
12
|
+
authority=Authority(
|
|
13
|
+
allowed_actions=["deploy"],
|
|
14
|
+
constraints=AuthorityConstraints(max_amount=50.0, currency="USD", allowed_targets=["worker:1"])
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
d = req.to_dict()
|
|
18
|
+
assert d["agent"] == {"id": "ag_1", "owner": "corp"}
|
|
19
|
+
assert d["principal"] == {"id": "corp"}
|
|
20
|
+
assert d["action"]["type"] == "deploy"
|
|
21
|
+
assert d["authority"]["allowed_actions"] == ["deploy"]
|
|
22
|
+
assert d["authority"]["constraints"]["max_amount"] == 50.0
|
|
23
|
+
assert d["authority"]["constraints"]["allowed_targets"] == ["worker:1"]
|
|
24
|
+
assert d["context"]["source"] == "rest"
|
|
25
|
+
|
|
26
|
+
def test_verification_response_from_dict():
|
|
27
|
+
raw = {
|
|
28
|
+
"decision": "ALLOW",
|
|
29
|
+
"risk_score": 0.0,
|
|
30
|
+
"reason_codes": [],
|
|
31
|
+
"explanation": "Action allowed",
|
|
32
|
+
"policy_results": [
|
|
33
|
+
{"rule_id": "rule.1", "result": "PASS", "reason_code": None, "details": {}}
|
|
34
|
+
],
|
|
35
|
+
"receipt": {
|
|
36
|
+
"id": "vrf_1",
|
|
37
|
+
"created_at": "2026-09-11T00:00:00Z",
|
|
38
|
+
"request_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
39
|
+
"decision": "ALLOW",
|
|
40
|
+
"risk_score": 0.0,
|
|
41
|
+
"policy_rule_ids": ["rule.1"],
|
|
42
|
+
"reason_codes": [],
|
|
43
|
+
"authority_provenance": "principal_signed",
|
|
44
|
+
"grant": {
|
|
45
|
+
"jti": "g_1",
|
|
46
|
+
"issuer": "corp",
|
|
47
|
+
"subject": "ag_1",
|
|
48
|
+
"key_id": "k_1",
|
|
49
|
+
"expires_at": "2026-09-12T00:00:00Z"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
resp = VerificationResponse.from_dict(raw)
|
|
54
|
+
assert resp.is_allowed is True
|
|
55
|
+
assert resp.is_blocked is False
|
|
56
|
+
assert resp.receipt.id == "vrf_1"
|
|
57
|
+
assert resp.receipt.grant is not None
|
|
58
|
+
assert resp.receipt.grant.key_id == "k_1"
|
|
59
|
+
assert resp.policy_results[0].rule_id == "rule.1"
|