safenode-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ # CI for the standalone safenode-py repository.
2
+ #
3
+ # This file is inert while the SDK lives inside the ordinex monorepo — GitHub only runs workflows
4
+ # from the repository root. Once `sdk/python/` becomes the root of its own repo, this becomes the
5
+ # active workflow. (The monorepo has its own copy at .github/workflows/sdk-python.yml; delete that
6
+ # one when the SDK moves out.)
7
+
8
+ name: ci
9
+
10
+ on:
11
+ push:
12
+ branches: [main]
13
+ tags: ["v*"]
14
+ pull_request:
15
+ workflow_dispatch:
16
+
17
+ jobs:
18
+ lint:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12"
25
+ - run: pip install -e ".[dev]"
26
+ - run: ruff check .
27
+ - run: ruff format --check .
28
+ - run: mypy
29
+
30
+ test:
31
+ runs-on: ubuntu-latest
32
+ strategy:
33
+ fail-fast: false
34
+ matrix:
35
+ # 3.9 is the floor the package claims. Tested for real here, because mypy cannot target it
36
+ # — see the note in pyproject.toml.
37
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - uses: actions/setup-python@v5
41
+ with:
42
+ python-version: ${{ matrix.python-version }}
43
+ - run: pip install -e ".[dev]"
44
+ - run: pytest -q
45
+
46
+ build:
47
+ runs-on: ubuntu-latest
48
+ needs: [lint, test]
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+ - uses: actions/setup-python@v5
52
+ with:
53
+ python-version: "3.12"
54
+ - run: pip install build twine
55
+ - run: python -m build
56
+ - run: twine check dist/*
57
+ - uses: actions/upload-artifact@v4
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+
62
+ publish:
63
+ # Publishes only on a v* tag, and only after lint + test + build are green.
64
+ if: startsWith(github.ref, 'refs/tags/v')
65
+ runs-on: ubuntu-latest
66
+ needs: [build]
67
+ environment: pypi
68
+ permissions:
69
+ # Trusted publishing: PyPI verifies this workflow's identity directly. No API token is
70
+ # stored in the repo.
71
+ id-token: write
72
+ steps:
73
+ - uses: actions/checkout@v4
74
+ - uses: actions/setup-python@v5
75
+ with:
76
+ python-version: "3.12"
77
+ - run: pip install build
78
+ - run: python -m build
79
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ While the version is below 1.0.0, minor releases may change the Python surface. The underlying
9
+ `/api/v1` HTTP contract is additive-only.
10
+
11
+ ## [Unreleased]
12
+
13
+ ## [0.1.0] - 2026-07-29
14
+
15
+ First release.
16
+
17
+ ### Added
18
+
19
+ - `SafeNode` (blocking) and `AsyncSafeNode` (awaitable) clients with an identical surface.
20
+ - `evaluate()` returning a `Result` with `decision`, `impact_score`, `risk_score`,
21
+ `matched_policies`, `reasons`, `alternatives`, `trace_id`, `degraded`, and `raw`, plus
22
+ `allowed` / `warned` / `needs_review` / `denied` / `permitted` / `blocked` properties.
23
+ - `guard()` context manager and `@guarded()` decorator, raising `PolicyDenied` on `deny` and
24
+ `review`. Decorated functions send nothing about their arguments unless given an explicit mapper.
25
+ - Three fail modes via `on_unavailable`: `fail_open` (default), `fail_closed`, `raise`. Degraded
26
+ results are marked in three independent ways — `degraded=True`, `trace_id=None`, and
27
+ `reasons=["safenode_unavailable"]` — so they can never be miscounted as policy decisions.
28
+ - Client-side `Redactor` covering emails, Luhn-validated credit cards, US SSNs, provider API key
29
+ prefixes (`sk-`, `ghp_`, `xoxb-`, `AKIA`, `AIza`), bearer tokens, PEM private key blocks, and
30
+ phone numbers. Extensible with custom rules, key allowlists, and forced-redaction keys.
31
+ - Redaction counts reported to policy in `context.safenode_redactions`, so client-side redaction
32
+ cannot silently disable the server-side `sensitive_data` rule.
33
+ - Three payload modes: `full`, `redacted` (default), and `metadata_only`. `metadata_only` preserves
34
+ key names — key-based policy rules keep working — and hashes every value.
35
+ - `build_request()` dry run: returns the exact body that would be sent, with no network call.
36
+ - Non-blocking `evaluate_async()` / `evaluate_nowait()` for audit-and-alert, backed by a bounded
37
+ drop-oldest queue.
38
+ - Distinct `RateLimitError` (retryable) and `QuotaExceededError` (not retryable) for the two
39
+ unrelated conditions the API returns as HTTP 429.
40
+ - `correlation_id` pass-through and optional OpenTelemetry spans.
41
+ - Typed throughout, `py.typed` shipped, `mypy --strict` clean.
42
+
43
+ ### Notes
44
+
45
+ - Published as `safenode-sdk`; the import name is `safenode_sdk`. The `safenode` name on PyPI is
46
+ held by an unrelated project.
47
+ - No telemetry, no phone-home, no analytics.
48
+
49
+ [Unreleased]: https://github.com/sp3ak/safenode-tech/compare/v0.1.0...HEAD
50
+ [0.1.0]: https://github.com/sp3ak/safenode-tech/releases/tag/v0.1.0
@@ -0,0 +1,48 @@
1
+ # Contributing
2
+
3
+ Thanks for looking. This is maintained by one person, so a few notes on what helps.
4
+
5
+ ## Most useful contribution
6
+
7
+ **Contract bugs.** If the SDK and the SafeNode API disagree — a field that is not the type we
8
+ claim, an error shape we mishandle, a status code we map wrong — that is the highest-value issue you
9
+ can file. Include the `trace_id` if you have one.
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ cd sdk/python
15
+ python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
16
+ pip install -e ".[dev]"
17
+ pytest -q
18
+ ```
19
+
20
+ ## Before opening a PR
21
+
22
+ ```bash
23
+ ruff check . && ruff format --check . && mypy && pytest -q
24
+ ```
25
+
26
+ All four must pass. CI runs the same, plus the test suite on Python 3.9 through 3.13.
27
+
28
+ ## House rules
29
+
30
+ - **No new required dependencies.** `httpx` is the entire runtime tree and it stays that way.
31
+ Optional extras are fine if they degrade gracefully when absent.
32
+ - **No telemetry, ever.** Non-negotiable for a security tool.
33
+ - **Keep the sync and async clients identical.** Same option names, same result fields, same
34
+ behaviour. Anything in one that is not in the other needs a reason in the PR.
35
+ - **Every fail path needs a test.** Timeouts, 5xx, both flavours of 429, and each `on_unavailable`
36
+ mode.
37
+ - **Do not add retries to the timeout path.** Every evaluate call is a server-side write; a
38
+ timed-out call may already have been recorded. This is deliberate, not an oversight.
39
+ - Tests use `httpx.MockTransport`. No live API calls in CI, ever.
40
+
41
+ ## Commit and PR style
42
+
43
+ Small PRs, one concern each. Describe what breaks if the change is wrong — that is more useful than
44
+ describing what it does.
45
+
46
+ ## Questions
47
+
48
+ Open an issue. For security reports, see [SECURITY.md](SECURITY.md) instead.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Seedling Systems LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,484 @@
1
+ Metadata-Version: 2.4
2
+ Name: safenode-sdk
3
+ Version: 0.1.0
4
+ Summary: Policy firewall for AI agent actions. Evaluate what your agent is about to do, before it does it.
5
+ Project-URL: Homepage, https://safenode.tech
6
+ Project-URL: Documentation, https://safenode.tech/docs
7
+ Project-URL: Repository, https://github.com/sp3ak/safenode-tech
8
+ Project-URL: Changelog, https://github.com/sp3ak/safenode-tech/blob/main/CHANGELOG.md
9
+ Project-URL: API Spec, https://safenode.tech/openapi.yaml
10
+ Author: SafeNode
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: agent-security,ai-agents,ai-safety,guardrails,llm,llmops,mcp,policy-engine
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Security
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: httpx<1,>=0.23
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.8; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
31
+ Requires-Dist: pytest>=7.4; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6; extra == 'dev'
33
+ Provides-Extra: otel
34
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # SafeNode Python SDK
38
+
39
+ Evaluate what your AI agent is about to do, before it does it.
40
+
41
+ ```bash
42
+ pip install safenode-sdk
43
+ ```
44
+
45
+ ```python
46
+ import os
47
+ from safenode_sdk import SafeNode
48
+
49
+ sn = SafeNode(api_key=os.environ["SAFENODE_API_KEY"])
50
+
51
+ result = sn.evaluate(
52
+ "send_email",
53
+ payload={"to": "customer@example.com", "subject": "Your refund"},
54
+ context={"vendor_id": "sendgrid", "cost_estimate": 0.01},
55
+ )
56
+
57
+ if result.denied:
58
+ raise RuntimeError(f"Blocked: {result.reasons} (trace {result.trace_id})")
59
+
60
+ send_the_email()
61
+ ```
62
+
63
+ That is the whole idea. Your agent proposes an action, SafeNode returns `allow`, `warn`, `review`, or
64
+ `deny` based on policies you configure in a dashboard, and your code branches on it.
65
+
66
+ [Get an API key — free tier, no card](https://safenode.tech) ·
67
+ [Docs](https://safenode.tech/docs) ·
68
+ [OpenAPI spec](https://safenode.tech/openapi.yaml)
69
+
70
+ ---
71
+
72
+ ## Contents
73
+
74
+ - [What this is not](#what-this-is-not)
75
+ - [Fail behaviour](#fail-behaviour-read-this-one)
76
+ - [Latency](#latency)
77
+ - [What data leaves your infrastructure](#what-data-leaves-your-infrastructure)
78
+ - [Enforcement helpers](#enforcement-helpers)
79
+ - [Non-blocking mode](#non-blocking-mode)
80
+ - [Error handling](#error-handling)
81
+ - [Why not just write if-statements](#why-not-just-write-if-statements)
82
+ - [Why not OPA](#why-not-opa)
83
+ - [FAQ](#faq)
84
+ - [API reference](#api-reference)
85
+
86
+ ---
87
+
88
+ ## What this is not
89
+
90
+ Being clear about this up front saves everyone time.
91
+
92
+ - **Not a jailbreak or prompt-injection detector.** It evaluates *actions*, not prompts. If your
93
+ agent has been talked into wiring money, SafeNode can stop the wire — it will not tell you the
94
+ agent was manipulated.
95
+ - **Not a sandbox.** Nothing here contains a process, restricts syscalls, or limits filesystem
96
+ access. It returns a decision; enforcing it is your code's job. (The
97
+ [MCP gateway](https://safenode.tech/docs) is the exception — it enforces by not forwarding.)
98
+ - **Not a local rules engine.** Every `evaluate()` is a network call. There is no offline mode and
99
+ no local policy evaluation.
100
+ - **Not a replacement for authz.** SafeNode answers "should this action happen at all, under these
101
+ circumstances", not "is this user allowed to do this". You still need both.
102
+ - **Not free of side effects.** Every evaluation is recorded server-side and counts against your
103
+ monthly allowance.
104
+
105
+ ---
106
+
107
+ ## Fail behaviour (read this one)
108
+
109
+ The single most consequential setting. When SafeNode is unreachable — network error, timeout, or a
110
+ 5xx — the SDK does one of three things:
111
+
112
+ | `on_unavailable` | Behaviour |
113
+ | --- | --- |
114
+ | `"fail_open"` **(default)** | Returns a synthetic `allow` with `degraded=True`. Your agent keeps working, unevaluated. |
115
+ | `"fail_closed"` | Returns a synthetic `deny` with `degraded=True`. Your agent stops. |
116
+ | `"raise"` | Raises `Unavailable` and lets you decide. |
117
+
118
+ **The default is `fail_open`, and that is a deliberate tradeoff.** A security tool that takes your
119
+ production down during its own first outage does not get a second chance. But it means a SafeNode
120
+ outage silently becomes an unpoliced window.
121
+
122
+ Choose per action class rather than globally. That is usually the right answer:
123
+
124
+ ```python
125
+ audit = SafeNode(api_key=key, on_unavailable="fail_open") # read-only, logging
126
+ gate = SafeNode(api_key=key, on_unavailable="fail_closed") # payments, deletions, outbound mail
127
+ ```
128
+
129
+ Degraded results are distinguishable from real decisions in three independent ways, so they can
130
+ never be quietly counted as policy allows:
131
+
132
+ ```python
133
+ result.degraded is True
134
+ result.trace_id is None # a real decision always has one
135
+ result.reasons == ["safenode_unavailable"]
136
+ ```
137
+
138
+ The SDK also logs a one-time warning at `WARNING` level the first time it fails open.
139
+
140
+ ---
141
+
142
+ ## Latency
143
+
144
+ Client-side budget, both configurable:
145
+
146
+ | | Default |
147
+ | --- | --- |
148
+ | Total request timeout | **2000 ms** |
149
+ | Connect timeout | **500 ms** |
150
+ | Retries | **0** |
151
+
152
+ ```python
153
+ sn = SafeNode(api_key=key, timeout=0.75, connect_timeout=0.2)
154
+ ```
155
+
156
+ **Timed-out calls are never retried, and this is not configurable.** Every evaluation is a
157
+ server-side write, so a request that timed out may already have been recorded. Retrying it would
158
+ double-count your metered usage, duplicate your decision feed, and double the worst-case latency of
159
+ a call sitting in front of a user-visible action.
160
+
161
+ Opt-in retries (`retries=2`) apply only to throttling and 5xx, with jittered exponential backoff.
162
+
163
+ > **Server-side p99 is not published yet.** We are not going to print a number we have not measured
164
+ > under realistic load. If a hard latency budget matters to you, measure it against your own
165
+ > workload and [tell us what you see](https://safenode.tech). If you cannot afford the round trip at
166
+ > all, use [non-blocking mode](#non-blocking-mode).
167
+
168
+ ---
169
+
170
+ ## What data leaves your infrastructure
171
+
172
+ You are being asked to send descriptions of your agent's actions to a third party. Here is exactly
173
+ what that means.
174
+
175
+ **Everything sent is stored.** SafeNode persists the full envelope server-side for the audit trail
176
+ and decision feed. Assume anything you send is retained.
177
+
178
+ Three payload modes control what that is:
179
+
180
+ | `payload_mode` | What is sent |
181
+ | --- | --- |
182
+ | `"full"` | Payload values verbatim. |
183
+ | `"redacted"` **(default)** | Payload values scrubbed client-side first. |
184
+ | `"metadata_only"` | No payload values at all — key names and value hashes only. |
185
+
186
+ `context` is **always sent unredacted**, in every mode. This is load-bearing: vendor, region, and
187
+ spend gating are all driven by context, and scrubbing it would break them. Do not put secrets in
188
+ `context`.
189
+
190
+ ### Inspect exactly what would be sent
191
+
192
+ `build_request()` is a dry run. No network call, no side effects:
193
+
194
+ ```python
195
+ >>> sn.build_request("send_email", {"to": "alice@corp.com", "note": "card 4111111111111111"})
196
+ {'action_type': 'send_email',
197
+ 'payload': {'to': '[REDACTED:email]', 'note': 'card [REDACTED:credit_card]'},
198
+ 'context': {'safenode_payload_mode': 'redacted',
199
+ 'safenode_redactions': {'email': 1, 'credit_card': 1}}}
200
+ ```
201
+
202
+ Default rules cover emails, credit cards (Luhn-validated, so order numbers survive), US SSNs,
203
+ provider API key prefixes (`sk-`, `ghp_`, `xoxb-`, `AKIA`, `AIza`), bearer tokens, PEM private key
204
+ blocks, and phone numbers.
205
+
206
+ ```python
207
+ from safenode_sdk import Redactor, RedactionRule
208
+ import re
209
+
210
+ sn = SafeNode(
211
+ api_key=key,
212
+ redactor=Redactor(
213
+ extra_rules=[RedactionRule("employee_id", re.compile(r"\bEMP-\d{5}\b"))],
214
+ allowlist_keys=["vendor_id"], # never redact these values
215
+ redact_keys=["internal_note"], # always strip these, whatever they contain
216
+ disabled_rules=["phone"],
217
+ ),
218
+ )
219
+ ```
220
+
221
+ ### Why redaction counts are sent
222
+
223
+ Notice `safenode_redactions` in the dry run above. The SDK reports *how many* values of each type it
224
+ stripped, and this is not optional.
225
+
226
+ SafeNode's server-side `sensitive_data` rule matches patterns against payload values. If the SDK
227
+ scrubbed those values and said nothing, a policy of "deny any action containing a card number" would
228
+ silently start passing — the client-side privacy feature would have disabled the server-side security
229
+ control. Reporting counts closes that hole: policy can act on the *presence* of a card number without
230
+ ever receiving one.
231
+
232
+ If you use `sensitive_data` with `patterns`, pair it with a `redaction_metadata` rule covering the
233
+ same types.
234
+
235
+ These counts are self-reported by the client. They raise the floor for honest callers; they are not a
236
+ defence against a hostile one, which could simply send an empty payload.
237
+
238
+ ### `metadata_only`
239
+
240
+ For when payload values must not leave your network at all:
241
+
242
+ ```python
243
+ sn = SafeNode(api_key=key, payload_mode="metadata_only")
244
+ ```
245
+
246
+ Key names are preserved (so key-based rules keep working) and every value becomes a truncated
247
+ SHA-256. **Eight of SafeNode's nine rule types are context-driven and work identically in this
248
+ mode** — only pattern-based `sensitive_data` degrades, and the redaction counts partly cover it.
249
+
250
+ Hashes let you correlate identical values across requests. They are not a privacy guarantee for
251
+ low-entropy values: anyone who guesses an email address can confirm it. Pass `hash_salt="..."` to
252
+ prevent cross-tenant correlation.
253
+
254
+ ### No telemetry
255
+
256
+ This package makes exactly one network call — to the SafeNode API, when you call `evaluate()`. No
257
+ analytics, no phone-home, no crash reporting, no install-time scripts. For a security tool anything
258
+ else would be disqualifying.
259
+
260
+ ---
261
+
262
+ ## Enforcement helpers
263
+
264
+ `evaluate()` returns a decision. These raise instead.
265
+
266
+ ```python
267
+ from safenode_sdk import PolicyDenied
268
+
269
+ # Context manager
270
+ with sn.guard("delete_records", {"table": "users", "count": 400}) as decision:
271
+ delete_records()
272
+ log.info("approved", trace_id=decision.trace_id)
273
+
274
+
275
+ # Decorator. Arguments are only sent if you map them.
276
+ @sn.guarded("send_email", payload=lambda to, body: {"to": to})
277
+ def send_email(to: str, body: str) -> None: ...
278
+ ```
279
+
280
+ Both raise `PolicyDenied` on `deny` and `review`. `warn` proceeds by default; pass
281
+ `allow_warn=False` to treat warnings as blocking.
282
+
283
+ Branch on the boolean properties rather than comparing strings:
284
+
285
+ ```python
286
+ result.allowed # allow
287
+ result.warned # warn
288
+ result.needs_review # review
289
+ result.denied # deny
290
+ result.permitted # allow or warn — "may proceed"
291
+ result.blocked # review or deny — "must not proceed"
292
+ ```
293
+
294
+ `permitted` exists because `if result.allowed` silently blocks every `warn`, which is rarely what
295
+ people mean the first time.
296
+
297
+ ### Async
298
+
299
+ `AsyncSafeNode` mirrors the sync surface exactly:
300
+
301
+ ```python
302
+ from safenode_sdk import AsyncSafeNode
303
+
304
+ async with AsyncSafeNode(api_key=key) as sn:
305
+ result = await sn.evaluate("call_model", {"prompt": prompt})
306
+
307
+ async with sn.guard("send_email", {"to": addr}) as decision:
308
+ await send(addr)
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Non-blocking mode
314
+
315
+ For audit-and-alert when you cannot afford a round trip in a hot path:
316
+
317
+ ```python
318
+ sn.evaluate_async("call_model", {"prompt": prompt}) # returns immediately
319
+ ```
320
+
321
+ **This cannot gate anything** — you get no decision back. It records the action and lets policy
322
+ violations surface in your dashboard and alerts after the fact.
323
+
324
+ Work goes to a single background thread behind a bounded queue (default 1000). When the queue is
325
+ full the oldest pending item is dropped and a warning is logged. A SafeNode outage can never become
326
+ your memory leak. `AsyncSafeNode.evaluate_nowait()` is the asyncio equivalent.
327
+
328
+ ---
329
+
330
+ ## Error handling
331
+
332
+ ```python
333
+ from safenode_sdk import (
334
+ SafeNodeError, # base — catching this contains the SDK entirely
335
+ ConfigurationError, # bad options, raised at construction
336
+ AuthError, # 401 — bad, expired, or unbound key
337
+ ValidationError, # 422 — server rejected the request
338
+ PayloadTooLargeError, # 422 — payload or context over 256 KiB
339
+ RateLimitError, # 429 — throttled. RETRYABLE after .retry_after
340
+ QuotaExceededError, # 429 — monthly cap exhausted. NOT retryable
341
+ Unavailable, # unreachable (only raised when on_unavailable="raise")
342
+ PolicyDenied, # raised by guard()/guarded(), never by evaluate()
343
+ )
344
+ ```
345
+
346
+ **The one that will bite you:** SafeNode returns HTTP 429 for two unrelated conditions. Throttling is
347
+ transient and retryable. Monthly quota exhaustion is not — it stays failing until your next billing
348
+ month, and retrying with backoff will just fail for days. The SDK discriminates on the response body
349
+ and raises different types, so you do not have to:
350
+
351
+ ```python
352
+ try:
353
+ result = sn.evaluate("send_email", payload)
354
+ except QuotaExceededError as e:
355
+ alert(f"SafeNode quota exhausted: {e.evaluations_used}/{e.evaluations_cap}") # upgrade
356
+ except RateLimitError as e:
357
+ backoff(e.retry_after) # retry later
358
+ ```
359
+
360
+ `evaluate()` never raises on a `deny` — a denial is a successful evaluation. Use `guard()` if you
361
+ want the exception.
362
+
363
+ ---
364
+
365
+ ## Why not just write if-statements
366
+
367
+ For one rule in one codebase, honestly, write the if-statement. This earns its place when:
368
+
369
+ - **The rules change more often than the code.** Policy lives in a dashboard; a non-engineer can
370
+ tighten a spend limit without a deploy.
371
+ - **You need the audit trail.** Every decision is recorded with a `trace_id`, the inputs, and the
372
+ matched rules. Reconstructing "why did the agent do that on the 14th" from application logs is
373
+ work you will do exactly once before wishing you had this.
374
+ - **Enforcement has to be consistent across agents.** Five agents in three languages plus some n8n
375
+ workflows will not stay consistent by convention.
376
+ - **`review` is a real state.** Human-in-the-loop approval queues are a meaningful amount of code to
377
+ build, and an if-statement cannot return "ask someone".
378
+
379
+ If none of those apply, use the if-statement. It is faster and has no failure mode.
380
+
381
+ ---
382
+
383
+ ## Why not OPA
384
+
385
+ Open Policy Agent is a good tool and solves an overlapping problem. Genuine differences:
386
+
387
+ - **Rego is a language.** Someone on your team has to learn and maintain it. SafeNode's rules are
388
+ configured in a UI, which is a real limitation as well as a real advantage.
389
+ - **Scoring vs. boolean.** OPA answers yes/no. SafeNode returns weighted impact and risk scores
390
+ banded into four outcomes, including `review`. If you want a human approval step for medium-risk
391
+ actions, that is native here and something you would build yourself on OPA.
392
+ - **Batteries for this specific domain.** Vendor registries, spend thresholds, region gating, and
393
+ business-hours rules ship working. On OPA they are Rego you write.
394
+ - **OPA runs locally.** That is a genuine OPA advantage: no network call and no third party. If
395
+ sub-millisecond local evaluation is a hard requirement, use OPA.
396
+
397
+ They compose. OPA for infrastructure authz, SafeNode for agent actions, is a reasonable architecture.
398
+
399
+ ---
400
+
401
+ ## FAQ
402
+
403
+ **Is there a self-hosted or VPC deployment?**
404
+ Not yet. It is planned, with no committed date. Today SafeNode is hosted only. If this is a blocker,
405
+ [say so](https://safenode.tech) — it moves the roadmap.
406
+
407
+ **What is the API stability commitment?**
408
+ `/api/v1` is additive-only. New response fields may appear; existing fields will not change type or
409
+ disappear without a new version path. Unknown fields are preserved on `result.raw`, so a server-side
410
+ addition cannot break your build. The SDK follows semver and is pre-1.0 — minor versions may change
411
+ the Python surface until 1.0.0.
412
+
413
+ **What are the rate limits?**
414
+ 60 requests/minute per API key by default. Separately, each plan has a monthly evaluation cap; see
415
+ `QuotaExceededError` above.
416
+
417
+ **Does `action_type` have to come from a fixed list?**
418
+ No, it is free-form (max 255 characters). Rules match on exact strings, so pick stable names and keep
419
+ them consistent. `send_email`, `call_model`, `mcp_tool_call`, `run_shell_command` are conventions,
420
+ not requirements.
421
+
422
+ **Do I need to send `agent_id`?**
423
+ No. The API key already identifies the agent.
424
+
425
+ **Does this work with LangChain / CrewAI / n8n?**
426
+ Not yet as a first-party adapter. `guarded()` wraps a tool function in three lines meanwhile. Tell us
427
+ which one you need.
428
+
429
+ ---
430
+
431
+ ## API reference
432
+
433
+ ### `SafeNode(api_key, **options)`
434
+
435
+ | Option | Default | Notes |
436
+ | --- | --- | --- |
437
+ | `api_key` | required | Your `sn_...` key |
438
+ | `base_url` | `https://safenode.tech` | For staging |
439
+ | `on_unavailable` | `"fail_open"` | `fail_open` \| `fail_closed` \| `raise` |
440
+ | `timeout` | `2.0` | Total seconds |
441
+ | `connect_timeout` | `0.5` | Seconds |
442
+ | `payload_mode` | `"redacted"` | `full` \| `redacted` \| `metadata_only` |
443
+ | `redactor` | `Redactor()` | Custom rules |
444
+ | `retries` | `0` | 429/5xx only, never timeouts |
445
+ | `static_context` | `None` | Merged into every request |
446
+ | `agent_id` | `None` | Usually unnecessary |
447
+ | `hash_salt` | `""` | For `metadata_only` |
448
+ | `async_queue_size` | `1000` | Background queue bound |
449
+ | `tracing` | `True` | OTel span if the API is installed |
450
+ | `transport` | `None` | Custom httpx transport (proxies, mTLS) |
451
+
452
+ ### `evaluate(action_type, payload=None, context=None, *, payload_mode=None, correlation_id=None, agent_id=None) -> Result`
453
+
454
+ `correlation_id` is passed through in `context` so you can join SafeNode decisions to your own logs.
455
+
456
+ ### `Result`
457
+
458
+ `decision`, `impact_score`, `risk_score` (both **0–100**), `matched_policies`, `reasons`,
459
+ `alternatives`, `trace_id`, `degraded`, `raw`, plus the boolean properties above.
460
+
461
+ Two server-side details worth knowing: `alternatives` is never empty — a `general` suggestion is
462
+ always appended, including on `allow` — and `matched_policies` is empty on hard-rule denials, so use
463
+ `reasons` for attribution.
464
+
465
+ ### OpenTelemetry
466
+
467
+ If `opentelemetry-api` is installed, each evaluation emits a `safenode.evaluate` span with
468
+ `safenode.decision`, `safenode.degraded`, and `safenode.trace_id`. Optional; never required. Disable
469
+ with `tracing=False`.
470
+
471
+ ---
472
+
473
+ ## Contributing
474
+
475
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports welcome, especially about the contract — if the
476
+ SDK and the API disagree, that is a bug worth filing.
477
+
478
+ ## Security
479
+
480
+ See [SECURITY.md](SECURITY.md). Please do not open public issues for vulnerabilities.
481
+
482
+ ## License
483
+
484
+ MIT. See [LICENSE](LICENSE).