Python SDK
vulnify asks Vulnify whether an action is allowed before your code runs it. These pages match version 0.4.0. The client behavior below is unchanged from 0.3.x. Decision.final_decision is still optional. Webhook helpers are included. The package requires Python 3.9 or newer. The core install depends only on the standard library. The CLI is the vulnify[cli] extra (PyYAML and jsonschema).
pip install vulnifypip install "vulnify[cli]"Source and changelog: github.com/vulnify/vulnify-sdk-python. Package: pypi.org/project/vulnify.
Client
Section titled “Client”import osfrom vulnify import Vulnify
vulnify = Vulnify( api_key=os.environ["VULNIFY_API_KEY"], timeout=3.0, fail_mode="closed", retries=2,)| Argument | Default | Meaning |
|---|---|---|
api_key |
required | Sent as Authorization: Bearer. |
base_url |
https://api.vulnify.io |
When omitted, VULNIFY_BASE_URL overrides the default. An explicit base_url wins over that variable. An empty explicit value raises ValueError. |
timeout |
3.0 |
Seconds, per attempt. |
fail_mode |
"closed" |
Used only by check(), and only after retries of a network error, 408, 429, or 5xx. Any other value raises ValueError. |
retries |
2 |
Extra attempts after the first, with a short backoff. The same Idempotency-Key is reused. |
decision = vulnify.check( agent="SalesBot", action="EXPORT_DATA", resource="Customer Database", destination="EXTERNAL_EMAIL", records_affected=12000, # content= is optional. It is scanned and not stored. # agent_id=, resource_id=, idempotency_key= are optional.)check() posts to /v1/events. Network problems, HTTP 408, HTTP 429, and HTTP 5xx do not raise from check(). They become the fallback decision. Every other 4xx raises VulnifyError immediately, including 400, 401, 403, 404, and 413, and including when fail_mode is "open". A 413 means the JSON body is larger than 200 KB. It is not retried and it is never turned into an allow.
final_decision
Section titled “final_decision”Run the action only when the effective outcome is ALLOW. That value is final_decision when the API sent it, and decision when final_decision is None.
outcome = decision.final_decision if decision.final_decision is not None else decision.decision
if outcome == "ALLOW": export_customer_records()elif outcome == "REVIEW": # Stop. A person still has to approve. decision.id is the event to poll. passelif decision.degraded: # Vulnify could not be reached. The fallback leaves final_decision as None. passelse: # BLOCK. A denied or expired review lands here even though decision stays REVIEW. passfinal_decision |
Meaning |
|---|---|
"REVIEW" |
A review is pending. Do not run the action. |
"ALLOW" |
The action may run. After a person approves, the stored decision is still "REVIEW". |
"BLOCK" |
Do not run the action. Denial and expiry both set this. |
None |
The API omitted finalDecision. Obey decision. Idempotent replays of decisions stored before the field existed omit it. The local fallback used when Vulnify cannot be reached leaves it as None and sets degraded to True. |
Decision.from_api copies finalDecision onto final_decision. Attribute names are snake_case. decision.evaluated_decision, decision.risk_score, decision.dlp_findings, decision.quota_exceeded. The JSON on the wire is camelCase. See Decisions.
get_event returns the same body as check(), including final_decision, quota_exceeded, sandbox, and lgpd_categories. Missing quotaExceeded and sandbox keys become False.
guard() and protect()
Section titled “guard() and protect()”guard() uses final_decision when it is set, and decision otherwise. ALLOW runs the callable. BLOCK and REVIEW raise VulnifyBlockedError.
An idempotent replay of an already approved review has decision="REVIEW" and final_decision="ALLOW". guard() runs the callable on that response and does not poll. final_decision="BLOCK" raises and does not poll.
vulnify.guard( lambda: export_customer_records(), agent="SalesBot", action="EXPORT_DATA", resource="Customer Database", destination="EXTERNAL_EMAIL", records_affected=12000,)Pass wait only when this process should poll until a person approves. Approval does not change decision away from REVIEW.
vulnify.guard( lambda: export_customer_records(), wait={"timeout": 120, "poll": 2}, agent="SalesBot", action="EXPORT_DATA", resource="Customer Database",)With wait, a pending review polls wait_for_review. When the API sends final_decision, that call returns "ALLOW" or "BLOCK" and keeps polling while the value is "REVIEW". When final_decision is None, it returns the review status "APPROVED", "DENIED", or "EXPIRED". Either way it returns "TIMEOUT" when the client gives up. The callable runs when the poll returns "ALLOW" or "APPROVED".
protect() is the same check as a decorator. The action is fixed when you decorate, not taken from the function arguments.
@vulnify.protect(agent="SalesBot", action="READ_DATA", resource="Customer Database")def load_customers(limit: int) -> list: ...Errors
Section titled “Errors”| Exception | When |
|---|---|
VulnifyError |
Bad API key, unknown agent or resource, rejected payload, body over 200 KB (413), or any other 4xx except 408 and 429. Also raised when get_event or wait_for_review exhaust retries. Never silenced by fail_mode. |
VulnifyBlockedError |
The action was blocked, or a review was not approved. exception.result is the Decision. |
The message uses the stored decision, so it looks like Vulnify BLOCK: external destination or Vulnify REVIEW: … even when final_decision is the effective outcome. Validation message arrays are joined into one string.
pip install "vulnify[cli]" installs the vulnify console script. Commands, flags, and exit codes match the Node.js CLI in @vulnify/sdk 0.3.0. See CLI and policies as code.
What this package does not include
Section titled “What this package does not include”vulnify 0.3.0 publishes verify_webhook_signature, construct_webhook, and construct_webhook_from_request. A bad signature raises WebhookVerificationError. parse_webhook in 0.3.0 required a test eventId of test- plus the delivery id. 0.3.1 accepts any id that starts with test-. That value is its own UUID, not the delivery id. A standard-library check is on Webhooks. CrewAI and LangGraph adapters are in vulnify.adapters and do not import those frameworks. They call guard(), so they follow final_decision the same way. See Framework adapters.

