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,15 @@
1
+ node_modules/
2
+ .next/
3
+ dist/
4
+ build/
5
+ coverage/
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ *.log
10
+ *.tsbuildinfo
11
+ .DS_Store
12
+ .vercel/
13
+ supabase/.temp/
14
+ .idea/
15
+ .vscode/
@@ -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,4 @@
1
+ from .client import Nodra, NodraAgent, NodraError
2
+
3
+ __all__ = ["Nodra", "NodraAgent", "NodraError"]
4
+ __version__ = "0.1.0"
@@ -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
+ )