Python SDK — Deep Reference
vanzik v2.0updated view on github
Companion to the short plugins/python/README.md. This page covers the details you need when things go sideways: fail modes, timeouts, exception semantics, adapter internals, and testing patterns.
Package on PyPI: vanzik. Import name: vanzik. Before 2.0 the package was agentguardproxy, imported as agentguard (see MIGRATION.md).
Source: plugins/python/vanzik/core.py (Guard client, exceptions, constants), plugins/python/vanzik/decorators.py (@guarded), and plugins/python/vanzik/adapters/*.py (framework integrations). The package __init__.py re-exports the public surface — always import from the package root.
Table of contents#
- Install and import
- Environment variables
Guardconstructor in detailcheck()— argument guide- Fail-mode semantics
- Timeouts
approve,deny,wait_for_approval- Exception hierarchy
@guardeddecorator- Adapters
- Testing against a fake proxy
Install and import#
pip install vanzik # core only
pip install vanzik[langchain]
pip install vanzik[crewai]
pip install vanzik[browser-use]
pip install vanzik[mcp]
pip install 'vanzik[openai-agents]'
pip install vanzik[all] # everything
pip install vanzik[dev] # pytest, coverage, etc.
from vanzik import (
Guard, CheckResult, guarded,
VanzikError, VanzikDenied,
VanzikApprovalRequired, VanzikApprovalTimeout,
VanzikAuthError,
)
vanzik.__version__ is the SDK's version string, e.g. "1.2.0" — the same as the vanzik release it ships in.
The core SDK has no runtime dependencies — it uses only the standard library (urllib, json, dataclasses, functools, time). Adapters pull in their respective frameworks through PyPI extras.
Environment variables#
| Variable | Default | Consumed by |
|---|---|---|
VANZIK_URL |
http://localhost:8080 |
Guard(base_url="") fallback. |
VANZIK_API_KEY |
(empty) | Guard(api_key="") fallback. Sent as Authorization: Bearer <key> on /v1/approve/{id}, /v1/deny/{id}, and every poll of /v1/status/{id} inside wait_for_approval. |
VANZIK_TENANT_ID |
(empty) | Guard(tenant_id=None) fallback. A value other than local routes every call to /v1/t/<tenant>/… (see API.md). |
Explicit arguments always win over env vars; env vars always win over package defaults. Variables for the server and CLI: CLI.md § Environment variables.
Guard constructor in detail#
Guard(
base_url: str = "",
agent_id: str = "",
timeout: int = 5,
api_key: str = "",
fail_mode: str = "deny", # or "allow"
tenant_id: Optional[str] = None,
parent_session_id: Optional[str] = None, # v1.4
)
base_url— trailing/is stripped. Empty string falls back toVANZIK_URL, then to"http://localhost:8080".agent_id— sent with everycheck()as theagent_idJSON field. Policyagents.<id>.overrideblocks key off this value.timeout— per-HTTP-call timeout in seconds. Applies to/v1/check,/v1/approve,/v1/deny, and each poll insidewait_for_approval.api_key— empty string falls back toVANZIK_API_KEY. Required whenever the server is started with--api-key.fail_mode— see Fail-mode semantics. Invalid values raiseValueErrorat construction so misconfiguration fails at startup rather than mid-request.tenant_id—Nonefalls back toVANZIK_TENANT_ID; an explicit""ignores the env var. Empty or"local"uses the legacy/v1/…URLs; any other value uses/v1/t/<tenant>/…, with the tenant URL-encoded.parent_session_id— sent asparent_session_idon everycheck()unless the call passes its own. See Linking sub-agent sessions.
Linking sub-agent sessions#
When an orchestrator starts sub-agents, give each sub-agent its own session_id and name the orchestrator's session as its parent. Policy ignores the parent; the audit log records it, and GET /v1/sessions and the dashboard's Sessions view show the sessions linked.
orchestrator = Guard(agent_id="planner")
orchestrator.check("shell", command="ls", session_id="run-42")
researcher = Guard(agent_id="researcher", parent_session_id="run-42")
researcher.check("network", domain="api.github.com", session_id="run-42/researcher")
@guarded accepts it. The framework adapters use the Guard's parent_session_id and never read one from tool input: the model writes the tool arguments, and it must not choose which session its calls are filed under.
Guard instances are thread-safe for concurrent check() calls (all state read-only after init). They are cheap; creating one per request is fine, and reusing one is slightly cheaper.
check() — argument guide#
guard.check(
scope: str,
*,
action: str = "",
command: str = "",
path: str = "",
domain: str = "",
url: str = "",
session_id: str = "",
est_cost: float = 0.0,
meta: Optional[dict] = None,
approval_id: str = "",
parent_session_id: str = "", # v1.4
) -> CheckResult
parent_session_id names the session that started session_id (see Linking sub-agent sessions). It defaults to the Guard's parent_session_id and is omitted from the body when empty.
approval_id replays a previously issued approval after wait_for_approval resolved ALLOW — the server consumes the one-shot capability, enforces --approval-validity, reserves cost, and audits the execution as allow:approved (see APPROVAL_WORKFLOW.md). Omitted from the body when empty.
All fields past scope are keyword-only. Fill the ones your scope cares about:
| Scope | Fields to set |
|---|---|
shell |
command (optional action) |
filesystem |
action + path |
network |
domain or url |
browser |
domain or url |
cost |
est_cost and session_id |
data (or any generic scope) |
whichever string(s) your rules match on |
Unset fields are dropped from the request body to keep audit entries clean. est_cost=0.0 is also dropped (use a non-zero value when you want the cost engine to consider the call).
The request body is always JSON with Content-Type: application/json and capped at 1 MiB by the server (configurable via the policy proxy.request.max_body_bytes key).
The returned CheckResult#
@dataclass
class CheckResult:
decision: str # "ALLOW", "DENY", "REQUIRE_APPROVAL"
reason: str
matched_rule: str = ""
approval_id: str = ""
approval_url: str = ""
Convenience booleans: .allowed, .denied, .needs_approval.
Fail-mode semantics#
These failures of check() collapse into a synthetic CheckResult controlled by fail_mode:
urllib.error.URLError— connection-phase failure (connect refused, DNS, SSL handshake), and HTTP error statuses (urllib raisesHTTPError, aURLErrorsubclass, for4xx/5xx).OSError— post-connect failure (ConnectionResetError,BrokenPipeError,socket.timeoutsurfaced fromresp.read()).json.JSONDecodeError— the body parsed as non-JSON; from the caller's perspective this is indistinguishable from an unreachable proxy.- A response that isn't a valid decision: a
Content-Typeother thanapplication/json, or a JSON body without adecisionfield (reasonVanzik returned unexpected content-type …/Vanzik returned malformed response body).
# fail_mode="deny" (default)
CheckResult(decision="DENY", reason="Vanzik unreachable (deny): <original error>")
# fail_mode="allow"
CheckResult(decision="ALLOW", reason="Vanzik unreachable (allow): <original error>")
When to use "allow": rarely. Acceptable only if (a) your agent is already in a trusted environment, (b) Vanzik is advisory rather than authoritative, and (c) you log and alert on the "Vanzik unreachable" reason so the outage is visible.
When to stick with "deny": any production path where an unsupervised agent must not act. That is the safe default.
Timeouts#
| Knob | Default | Effect |
|---|---|---|
Guard(timeout=5) |
5s | Per-HTTP-call timeout. |
wait_for_approval(timeout=300) |
300s | Wall-clock deadline for the whole poll loop. |
wait_for_approval(poll_interval=2) |
2s | Sleep between polls. |
wait_for_approval quietly swallows individual poll failures (URLError, HTTP errors other than 401/403, other OSErrors, and a body that is not JSON) and keeps retrying until the deadline — the assumption is that the server is momentarily unreachable but will come back within the approval window. A final deadline miss returns CheckResult(decision="DENY", reason="Approval timed out"), which the @guarded(wait_for_approval=True) wrapper surfaces as VanzikApprovalTimeout.
Pick timeout higher than your human-SLA. If approvers need 15 minutes on average, timeout=300 will fire false negatives.
Restarts pause approvals; they don't kill them. The server persists the approval queue by default (--persist), so pending IDs survive a restart and your poll loop picks up where it left off. The exceptions: a server running --persist=false loses every pending ID on restart, and an entry created in the final ≥1 s store-sync window before a hard crash may be gone. In either case the poll loop retries against a queue that no longer has the entry — catch the timeout and re-issue the check call (which creates a new approval ID).
approve, deny, wait_for_approval#
guard.approve("ap_1a2b3c…") # True / False
guard.deny("ap_1a2b3c…") # True / False
guard.wait_for_approval("ap_1a2b3c…",
timeout=300,
poll_interval=2) # CheckResult
All three send Authorization: Bearer <api_key> when api_key is set. If the server was started without --api-key, the key is ignored. If the server was started with --api-key and you do not set one on the SDK side, the server answers 401: approve/deny return False, and wait_for_approval raises VanzikAuthError (with .status) on the first 401/403 instead of polling until the deadline.
approve/deny treat any URLError as failure and return False. They do not distinguish network error from 4xx. If you need that distinction, call the HTTP API directly.
Exception hierarchy#
The first three subclasses are raised by the @guarded decorator; VanzikAuthError is raised by wait_for_approval (and so also through @guarded(wait_for_approval=True)) when a status poll gets 401/403. All extend PermissionError so legacy except PermissionError: keeps working.
PermissionError
└── VanzikError .result: Optional[CheckResult]
├── VanzikDenied
├── VanzikApprovalRequired .approval_id, .approval_url
├── VanzikApprovalTimeout .approval_id
└── VanzikAuthError .status
The .result attribute lets you skip re-parsing the reason string:
try:
do_thing()
except VanzikDenied as e:
log.warning("blocked by rule %s: %s",
e.result.matched_rule, e.result.reason)
except VanzikApprovalRequired as e:
notify_slack(f"Action {e.approval_id} waiting at {e.approval_url}")
@guarded decorator#
@guarded(
scope: str,
guard: Optional[Guard] = None,
*,
wait_for_approval: bool = False,
approval_timeout: int = 300,
approval_poll_interval: int = 2,
**check_kwargs,
)
Behavior:
- Creates a default
Guard()ifguard=None(picks up env vars). - Extracts a
commandstring: the first positional arg, else thecommand=orcmd=kwarg, else"". - Calls
guard.check(scope, command=..., **check_kwargs). - Branches on the decision:
ALLOW→ runs the wrapped function.REQUIRE_APPROVAL+wait_for_approval=False→ raisesVanzikApprovalRequiredimmediately.REQUIRE_APPROVAL+wait_for_approval=True→ blocks onwait_for_approval. A resolved ALLOW is replayed throughguard.check(..., approval_id=...)(consuming the one-shot approval and reserving cost) and the function runs only if that replay allows; a resolved DENY or a denied replay raisesVanzikDenied; a refused replay that re-entered the approval flow raisesVanzikApprovalRequiredwith the new id; timeout raisesVanzikApprovalTimeout.DENY→ raisesVanzikDenied.
Any **check_kwargs you pass are forwarded verbatim to Guard.check(). Useful for pinning session_id=, meta=, etc.
Adapters#
All adapters import lazily — the extras you do not install are never imported and never crash the core SDK.
| Framework | Entry point | One-liner |
|---|---|---|
| LangChain | vanzik.adapters.langchain — GuardedTool, GuardedToolkit |
Wrap a tool list; every run/arun is policy-checked. |
| CrewAI | vanzik.adapters.crewai — GuardedCrewTool, guard_crew_tools |
Same, hooking both run and _run. |
| browser-use | vanzik.adapters.browseruse — GuardedBrowser |
Gate navigation, actions, and form input; wrap_page returns a page whose 14 action methods (goto, click, fill, type, evaluate, …) are checked first. |
| MCP | vanzik.adapters.mcp — GuardedMCPServer (or python -m vanzik.adapters.mcp) |
A guarded stdio MCP server; every tools/call is checked first. |
| OpenAI Agents SDK | vanzik.adapters.openai_agents — guard_tool, guard_tools, vanzik_tool_guardrail |
Gated copies of an agent's FunctionTools; a deny raises out of Runner.run, or the guardrail shows it to the model. |
Per-framework usage, scope-inference rules (keyword tables, runtime
upgrades, default_scope fallback), version pins, and deny/approval
return semantics are documented once in ADAPTERS.md.
Testing against a fake proxy#
Option A — run the real binary#
The repo's tests/test_end_to_end_real_server.py does exactly this: spawn ./vanzik serve --policy <test-policy.yaml> on a random port, talk to it with Guard, and assert decisions. This is the most realistic path and what CI uses.
Option B — a stdlib http.server stub#
For unit tests, stand up a local HTTP server that answers /v1/check with a canned decision. The SDK sends its requests with urllib, so mocks that patch the requests library (such as responses) never see the call: the check fails closed and the test only looks like it passed. A real socket on 127.0.0.1 works and needs nothing beyond the standard library:
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from vanzik import Guard
class StubVanzik(BaseHTTPRequestHandler):
"""Answers every POST /v1/check with one canned decision."""
def do_POST(self):
if self.path != "/v1/check":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", 0))
self.server.requests.append(json.loads(self.rfile.read(length)))
body = json.dumps({
"decision": "DENY",
"reason": "destructive command",
"matched_rule": "deny:shell:rm-rf",
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # keep pytest output quiet
pass
@pytest.fixture
def stub_vanzik():
server = HTTPServer(("127.0.0.1", 0), StubVanzik) # port 0: any free port
server.requests = []
threading.Thread(target=server.serve_forever, daemon=True).start()
yield server
server.shutdown()
server.server_close()
def test_denies_rm_rf(stub_vanzik):
guard = Guard(f"http://127.0.0.1:{stub_vanzik.server_port}")
r = guard.check("shell", command="rm -rf /")
assert r.denied
assert "destructive" in r.reason
assert stub_vanzik.requests[0]["command"] == "rm -rf /"
Option C — fail_mode="allow" for disconnected tests#
If you intentionally do not run a proxy in your unit tests and you want check() to succeed, construct Guard(..., fail_mode="allow"). Only do this in tests; it is the wrong default anywhere else.
Related docs#
docs/DEPLOYMENT.md— getting the server set up so the SDK can reach it.docs/POLICY_REFERENCE.md— what you pass tocheck().docs/TROUBLESHOOTING.md— "Python SDK always returns DENY", "approvals disappeared after restart", etc.docs/ADAPTERS.md— per-framework deep dive (written separately so this file does not balloon).