Metadata-Version: 2.4
Name: safenode-sdk
Version: 0.1.0
Summary: Policy firewall for AI agent actions. Evaluate what your agent is about to do, before it does it.
Project-URL: Homepage, https://safenode.tech
Project-URL: Documentation, https://safenode.tech/docs
Project-URL: Repository, https://github.com/sp3ak/safenode-tech
Project-URL: Changelog, https://github.com/sp3ak/safenode-tech/blob/main/CHANGELOG.md
Project-URL: API Spec, https://safenode.tech/openapi.yaml
Author: SafeNode
License-Expression: MIT
License-File: LICENSE
Keywords: agent-security,ai-agents,ai-safety,guardrails,llm,llmops,mcp,policy-engine
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.23
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: otel
Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
Description-Content-Type: text/markdown

# SafeNode Python SDK

Evaluate what your AI agent is about to do, before it does it.

```bash
pip install safenode-sdk
```

```python
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](https://safenode.tech) ·
[Docs](https://safenode.tech/docs) ·
[OpenAPI spec](https://safenode.tech/openapi.yaml)

---

## Contents

- [What this is not](#what-this-is-not)
- [Fail behaviour](#fail-behaviour-read-this-one)
- [Latency](#latency)
- [What data leaves your infrastructure](#what-data-leaves-your-infrastructure)
- [Enforcement helpers](#enforcement-helpers)
- [Non-blocking mode](#non-blocking-mode)
- [Error handling](#error-handling)
- [Why not just write if-statements](#why-not-just-write-if-statements)
- [Why not OPA](#why-not-opa)
- [FAQ](#faq)
- [API reference](#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](https://safenode.tech/docs) 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:

```python
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:

```python
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** |

```python
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](https://safenode.tech). If you cannot afford the round trip at
> all, use [non-blocking mode](#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:

```python
>>> 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.

```python
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:

```python
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.

```python
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:

```python
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:

```python
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:

```python
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

```python
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:

```python
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](https://safenode.tech) — 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](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](SECURITY.md). Please do not open public issues for vulnerabilities.

## License

MIT. See [LICENSE](LICENSE).
