Skip to main content

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

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.
  • review is 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

safenode_sdk-0.1.0.tar.gz (36.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

safenode_sdk-0.1.0-py3-none-any.whl (27.1 kB view details)

Uploaded Python 3

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

Hashes for safenode_sdk-0.1.0.tar.gz
Algorithm Hash digest
SHA256 22b5c6572bf3bf3f6f7b498bf6ab2d398ccf23ce62c7b40bc47ae671e77b89db
MD5 14f5746221ab76e39f727b68f593b717
BLAKE2b-256 6ae2ff360b3b10a5f649a256e0e287ffc10dcf470e8f4db9301107f16732d422

See more details on using hashes here.

Provenance

The following attestation bundles were made for safenode_sdk-0.1.0.tar.gz:

Publisher: ci.yml on sp3ak/safenode-tech

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for safenode_sdk-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 771401b50ae91b5c2a4d2f2da865023b8b11e269f0bdda6e96afd30fe9b99a55
MD5 5ad3e775e610de3f6242099d63828f07
BLAKE2b-256 79bdbdd9e7d007ee2cf765c86d2b93fe0d0a2b357cc6235740fdad4ed4af0087

See more details on using hashes here.

Provenance

The following attestation bundles were made for safenode_sdk-0.1.0-py3-none-any.whl:

Publisher: ci.yml on sp3ak/safenode-tech

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page