nodra-agent-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,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
https://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Nodra
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nodra-agent-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for protecting autonomous AI-agent actions with Nodra.
|
|
5
|
+
Project-URL: Homepage, https://nodra-kappa.vercel.app
|
|
6
|
+
Project-URL: Repository, https://github.com/Only-time-hash/Nodra
|
|
7
|
+
Project-URL: Documentation, https://nodra-kappa.vercel.app/docs
|
|
8
|
+
Author: Nodra
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agentic-ai,agents,ai,authorization,llm,mcp,nodra,security
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Nodra Python SDK
|
|
26
|
+
|
|
27
|
+
Protect consequential Python AI-agent actions with Nodra's deterministic authorization gateway.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install nodra-agent-sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The distribution name is `nodra-agent-sdk`; the Python import is `nodra`.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import os
|
|
41
|
+
from nodra import Nodra
|
|
42
|
+
|
|
43
|
+
nodra = Nodra(
|
|
44
|
+
base_url=os.environ["NODRA_BASE_URL"],
|
|
45
|
+
credential=os.environ["NODRA_CREDENTIAL"],
|
|
46
|
+
timeout_seconds=8,
|
|
47
|
+
max_retries=2,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
finance = nodra.protect("finance-agent")
|
|
51
|
+
|
|
52
|
+
decision = finance.authorize(
|
|
53
|
+
resource_id="stripe",
|
|
54
|
+
action="payments.submit",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if decision["decision"] == "allow":
|
|
58
|
+
submit_payment()
|
|
59
|
+
|
|
60
|
+
if decision["decision"] == "require-approval":
|
|
61
|
+
print("Waiting for human approval:", decision["authorizationEventId"])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Record evidence
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
finance.intent(
|
|
68
|
+
"stripe",
|
|
69
|
+
"payments.submit",
|
|
70
|
+
decision="allow",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
result = submit_payment()
|
|
74
|
+
|
|
75
|
+
finance.result(
|
|
76
|
+
"stripe",
|
|
77
|
+
"payments.submit",
|
|
78
|
+
decision="allow",
|
|
79
|
+
executed=True,
|
|
80
|
+
outcome="succeeded",
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Automatic approval continuation
|
|
85
|
+
|
|
86
|
+
A protected Python runtime can wait for the reviewer and resume without a human copying a token:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
final_decision = finance.authorize_and_wait(
|
|
90
|
+
resource_id="stripe",
|
|
91
|
+
action="payments.submit",
|
|
92
|
+
timeout_seconds=300,
|
|
93
|
+
poll_interval_seconds=1.5,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if final_decision["decision"] == "allow":
|
|
97
|
+
submit_payment()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The runtime generates the one-time token locally, claims the approved authorization idempotently, and consumes it through Nodra. Plaintext approval tokens are not stored by Nodra.
|
|
101
|
+
|
|
102
|
+
Low-level `wait_for_approval()` and `execute_approved()` methods remain available when an application needs explicit control of the workflow.
|
|
103
|
+
|
|
104
|
+
## Error handling
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from nodra import NodraError
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
finance.authorize("stripe", "payments.submit")
|
|
111
|
+
except NodraError as error:
|
|
112
|
+
print(error.code, error.status, error.request_id, error.retryable)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Security
|
|
116
|
+
|
|
117
|
+
- Keep `NODRA_CREDENTIAL` in server-side secret storage.
|
|
118
|
+
- Never embed it in browser/mobile code.
|
|
119
|
+
- Every request is HMAC signed with a timestamp and nonce.
|
|
120
|
+
- Safe authorization/event requests use bounded retries.
|
|
121
|
+
- One-time approved execution never retries automatically.
|
|
122
|
+
- Nodra remains fail-closed when the gateway cannot authorize an action.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Nodra Python SDK
|
|
2
|
+
|
|
3
|
+
Protect consequential Python AI-agent actions with Nodra's deterministic authorization gateway.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nodra-agent-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The distribution name is `nodra-agent-sdk`; the Python import is `nodra`.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from nodra import Nodra
|
|
18
|
+
|
|
19
|
+
nodra = Nodra(
|
|
20
|
+
base_url=os.environ["NODRA_BASE_URL"],
|
|
21
|
+
credential=os.environ["NODRA_CREDENTIAL"],
|
|
22
|
+
timeout_seconds=8,
|
|
23
|
+
max_retries=2,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
finance = nodra.protect("finance-agent")
|
|
27
|
+
|
|
28
|
+
decision = finance.authorize(
|
|
29
|
+
resource_id="stripe",
|
|
30
|
+
action="payments.submit",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if decision["decision"] == "allow":
|
|
34
|
+
submit_payment()
|
|
35
|
+
|
|
36
|
+
if decision["decision"] == "require-approval":
|
|
37
|
+
print("Waiting for human approval:", decision["authorizationEventId"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Record evidence
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
finance.intent(
|
|
44
|
+
"stripe",
|
|
45
|
+
"payments.submit",
|
|
46
|
+
decision="allow",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
result = submit_payment()
|
|
50
|
+
|
|
51
|
+
finance.result(
|
|
52
|
+
"stripe",
|
|
53
|
+
"payments.submit",
|
|
54
|
+
decision="allow",
|
|
55
|
+
executed=True,
|
|
56
|
+
outcome="succeeded",
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Automatic approval continuation
|
|
61
|
+
|
|
62
|
+
A protected Python runtime can wait for the reviewer and resume without a human copying a token:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
final_decision = finance.authorize_and_wait(
|
|
66
|
+
resource_id="stripe",
|
|
67
|
+
action="payments.submit",
|
|
68
|
+
timeout_seconds=300,
|
|
69
|
+
poll_interval_seconds=1.5,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if final_decision["decision"] == "allow":
|
|
73
|
+
submit_payment()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The runtime generates the one-time token locally, claims the approved authorization idempotently, and consumes it through Nodra. Plaintext approval tokens are not stored by Nodra.
|
|
77
|
+
|
|
78
|
+
Low-level `wait_for_approval()` and `execute_approved()` methods remain available when an application needs explicit control of the workflow.
|
|
79
|
+
|
|
80
|
+
## Error handling
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from nodra import NodraError
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
finance.authorize("stripe", "payments.submit")
|
|
87
|
+
except NodraError as error:
|
|
88
|
+
print(error.code, error.status, error.request_id, error.retryable)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Security
|
|
92
|
+
|
|
93
|
+
- Keep `NODRA_CREDENTIAL` in server-side secret storage.
|
|
94
|
+
- Never embed it in browser/mobile code.
|
|
95
|
+
- Every request is HMAC signed with a timestamp and nonce.
|
|
96
|
+
- Safe authorization/event requests use bounded retries.
|
|
97
|
+
- One-time approved execution never retries automatically.
|
|
98
|
+
- Nodra remains fail-closed when the gateway cannot authorize an action.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nodra-agent-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for protecting autonomous AI-agent actions with Nodra."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Nodra" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"ai",
|
|
17
|
+
"agents",
|
|
18
|
+
"agentic-ai",
|
|
19
|
+
"security",
|
|
20
|
+
"authorization",
|
|
21
|
+
"mcp",
|
|
22
|
+
"llm",
|
|
23
|
+
"nodra"
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Topic :: Security",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
36
|
+
]
|
|
37
|
+
dependencies = []
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://nodra-kappa.vercel.app"
|
|
41
|
+
Repository = "https://github.com/Only-time-hash/Nodra"
|
|
42
|
+
Documentation = "https://nodra-kappa.vercel.app/docs"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/nodra"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
include = [
|
|
49
|
+
"/src",
|
|
50
|
+
"/README.md",
|
|
51
|
+
"/LICENSE",
|
|
52
|
+
"/pyproject.toml"
|
|
53
|
+
]
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import secrets
|
|
8
|
+
import socket
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
SDK_VERSION = "0.1.0"
|
|
17
|
+
DEFAULT_TIMEOUT_SECONDS = 8.0
|
|
18
|
+
DEFAULT_MAX_RETRIES = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NodraError(RuntimeError):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
*,
|
|
26
|
+
code: str,
|
|
27
|
+
status: int | None = None,
|
|
28
|
+
request_id: str | None = None,
|
|
29
|
+
retryable: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.code = code
|
|
33
|
+
self.status = status
|
|
34
|
+
self.request_id = request_id
|
|
35
|
+
self.retryable = retryable
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_base_url(value: str) -> str:
|
|
39
|
+
parsed = urllib.parse.urlparse(value)
|
|
40
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
41
|
+
raise NodraError(
|
|
42
|
+
"Nodra base_url must be an absolute http(s) URL.",
|
|
43
|
+
code="invalid_base_url",
|
|
44
|
+
)
|
|
45
|
+
return value.rstrip("/")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_required(value: str, field: str) -> None:
|
|
49
|
+
if not isinstance(value, str) or not value.strip():
|
|
50
|
+
raise NodraError(f"{field} is required.", code="invalid_request")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _retryable_status(status: int) -> bool:
|
|
54
|
+
return status in {408, 425, 429} or status >= 500
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Nodra:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
base_url: str,
|
|
61
|
+
credential: str,
|
|
62
|
+
workspace_id: str | None = None,
|
|
63
|
+
*,
|
|
64
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
65
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
66
|
+
) -> None:
|
|
67
|
+
if not isinstance(credential, str) or len(credential) < 32:
|
|
68
|
+
raise NodraError(
|
|
69
|
+
"Nodra credential must contain at least 32 characters.",
|
|
70
|
+
code="credential_too_short",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.base_url = _normalize_base_url(base_url)
|
|
74
|
+
self.workspace_id = workspace_id
|
|
75
|
+
self.credential = credential
|
|
76
|
+
self.timeout_seconds = max(0.25, float(timeout_seconds))
|
|
77
|
+
self.max_retries = max(0, min(5, int(max_retries)))
|
|
78
|
+
|
|
79
|
+
def protect(self, agent_id: str) -> "NodraAgent":
|
|
80
|
+
_validate_required(agent_id, "agent_id")
|
|
81
|
+
return NodraAgent(self, agent_id)
|
|
82
|
+
|
|
83
|
+
def authorize(
|
|
84
|
+
self,
|
|
85
|
+
agent_id: str,
|
|
86
|
+
resource_id: str,
|
|
87
|
+
action: str,
|
|
88
|
+
*,
|
|
89
|
+
context: dict[str, Any] | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
return self.protect(agent_id).authorize(
|
|
92
|
+
resource_id,
|
|
93
|
+
action,
|
|
94
|
+
context=context,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def execute_approved(
|
|
98
|
+
self,
|
|
99
|
+
agent_id: str,
|
|
100
|
+
resource_id: str,
|
|
101
|
+
action: str,
|
|
102
|
+
authorization_event_id: str,
|
|
103
|
+
execution_token: str,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
return self.protect(agent_id).execute_approved(
|
|
106
|
+
resource_id,
|
|
107
|
+
action,
|
|
108
|
+
authorization_event_id,
|
|
109
|
+
execution_token,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def wait_for_approval(
|
|
113
|
+
self,
|
|
114
|
+
agent_id: str,
|
|
115
|
+
resource_id: str,
|
|
116
|
+
action: str,
|
|
117
|
+
authorization_event_id: str,
|
|
118
|
+
*,
|
|
119
|
+
timeout_seconds: float = 300.0,
|
|
120
|
+
poll_interval_seconds: float = 1.5,
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
return self.protect(agent_id).wait_for_approval(
|
|
123
|
+
resource_id,
|
|
124
|
+
action,
|
|
125
|
+
authorization_event_id,
|
|
126
|
+
timeout_seconds=timeout_seconds,
|
|
127
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def authorize_and_wait(
|
|
131
|
+
self,
|
|
132
|
+
agent_id: str,
|
|
133
|
+
resource_id: str,
|
|
134
|
+
action: str,
|
|
135
|
+
*,
|
|
136
|
+
context: dict[str, Any] | None = None,
|
|
137
|
+
timeout_seconds: float = 300.0,
|
|
138
|
+
poll_interval_seconds: float = 1.5,
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
return self.protect(agent_id).authorize_and_wait(
|
|
141
|
+
resource_id,
|
|
142
|
+
action,
|
|
143
|
+
context=context,
|
|
144
|
+
timeout_seconds=timeout_seconds,
|
|
145
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _signed_headers(self, body: bytes) -> dict[str, str]:
|
|
149
|
+
timestamp = str(int(time.time()))
|
|
150
|
+
nonce = str(uuid.uuid4())
|
|
151
|
+
body_digest = hashlib.sha256(body).hexdigest()
|
|
152
|
+
canonical = f"v1\n{timestamp}\n{nonce}\n{body_digest}".encode()
|
|
153
|
+
signature = "v1=" + hmac.new(
|
|
154
|
+
self.credential.encode(),
|
|
155
|
+
canonical,
|
|
156
|
+
hashlib.sha256,
|
|
157
|
+
).hexdigest()
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
"Accept": "application/json",
|
|
162
|
+
"x-nodra-credential": self.credential,
|
|
163
|
+
"x-nodra-timestamp": timestamp,
|
|
164
|
+
"x-nodra-nonce": nonce,
|
|
165
|
+
"x-nodra-signature": signature,
|
|
166
|
+
"x-nodra-sdk-version": SDK_VERSION,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def _post(
|
|
170
|
+
self,
|
|
171
|
+
path: str,
|
|
172
|
+
payload: dict[str, Any],
|
|
173
|
+
*,
|
|
174
|
+
retry_safe: bool,
|
|
175
|
+
operation: str,
|
|
176
|
+
) -> dict[str, Any]:
|
|
177
|
+
body = json.dumps(payload, separators=(",", ":")).encode()
|
|
178
|
+
endpoint = self.base_url + path
|
|
179
|
+
last_error: NodraError | None = None
|
|
180
|
+
|
|
181
|
+
for attempt in range(self.max_retries + 1):
|
|
182
|
+
request = urllib.request.Request(
|
|
183
|
+
endpoint,
|
|
184
|
+
data=body,
|
|
185
|
+
method="POST",
|
|
186
|
+
headers=self._signed_headers(body),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
with urllib.request.urlopen(
|
|
191
|
+
request,
|
|
192
|
+
timeout=self.timeout_seconds,
|
|
193
|
+
) as response:
|
|
194
|
+
raw = response.read()
|
|
195
|
+
if not raw:
|
|
196
|
+
return {}
|
|
197
|
+
return json.loads(raw)
|
|
198
|
+
|
|
199
|
+
except urllib.error.HTTPError as exc:
|
|
200
|
+
raw = exc.read()
|
|
201
|
+
try:
|
|
202
|
+
data = json.loads(raw) if raw else {}
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
data = {}
|
|
205
|
+
|
|
206
|
+
code = str(data.get("error") or f"nodra_http_{exc.code}")
|
|
207
|
+
request_id = (
|
|
208
|
+
exc.headers.get("x-request-id")
|
|
209
|
+
or exc.headers.get("x-nodra-request-id")
|
|
210
|
+
if exc.headers
|
|
211
|
+
else None
|
|
212
|
+
)
|
|
213
|
+
retryable = retry_safe and _retryable_status(exc.code)
|
|
214
|
+
error = NodraError(
|
|
215
|
+
f"Nodra {operation} failed: {code}",
|
|
216
|
+
code=code,
|
|
217
|
+
status=exc.code,
|
|
218
|
+
request_id=request_id,
|
|
219
|
+
retryable=retryable,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if not retryable or attempt >= self.max_retries:
|
|
223
|
+
raise error from exc
|
|
224
|
+
|
|
225
|
+
last_error = error
|
|
226
|
+
retry_after = exc.headers.get("retry-after") if exc.headers else None
|
|
227
|
+
if retry_after:
|
|
228
|
+
try:
|
|
229
|
+
delay = float(retry_after)
|
|
230
|
+
except ValueError:
|
|
231
|
+
delay = self._backoff(attempt)
|
|
232
|
+
else:
|
|
233
|
+
delay = self._backoff(attempt)
|
|
234
|
+
time.sleep(max(0.0, delay))
|
|
235
|
+
|
|
236
|
+
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
|
|
237
|
+
retryable = retry_safe
|
|
238
|
+
reason = getattr(exc, "reason", None)
|
|
239
|
+
timed_out = isinstance(exc, (TimeoutError, socket.timeout)) or isinstance(
|
|
240
|
+
reason, (TimeoutError, socket.timeout)
|
|
241
|
+
)
|
|
242
|
+
error = NodraError(
|
|
243
|
+
(
|
|
244
|
+
f"Nodra {operation} timed out after "
|
|
245
|
+
f"{self.timeout_seconds:g}s."
|
|
246
|
+
if timed_out
|
|
247
|
+
else f"Nodra {operation} could not reach the gateway."
|
|
248
|
+
),
|
|
249
|
+
code="request_timeout" if timed_out else "network_error",
|
|
250
|
+
retryable=retryable,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if not retryable or attempt >= self.max_retries:
|
|
254
|
+
raise error from exc
|
|
255
|
+
|
|
256
|
+
last_error = error
|
|
257
|
+
time.sleep(self._backoff(attempt))
|
|
258
|
+
|
|
259
|
+
if last_error is not None:
|
|
260
|
+
raise last_error
|
|
261
|
+
|
|
262
|
+
raise NodraError(
|
|
263
|
+
f"Nodra {operation} failed.",
|
|
264
|
+
code="unknown_error",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _backoff(attempt: int) -> float:
|
|
269
|
+
base = min(1.0, 0.1 * (2**attempt))
|
|
270
|
+
return base + random.uniform(0.0, 0.075)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class NodraAgent:
|
|
274
|
+
def __init__(self, client: Nodra, agent_id: str) -> None:
|
|
275
|
+
self.client = client
|
|
276
|
+
self.agent_id = agent_id
|
|
277
|
+
|
|
278
|
+
def authorize(
|
|
279
|
+
self,
|
|
280
|
+
resource_id: str,
|
|
281
|
+
action: str,
|
|
282
|
+
*,
|
|
283
|
+
context: dict[str, Any] | None = None,
|
|
284
|
+
) -> dict[str, Any]:
|
|
285
|
+
_validate_required(resource_id, "resource_id")
|
|
286
|
+
_validate_required(action, "action")
|
|
287
|
+
|
|
288
|
+
payload: dict[str, Any] = {
|
|
289
|
+
"agentId": self.agent_id,
|
|
290
|
+
"resourceId": resource_id,
|
|
291
|
+
"action": action,
|
|
292
|
+
}
|
|
293
|
+
if context is not None:
|
|
294
|
+
if not isinstance(context, dict):
|
|
295
|
+
raise NodraError(
|
|
296
|
+
"context must be a dictionary.",
|
|
297
|
+
code="invalid_request",
|
|
298
|
+
)
|
|
299
|
+
payload["context"] = context
|
|
300
|
+
|
|
301
|
+
return self.client._post(
|
|
302
|
+
"/api/v1/authorize",
|
|
303
|
+
payload,
|
|
304
|
+
retry_safe=True,
|
|
305
|
+
operation="authorization",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def execute_approved(
|
|
309
|
+
self,
|
|
310
|
+
resource_id: str,
|
|
311
|
+
action: str,
|
|
312
|
+
authorization_event_id: str,
|
|
313
|
+
execution_token: str,
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
_validate_required(resource_id, "resource_id")
|
|
316
|
+
_validate_required(action, "action")
|
|
317
|
+
_validate_required(authorization_event_id, "authorization_event_id")
|
|
318
|
+
_validate_required(execution_token, "execution_token")
|
|
319
|
+
|
|
320
|
+
# The token is one-time. Never automatically retry an execution that
|
|
321
|
+
# may already have reached the Nodra gateway.
|
|
322
|
+
return self.client._post(
|
|
323
|
+
"/api/v1/execute-approved",
|
|
324
|
+
{
|
|
325
|
+
"agentId": self.agent_id,
|
|
326
|
+
"resourceId": resource_id,
|
|
327
|
+
"action": action,
|
|
328
|
+
"authorizationEventId": authorization_event_id,
|
|
329
|
+
"executionToken": execution_token,
|
|
330
|
+
},
|
|
331
|
+
retry_safe=False,
|
|
332
|
+
operation="approved execution",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def wait_for_approval(
|
|
336
|
+
self,
|
|
337
|
+
resource_id: str,
|
|
338
|
+
action: str,
|
|
339
|
+
authorization_event_id: str,
|
|
340
|
+
*,
|
|
341
|
+
timeout_seconds: float = 300.0,
|
|
342
|
+
poll_interval_seconds: float = 1.5,
|
|
343
|
+
) -> dict[str, Any]:
|
|
344
|
+
_validate_required(resource_id, "resource_id")
|
|
345
|
+
_validate_required(action, "action")
|
|
346
|
+
_validate_required(authorization_event_id, "authorization_event_id")
|
|
347
|
+
|
|
348
|
+
deadline = time.monotonic() + max(1.0, float(timeout_seconds))
|
|
349
|
+
poll_interval = max(0.25, min(10.0, float(poll_interval_seconds)))
|
|
350
|
+
execution_token = secrets.token_urlsafe(32)
|
|
351
|
+
|
|
352
|
+
status_payload = {
|
|
353
|
+
"agentId": self.agent_id,
|
|
354
|
+
"resourceId": resource_id,
|
|
355
|
+
"action": action,
|
|
356
|
+
"authorizationEventId": authorization_event_id,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
while time.monotonic() < deadline:
|
|
360
|
+
status = self.client._post(
|
|
361
|
+
"/api/v1/approval-status",
|
|
362
|
+
status_payload,
|
|
363
|
+
retry_safe=True,
|
|
364
|
+
operation="approval status",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if status.get("status") == "denied":
|
|
368
|
+
raise NodraError(
|
|
369
|
+
"Human approval was denied.",
|
|
370
|
+
code="approval_denied",
|
|
371
|
+
status=403,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if status.get("status") == "approved":
|
|
375
|
+
claim = self.client._post(
|
|
376
|
+
"/api/v1/approval-claim",
|
|
377
|
+
{
|
|
378
|
+
**status_payload,
|
|
379
|
+
"executionToken": execution_token,
|
|
380
|
+
},
|
|
381
|
+
retry_safe=True,
|
|
382
|
+
operation="approval claim",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
"executionToken": execution_token,
|
|
387
|
+
"reason": status.get("reason"),
|
|
388
|
+
"decidedAt": status.get("decidedAt"),
|
|
389
|
+
"expiresAt": claim.get("expiresAt"),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
remaining = deadline - time.monotonic()
|
|
393
|
+
if remaining <= 0:
|
|
394
|
+
break
|
|
395
|
+
time.sleep(min(poll_interval, remaining))
|
|
396
|
+
|
|
397
|
+
raise NodraError(
|
|
398
|
+
"Timed out waiting for human approval.",
|
|
399
|
+
code="approval_wait_timeout",
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def authorize_and_wait(
|
|
403
|
+
self,
|
|
404
|
+
resource_id: str,
|
|
405
|
+
action: str,
|
|
406
|
+
*,
|
|
407
|
+
context: dict[str, Any] | None = None,
|
|
408
|
+
timeout_seconds: float = 300.0,
|
|
409
|
+
poll_interval_seconds: float = 1.5,
|
|
410
|
+
) -> dict[str, Any]:
|
|
411
|
+
decision = self.authorize(resource_id, action, context=context)
|
|
412
|
+
|
|
413
|
+
if decision.get("decision") != "require-approval":
|
|
414
|
+
return decision
|
|
415
|
+
|
|
416
|
+
authorization_event_id = str(decision.get("authorizationEventId") or "")
|
|
417
|
+
_validate_required(authorization_event_id, "authorization_event_id")
|
|
418
|
+
|
|
419
|
+
claim = self.wait_for_approval(
|
|
420
|
+
resource_id,
|
|
421
|
+
action,
|
|
422
|
+
authorization_event_id,
|
|
423
|
+
timeout_seconds=timeout_seconds,
|
|
424
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return self.execute_approved(
|
|
428
|
+
resource_id,
|
|
429
|
+
action,
|
|
430
|
+
authorization_event_id,
|
|
431
|
+
claim["executionToken"],
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def record(
|
|
435
|
+
self,
|
|
436
|
+
resource_id: str,
|
|
437
|
+
action: str,
|
|
438
|
+
decision: str,
|
|
439
|
+
*,
|
|
440
|
+
phase: str = "result",
|
|
441
|
+
executed: bool = False,
|
|
442
|
+
event_id: str | None = None,
|
|
443
|
+
**extra: Any,
|
|
444
|
+
) -> dict[str, Any]:
|
|
445
|
+
_validate_required(resource_id, "resource_id")
|
|
446
|
+
_validate_required(action, "action")
|
|
447
|
+
|
|
448
|
+
if decision not in {"allow", "deny", "require-approval"}:
|
|
449
|
+
raise NodraError(
|
|
450
|
+
"decision must be allow, deny, or require-approval.",
|
|
451
|
+
code="invalid_request",
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
payload = {
|
|
455
|
+
"id": event_id or str(uuid.uuid4()),
|
|
456
|
+
"agentId": self.agent_id,
|
|
457
|
+
"resourceId": resource_id,
|
|
458
|
+
"action": action,
|
|
459
|
+
"decision": decision,
|
|
460
|
+
"phase": phase,
|
|
461
|
+
"executed": executed,
|
|
462
|
+
**extra,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return self.client._post(
|
|
466
|
+
"/api/v1/events",
|
|
467
|
+
payload,
|
|
468
|
+
retry_safe=True,
|
|
469
|
+
operation="event recording",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def intent(
|
|
473
|
+
self,
|
|
474
|
+
resource_id: str,
|
|
475
|
+
action: str,
|
|
476
|
+
decision: str,
|
|
477
|
+
**extra: Any,
|
|
478
|
+
) -> dict[str, Any]:
|
|
479
|
+
return self.record(
|
|
480
|
+
resource_id,
|
|
481
|
+
action,
|
|
482
|
+
decision,
|
|
483
|
+
phase="intent",
|
|
484
|
+
executed=False,
|
|
485
|
+
**extra,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def result(
|
|
489
|
+
self,
|
|
490
|
+
resource_id: str,
|
|
491
|
+
action: str,
|
|
492
|
+
decision: str,
|
|
493
|
+
*,
|
|
494
|
+
executed: bool = False,
|
|
495
|
+
**extra: Any,
|
|
496
|
+
) -> dict[str, Any]:
|
|
497
|
+
return self.record(
|
|
498
|
+
resource_id,
|
|
499
|
+
action,
|
|
500
|
+
decision,
|
|
501
|
+
phase="result",
|
|
502
|
+
executed=executed,
|
|
503
|
+
**extra,
|
|
504
|
+
)
|