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.
- safenode_sdk-0.1.0/.github/workflows/ci.yml +79 -0
- safenode_sdk-0.1.0/.gitignore +9 -0
- safenode_sdk-0.1.0/CHANGELOG.md +50 -0
- safenode_sdk-0.1.0/CONTRIBUTING.md +48 -0
- safenode_sdk-0.1.0/LICENSE +21 -0
- safenode_sdk-0.1.0/PKG-INFO +484 -0
- safenode_sdk-0.1.0/README.md +448 -0
- safenode_sdk-0.1.0/SECURITY.md +44 -0
- safenode_sdk-0.1.0/examples/01_basic_gate.py +71 -0
- safenode_sdk-0.1.0/examples/02_agent_tool_loop.py +96 -0
- safenode_sdk-0.1.0/examples/03_redaction_dry_run.py +81 -0
- safenode_sdk-0.1.0/examples/README.md +17 -0
- safenode_sdk-0.1.0/pyproject.toml +94 -0
- safenode_sdk-0.1.0/src/safenode_sdk/__init__.py +64 -0
- safenode_sdk-0.1.0/src/safenode_sdk/_envelope.py +104 -0
- safenode_sdk-0.1.0/src/safenode_sdk/_version.py +1 -0
- safenode_sdk-0.1.0/src/safenode_sdk/client.py +743 -0
- safenode_sdk-0.1.0/src/safenode_sdk/errors.py +172 -0
- safenode_sdk-0.1.0/src/safenode_sdk/models.py +153 -0
- safenode_sdk-0.1.0/src/safenode_sdk/py.typed +0 -0
- safenode_sdk-0.1.0/src/safenode_sdk/redaction.py +219 -0
- safenode_sdk-0.1.0/tests/__init__.py +0 -0
- safenode_sdk-0.1.0/tests/conftest.py +69 -0
- safenode_sdk-0.1.0/tests/test_client.py +482 -0
- safenode_sdk-0.1.0/tests/test_envelope.py +147 -0
- safenode_sdk-0.1.0/tests/test_redaction.py +153 -0
|
@@ -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,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).
|