SafeNode Python SDK
Evaluate what your AI agent is about to do, before it does it.
pip install safenode-sdk
import os
from safenode_sdk import SafeNode
sn = SafeNode(api_key=os.environ["SAFENODE_API_KEY"])
result = sn.evaluate(
"send_email",
payload={"to": "customer@example.com", "subject": "Your refund"},
context={"vendor_id": "sendgrid", "cost_estimate": 0.01},
)
if result.denied:
raise RuntimeError(f"Blocked: {result.reasons} (trace {result.trace_id})")
send_the_email()
That is the whole idea. Your agent proposes an action, SafeNode returns allow, warn, review, or
deny based on policies you configure in a dashboard, and your code branches on it.
Get an API key — free tier, no card · Docs · OpenAPI spec
Contents
- What this is not
- Fail behaviour
- Latency
- What data leaves your infrastructure
- Enforcement helpers
- Non-blocking mode
- Error handling
- Why not just write if-statements
- Why not OPA
- FAQ
- API reference
What this is not
Being clear about this up front saves everyone time.
- Not a jailbreak or prompt-injection detector. It evaluates actions, not prompts. If your agent has been talked into wiring money, SafeNode can stop the wire — it will not tell you the agent was manipulated.
- Not a sandbox. Nothing here contains a process, restricts syscalls, or limits filesystem access. It returns a decision; enforcing it is your code's job. (The MCP gateway is the exception — it enforces by not forwarding.)
- Not a local rules engine. Every
evaluate()is a network call. There is no offline mode and no local policy evaluation. - Not a replacement for authz. SafeNode answers "should this action happen at all, under these circumstances", not "is this user allowed to do this". You still need both.
- Not free of side effects. Every evaluation is recorded server-side and counts against your monthly allowance.
Fail behaviour (read this one)
The single most consequential setting. When SafeNode is unreachable — network error, timeout, or a 5xx — the SDK does one of three things:
on_unavailable |
Behaviour |
|---|---|
"fail_open" (default) |
Returns a synthetic allow with degraded=True. Your agent keeps working, unevaluated. |
"fail_closed" |
Returns a synthetic deny with degraded=True. Your agent stops. |
"raise" |
Raises Unavailable and lets you decide. |
The default is fail_open, and that is a deliberate tradeoff. A security tool that takes your
production down during its own first outage does not get a second chance. But it means a SafeNode
outage silently becomes an unpoliced window.
Choose per action class rather than globally. That is usually the right answer:
audit = SafeNode(api_key=key, on_unavailable="fail_open") # read-only, logging
gate = SafeNode(api_key=key, on_unavailable="fail_closed") # payments, deletions, outbound mail
Degraded results are distinguishable from real decisions in three independent ways, so they can never be quietly counted as policy allows:
result.degraded is True
result.trace_id is None # a real decision always has one
result.reasons == ["safenode_unavailable"]
The SDK also logs a one-time warning at WARNING level the first time it fails open.
Latency
Client-side budget, both configurable:
| Default | |
|---|---|
| Total request timeout | 2000 ms |
| Connect timeout | 500 ms |
| Retries | 0 |
sn = SafeNode(api_key=key, timeout=0.75, connect_timeout=0.2)
Timed-out calls are never retried, and this is not configurable. Every evaluation is a server-side write, so a request that timed out may already have been recorded. Retrying it would double-count your metered usage, duplicate your decision feed, and double the worst-case latency of a call sitting in front of a user-visible action.
Opt-in retries (retries=2) apply only to throttling and 5xx, with jittered exponential backoff.
Server-side p99 is not published yet. We are not going to print a number we have not measured under realistic load. If a hard latency budget matters to you, measure it against your own workload and tell us what you see. If you cannot afford the round trip at all, use non-blocking mode.
What data leaves your infrastructure
You are being asked to send descriptions of your agent's actions to a third party. Here is exactly what that means.
Everything sent is stored. SafeNode persists the full envelope server-side for the audit trail and decision feed. Assume anything you send is retained.
Three payload modes control what that is:
payload_mode |
What is sent |
|---|---|
"full" |
Payload values verbatim. |
"redacted" (default) |
Payload values scrubbed client-side first. |
"metadata_only" |
No payload values at all — key names and value hashes only. |
context is always sent unredacted, in every mode. This is load-bearing: vendor, region, and
spend gating are all driven by context, and scrubbing it would break them. Do not put secrets in
context.
Inspect exactly what would be sent
build_request() is a dry run. No network call, no side effects:
>>> sn.build_request("send_email", {"to": "alice@corp.com", "note": "card 4111111111111111"})
{'action_type': 'send_email',
'payload': {'to': '[REDACTED:email]', 'note': 'card [REDACTED:credit_card]'},
'context': {'safenode_payload_mode': 'redacted',
'safenode_redactions': {'email': 1, 'credit_card': 1}}}
Default rules cover emails, credit cards (Luhn-validated, so order numbers survive), US SSNs,
provider API key prefixes (sk-, ghp_, xoxb-, AKIA, AIza), bearer tokens, PEM private key
blocks, and phone numbers.
from safenode_sdk import Redactor, RedactionRule
import re
sn = SafeNode(
api_key=key,
redactor=Redactor(
extra_rules=[RedactionRule("employee_id", re.compile(r"\bEMP-\d{5}\b"))],
allowlist_keys=["vendor_id"], # never redact these values
redact_keys=["internal_note"], # always strip these, whatever they contain
disabled_rules=["phone"],
),
)
Why redaction counts are sent
Notice safenode_redactions in the dry run above. The SDK reports how many values of each type it
stripped, and this is not optional.
SafeNode's server-side sensitive_data rule matches patterns against payload values. If the SDK
scrubbed those values and said nothing, a policy of "deny any action containing a card number" would
silently start passing — the client-side privacy feature would have disabled the server-side security
control. Reporting counts closes that hole: policy can act on the presence of a card number without
ever receiving one.
If you use sensitive_data with patterns, pair it with a redaction_metadata rule covering the
same types.
These counts are self-reported by the client. They raise the floor for honest callers; they are not a defence against a hostile one, which could simply send an empty payload.
metadata_only
For when payload values must not leave your network at all:
sn = SafeNode(api_key=key, payload_mode="metadata_only")
Key names are preserved (so key-based rules keep working) and every value becomes a truncated
SHA-256. Eight of SafeNode's nine rule types are context-driven and work identically in this
mode — only pattern-based sensitive_data degrades, and the redaction counts partly cover it.
Hashes let you correlate identical values across requests. They are not a privacy guarantee for
low-entropy values: anyone who guesses an email address can confirm it. Pass hash_salt="..." to
prevent cross-tenant correlation.
No telemetry
This package makes exactly one network call — to the SafeNode API, when you call evaluate(). No
analytics, no phone-home, no crash reporting, no install-time scripts. For a security tool anything
else would be disqualifying.
Enforcement helpers
evaluate() returns a decision. These raise instead.
from safenode_sdk import PolicyDenied
# Context manager
with sn.guard("delete_records", {"table": "users", "count": 400}) as decision:
delete_records()
log.info("approved", trace_id=decision.trace_id)
# Decorator. Arguments are only sent if you map them.
@sn.guarded("send_email", payload=lambda to, body: {"to": to})
def send_email(to: str, body: str) -> None: ...
Both raise PolicyDenied on deny and review. warn proceeds by default; pass
allow_warn=False to treat warnings as blocking.
Branch on the boolean properties rather than comparing strings:
result.allowed # allow
result.warned # warn
result.needs_review # review
result.denied # deny
result.permitted # allow or warn — "may proceed"
result.blocked # review or deny — "must not proceed"
permitted exists because if result.allowed silently blocks every warn, which is rarely what
people mean the first time.
Async
AsyncSafeNode mirrors the sync surface exactly:
from safenode_sdk import AsyncSafeNode
async with AsyncSafeNode(api_key=key) as sn:
result = await sn.evaluate("call_model", {"prompt": prompt})
async with sn.guard("send_email", {"to": addr}) as decision:
await send(addr)
Non-blocking mode
For audit-and-alert when you cannot afford a round trip in a hot path:
sn.evaluate_async("call_model", {"prompt": prompt}) # returns immediately
This cannot gate anything — you get no decision back. It records the action and lets policy violations surface in your dashboard and alerts after the fact.
Work goes to a single background thread behind a bounded queue (default 1000). When the queue is
full the oldest pending item is dropped and a warning is logged. A SafeNode outage can never become
your memory leak. AsyncSafeNode.evaluate_nowait() is the asyncio equivalent.
Error handling
from safenode_sdk import (
SafeNodeError, # base — catching this contains the SDK entirely
ConfigurationError, # bad options, raised at construction
AuthError, # 401 — bad, expired, or unbound key
ValidationError, # 422 — server rejected the request
PayloadTooLargeError, # 422 — payload or context over 256 KiB
RateLimitError, # 429 — throttled. RETRYABLE after .retry_after
QuotaExceededError, # 429 — monthly cap exhausted. NOT retryable
Unavailable, # unreachable (only raised when on_unavailable="raise")
PolicyDenied, # raised by guard()/guarded(), never by evaluate()
)
The one that will bite you: SafeNode returns HTTP 429 for two unrelated conditions. Throttling is transient and retryable. Monthly quota exhaustion is not — it stays failing until your next billing month, and retrying with backoff will just fail for days. The SDK discriminates on the response body and raises different types, so you do not have to:
try:
result = sn.evaluate("send_email", payload)
except QuotaExceededError as e:
alert(f"SafeNode quota exhausted: {e.evaluations_used}/{e.evaluations_cap}") # upgrade
except RateLimitError as e:
backoff(e.retry_after) # retry later
evaluate() never raises on a deny — a denial is a successful evaluation. Use guard() if you
want the exception.
Why not just write if-statements
For one rule in one codebase, honestly, write the if-statement. This earns its place when:
- The rules change more often than the code. Policy lives in a dashboard; a non-engineer can tighten a spend limit without a deploy.
- You need the audit trail. Every decision is recorded with a
trace_id, the inputs, and the matched rules. Reconstructing "why did the agent do that on the 14th" from application logs is work you will do exactly once before wishing you had this. - Enforcement has to be consistent across agents. Five agents in three languages plus some n8n workflows will not stay consistent by convention.
reviewis a real state. Human-in-the-loop approval queues are a meaningful amount of code to build, and an if-statement cannot return "ask someone".
If none of those apply, use the if-statement. It is faster and has no failure mode.
Why not OPA
Open Policy Agent is a good tool and solves an overlapping problem. Genuine differences:
- Rego is a language. Someone on your team has to learn and maintain it. SafeNode's rules are configured in a UI, which is a real limitation as well as a real advantage.
- Scoring vs. boolean. OPA answers yes/no. SafeNode returns weighted impact and risk scores
banded into four outcomes, including
review. If you want a human approval step for medium-risk actions, that is native here and something you would build yourself on OPA. - Batteries for this specific domain. Vendor registries, spend thresholds, region gating, and business-hours rules ship working. On OPA they are Rego you write.
- OPA runs locally. That is a genuine OPA advantage: no network call and no third party. If sub-millisecond local evaluation is a hard requirement, use OPA.
They compose. OPA for infrastructure authz, SafeNode for agent actions, is a reasonable architecture.
FAQ
Is there a self-hosted or VPC deployment? Not yet. It is planned, with no committed date. Today SafeNode is hosted only. If this is a blocker, say so — it moves the roadmap.
What is the API stability commitment?
/api/v1 is additive-only. New response fields may appear; existing fields will not change type or
disappear without a new version path. Unknown fields are preserved on result.raw, so a server-side
addition cannot break your build. The SDK follows semver and is pre-1.0 — minor versions may change
the Python surface until 1.0.0.
What are the rate limits?
60 requests/minute per API key by default. Separately, each plan has a monthly evaluation cap; see
QuotaExceededError above.
Does action_type have to come from a fixed list?
No, it is free-form (max 255 characters). Rules match on exact strings, so pick stable names and keep
them consistent. send_email, call_model, mcp_tool_call, run_shell_command are conventions,
not requirements.
Do I need to send agent_id?
No. The API key already identifies the agent.
Does this work with LangChain / CrewAI / n8n?
Not yet as a first-party adapter. guarded() wraps a tool function in three lines meanwhile. Tell us
which one you need.
API reference
SafeNode(api_key, **options)
| Option | Default | Notes |
|---|---|---|
api_key |
required | Your sn_... key |
base_url |
https://safenode.tech |
For staging |
on_unavailable |
"fail_open" |
fail_open | fail_closed | raise |
timeout |
2.0 |
Total seconds |
connect_timeout |
0.5 |
Seconds |
payload_mode |
"redacted" |
full | redacted | metadata_only |
redactor |
Redactor() |
Custom rules |
retries |
0 |
429/5xx only, never timeouts |
static_context |
None |
Merged into every request |
agent_id |
None |
Usually unnecessary |
hash_salt |
"" |
For metadata_only |
async_queue_size |
1000 |
Background queue bound |
tracing |
True |
OTel span if the API is installed |
transport |
None |
Custom httpx transport (proxies, mTLS) |
evaluate(action_type, payload=None, context=None, *, payload_mode=None, correlation_id=None, agent_id=None) -> Result
correlation_id is passed through in context so you can join SafeNode decisions to your own logs.
Result
decision, impact_score, risk_score (both 0–100), matched_policies, reasons,
alternatives, trace_id, degraded, raw, plus the boolean properties above.
Two server-side details worth knowing: alternatives is never empty — a general suggestion is
always appended, including on allow — and matched_policies is empty on hard-rule denials, so use
reasons for attribution.
OpenTelemetry
If opentelemetry-api is installed, each evaluation emits a safenode.evaluate span with
safenode.decision, safenode.degraded, and safenode.trace_id. Optional; never required. Disable
with tracing=False.
Contributing
See CONTRIBUTING.md. Bug reports welcome, especially about the contract — if the SDK and the API disagree, that is a bug worth filing.
Security
See SECURITY.md. Please do not open public issues for vulnerabilities.
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file safenode_sdk-0.1.0.tar.gz.
File metadata
- Download URL: safenode_sdk-0.1.0.tar.gz
- Upload date:
- Size: 36.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
22b5c6572bf3bf3f6f7b498bf6ab2d398ccf23ce62c7b40bc47ae671e77b89db
|
|
| MD5 |
14f5746221ab76e39f727b68f593b717
|
|
| BLAKE2b-256 |
6ae2ff360b3b10a5f649a256e0e287ffc10dcf470e8f4db9301107f16732d422
|
Provenance
The following attestation bundles were made for safenode_sdk-0.1.0.tar.gz:
Publisher:
ci.yml on sp3ak/safenode-tech
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safenode_sdk-0.1.0.tar.gz -
Subject digest:
22b5c6572bf3bf3f6f7b498bf6ab2d398ccf23ce62c7b40bc47ae671e77b89db - Sigstore transparency entry: 2277930548
- Sigstore integration time:
-
Permalink:
sp3ak/safenode-tech@0c276f602a6b8defca95892660ba3c76ece49647 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/sp3ak
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@0c276f602a6b8defca95892660ba3c76ece49647 -
Trigger Event:
push
-
Statement type:
File details
Details for the file safenode_sdk-0.1.0-py3-none-any.whl.
File metadata
- Download URL: safenode_sdk-0.1.0-py3-none-any.whl
- Upload date:
- Size: 27.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
771401b50ae91b5c2a4d2f2da865023b8b11e269f0bdda6e96afd30fe9b99a55
|
|
| MD5 |
5ad3e775e610de3f6242099d63828f07
|
|
| BLAKE2b-256 |
79bdbdd9e7d007ee2cf765c86d2b93fe0d0a2b357cc6235740fdad4ed4af0087
|
Provenance
The following attestation bundles were made for safenode_sdk-0.1.0-py3-none-any.whl:
Publisher:
ci.yml on sp3ak/safenode-tech
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safenode_sdk-0.1.0-py3-none-any.whl -
Subject digest:
771401b50ae91b5c2a4d2f2da865023b8b11e269f0bdda6e96afd30fe9b99a55 - Sigstore transparency entry: 2277930586
- Sigstore integration time:
-
Permalink:
sp3ak/safenode-tech@0c276f602a6b8defca95892660ba3c76ece49647 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/sp3ak
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@0c276f602a6b8defca95892660ba3c76ece49647 -
Trigger Event:
push
-
Statement type: