kobil-agent-audit 1.2.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.
- kobil_agent_audit-1.2.0/.gitignore +9 -0
- kobil_agent_audit-1.2.0/LICENSE +128 -0
- kobil_agent_audit-1.2.0/PKG-INFO +58 -0
- kobil_agent_audit-1.2.0/README.md +43 -0
- kobil_agent_audit-1.2.0/kobil_agent_audit/__init__.py +211 -0
- kobil_agent_audit-1.2.0/kobil_agent_audit/_common.py +38 -0
- kobil_agent_audit-1.2.0/kobil_agent_audit/async_auth.py +163 -0
- kobil_agent_audit-1.2.0/kobil_agent_audit/ciba_notification.py +157 -0
- kobil_agent_audit-1.2.0/kobil_agent_audit/compliance.py +352 -0
- kobil_agent_audit-1.2.0/kobil_agent_audit/logger.py +319 -0
- kobil_agent_audit-1.2.0/kobil_agent_audit/token_tracker.py +226 -0
- kobil_agent_audit-1.2.0/kobil_agent_audit/types.py +291 -0
- kobil_agent_audit-1.2.0/pyproject.toml +34 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
KOBIL Agent Trust SDK License
|
|
2
|
+
Version 1.0
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 KOBIL GmbH. All rights reserved.
|
|
5
|
+
The Software is developed by the KOBIL AI Team.
|
|
6
|
+
|
|
7
|
+
In short: you may install and use the Software free of charge, also in
|
|
8
|
+
commercial products, and ship it inside your own application. You may not
|
|
9
|
+
change it, copy it into other software, or pass it on or sell it on its
|
|
10
|
+
own. The numbered terms below are the license; this summary is not.
|
|
11
|
+
|
|
12
|
+
1. Definitions
|
|
13
|
+
|
|
14
|
+
"The Software" is the code and documentation in this package.
|
|
15
|
+
|
|
16
|
+
"You" is the person or organization that accepts these terms.
|
|
17
|
+
|
|
18
|
+
"Your company" is the organization you work for, and the organizations
|
|
19
|
+
that control it, that it controls, or that are under common control
|
|
20
|
+
with it.
|
|
21
|
+
|
|
22
|
+
"Your application" is software or a service that you build and that
|
|
23
|
+
does more than give access to the Software. Your application is not a
|
|
24
|
+
work based on the Software just because it uses the Software.
|
|
25
|
+
|
|
26
|
+
2. Acceptance
|
|
27
|
+
|
|
28
|
+
By installing, copying or using the Software you accept these terms.
|
|
29
|
+
If you do not accept them, do not use the Software.
|
|
30
|
+
|
|
31
|
+
3. What you may do
|
|
32
|
+
|
|
33
|
+
KOBIL grants you and your company a non-exclusive, worldwide,
|
|
34
|
+
non-transferable license, free of charge, to:
|
|
35
|
+
|
|
36
|
+
a. install and use the Software, unchanged, to build, test and run your
|
|
37
|
+
applications, including commercial ones that you charge for;
|
|
38
|
+
b. make the copies of the Software that this needs, for example on
|
|
39
|
+
developer machines, on build servers and in your application's
|
|
40
|
+
environments;
|
|
41
|
+
c. ship the Software, unchanged, as part of your application, for
|
|
42
|
+
example inside its container image, package or bundle.
|
|
43
|
+
|
|
44
|
+
Your company's employees and contractors may do the same on your
|
|
45
|
+
company's behalf.
|
|
46
|
+
|
|
47
|
+
Building your application with tools such as bundlers, minifiers,
|
|
48
|
+
transpilers or compilers is not a change to the Software. Using AI
|
|
49
|
+
coding tools to write applications that use the Software is allowed.
|
|
50
|
+
|
|
51
|
+
4. Shipping the Software inside your application
|
|
52
|
+
|
|
53
|
+
When you ship the Software as part of your application:
|
|
54
|
+
|
|
55
|
+
a. the Software must be needed for your application to work, and must
|
|
56
|
+
not be offered as a library, SDK or tool of its own;
|
|
57
|
+
b. the people who receive your application get no right to use the
|
|
58
|
+
Software apart from your application;
|
|
59
|
+
c. keep this license and the copyright notice with the Software, or in
|
|
60
|
+
your application's third-party notices.
|
|
61
|
+
|
|
62
|
+
5. What you may not do
|
|
63
|
+
|
|
64
|
+
You may not:
|
|
65
|
+
|
|
66
|
+
a. change, adapt or translate the Software, or make works based on it;
|
|
67
|
+
b. copy the Software's code, or substantial parts of it, into other
|
|
68
|
+
software, whether by hand or with automated or AI tools, or use the
|
|
69
|
+
Software to train AI models;
|
|
70
|
+
c. publish, share, sublicense, sell, rent or lease the Software on its
|
|
71
|
+
own, or as part of another SDK, library, framework or developer
|
|
72
|
+
tool;
|
|
73
|
+
d. remove or change this license or any copyright notice in the
|
|
74
|
+
Software;
|
|
75
|
+
e. use the Software to break the law, or to suggest that KOBIL endorses
|
|
76
|
+
your application.
|
|
77
|
+
|
|
78
|
+
These limits apply to the Software itself. You may sell and
|
|
79
|
+
commercially distribute your application.
|
|
80
|
+
|
|
81
|
+
6. KOBIL services
|
|
82
|
+
|
|
83
|
+
The Software can connect to KOBIL services. Using those services is
|
|
84
|
+
governed by their own terms, not by this license. This license does not
|
|
85
|
+
give you access to any KOBIL service.
|
|
86
|
+
|
|
87
|
+
7. Ownership
|
|
88
|
+
|
|
89
|
+
KOBIL keeps all rights in the Software that this license does not
|
|
90
|
+
expressly grant. This license gives you no right to KOBIL's names,
|
|
91
|
+
logos or trademarks, except to say truthfully that your application
|
|
92
|
+
uses the Software.
|
|
93
|
+
|
|
94
|
+
8. Other software
|
|
95
|
+
|
|
96
|
+
The Software uses third-party open-source packages. Each of them is
|
|
97
|
+
covered by its own license, not by this one.
|
|
98
|
+
|
|
99
|
+
9. New versions
|
|
100
|
+
|
|
101
|
+
KOBIL may release new versions of the Software under a new version of
|
|
102
|
+
this license. Each copy of the Software is covered by the license that
|
|
103
|
+
came with it.
|
|
104
|
+
|
|
105
|
+
10. Violations
|
|
106
|
+
|
|
107
|
+
If you break these terms, KOBIL may tell you so in writing. The first
|
|
108
|
+
time this happens, your license continues if, within 30 days of that
|
|
109
|
+
notice, you fully comply again and take practical steps to correct past
|
|
110
|
+
violations. Otherwise, and after any later violation, your license
|
|
111
|
+
ends. You must then stop using and shipping the Software.
|
|
112
|
+
|
|
113
|
+
11. No warranty
|
|
114
|
+
|
|
115
|
+
As far as the law allows, the Software comes "as is", without any
|
|
116
|
+
warranty or condition, express or implied, including fitness for a
|
|
117
|
+
particular purpose and non-infringement.
|
|
118
|
+
|
|
119
|
+
12. Limitation of liability
|
|
120
|
+
|
|
121
|
+
As far as the law allows, KOBIL is not liable for any damages that come
|
|
122
|
+
from these terms or from using, or not being able to use, the Software,
|
|
123
|
+
under any kind of legal claim.
|
|
124
|
+
|
|
125
|
+
13. Other permissions
|
|
126
|
+
|
|
127
|
+
For any use these terms do not allow, ask KOBIL for written permission
|
|
128
|
+
at www.kobil.com.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kobil-agent-audit
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Audit, Observability & Human-in-the-Loop for the KOBIL AI Trust Platform
|
|
5
|
+
License-Expression: LicenseRef-KOBIL-Agent-Trust-SDK-1.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: agent,ai-security,audit,ciba,compliance,human-in-the-loop,kobil,observability,siem,token-tracking
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# kobil-agent-audit (Python)
|
|
17
|
+
|
|
18
|
+
Python mirror of the `@uveysservetoglu/kobil-agent-audit` TypeScript SDK — audit,
|
|
19
|
+
observability, and human-in-the-loop for the KOBIL AI Trust Platform.
|
|
20
|
+
|
|
21
|
+
Behavioral 1:1 port of the TypeScript package in `../src`. The TypeScript
|
|
22
|
+
source is the reference specification; the Python code reproduces its logic,
|
|
23
|
+
HTTP wire format, and error semantics. `AuditConfig` is a snake_case dataclass
|
|
24
|
+
(developer surface); the entity types (AuditEvent, TokenTrackingEntry, …) are
|
|
25
|
+
camelCase dicts because they are the literal JSON exchanged with the platform.
|
|
26
|
+
|
|
27
|
+
## Install (dev)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m venv .venv && source .venv/bin/activate
|
|
31
|
+
pip install -e ".[dev]"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Test
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pytest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from kobil_agent_audit import KobilAgentAudit
|
|
45
|
+
|
|
46
|
+
audit = KobilAgentAudit(
|
|
47
|
+
platform_url="https://platform.kobil.com",
|
|
48
|
+
agent_id="my-agent",
|
|
49
|
+
api_token="tok",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
audit.log_action("call_tool", "weather-api", "success", {"temp": 72})
|
|
53
|
+
|
|
54
|
+
async def main():
|
|
55
|
+
await audit.destroy() # flushes remaining events
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# kobil-agent-audit (Python)
|
|
2
|
+
|
|
3
|
+
Python mirror of the `@uveysservetoglu/kobil-agent-audit` TypeScript SDK — audit,
|
|
4
|
+
observability, and human-in-the-loop for the KOBIL AI Trust Platform.
|
|
5
|
+
|
|
6
|
+
Behavioral 1:1 port of the TypeScript package in `../src`. The TypeScript
|
|
7
|
+
source is the reference specification; the Python code reproduces its logic,
|
|
8
|
+
HTTP wire format, and error semantics. `AuditConfig` is a snake_case dataclass
|
|
9
|
+
(developer surface); the entity types (AuditEvent, TokenTrackingEntry, …) are
|
|
10
|
+
camelCase dicts because they are the literal JSON exchanged with the platform.
|
|
11
|
+
|
|
12
|
+
## Install (dev)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
python -m venv .venv && source .venv/bin/activate
|
|
16
|
+
pip install -e ".[dev]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Test
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pytest
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
from kobil_agent_audit import KobilAgentAudit
|
|
30
|
+
|
|
31
|
+
audit = KobilAgentAudit(
|
|
32
|
+
platform_url="https://platform.kobil.com",
|
|
33
|
+
agent_id="my-agent",
|
|
34
|
+
api_token="tok",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
audit.log_action("call_tool", "weather-api", "success", {"temp": 72})
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
await audit.destroy() # flushes remaining events
|
|
41
|
+
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
```
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""kobil-agent-audit — Audit, Observability & Human-in-the-Loop (Python).
|
|
2
|
+
|
|
3
|
+
Python mirror of the ``@uveysservetoglu/kobil-agent-audit`` TypeScript package.
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- Batched audit event logging with platform / SIEM / webhook sinks
|
|
7
|
+
- Full token lifecycle tracking with anomaly detection
|
|
8
|
+
- CIBA push notifications for human approval
|
|
9
|
+
- Async authorization gates for tool execution
|
|
10
|
+
- Compliance report generation, export, and scheduling
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from .async_auth import AsyncAuthError, AsyncAuthorization
|
|
18
|
+
from .ciba_notification import CIBANotificationError, CIBANotificationService
|
|
19
|
+
from .compliance import ComplianceError, ComplianceReporter
|
|
20
|
+
from .logger import AuditLogger, AuditLoggerError, AuditQueryFilter, AuditQueryResult
|
|
21
|
+
from .token_tracker import TokenTracker, TokenTrackerError
|
|
22
|
+
from .types import (
|
|
23
|
+
ALL_EVENT_TYPES,
|
|
24
|
+
AnomalyDetection,
|
|
25
|
+
AnomalySeverity,
|
|
26
|
+
AnomalyType,
|
|
27
|
+
ApprovalStats,
|
|
28
|
+
AsyncAuthRequest,
|
|
29
|
+
AsyncAuthResponse,
|
|
30
|
+
AsyncAuthStatus,
|
|
31
|
+
AuditConfig,
|
|
32
|
+
AuditEvent,
|
|
33
|
+
AuditEventInput,
|
|
34
|
+
AuditOutcome,
|
|
35
|
+
CIBANotification,
|
|
36
|
+
CIBAStatus,
|
|
37
|
+
ComplianceReport,
|
|
38
|
+
EventType,
|
|
39
|
+
SinkType,
|
|
40
|
+
TokenStats,
|
|
41
|
+
TokenStatus,
|
|
42
|
+
TokenTrackingEntry,
|
|
43
|
+
Urgency,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# The release pipeline stamps the version into pyproject.toml only, so read it back from the installed metadata.
|
|
47
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError, version as _version
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
__version__ = _version("kobil-agent-audit")
|
|
51
|
+
except _PackageNotFoundError: # running from a source checkout that is not installed
|
|
52
|
+
__version__ = "0.0.0"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuditError(Exception):
|
|
56
|
+
"""Base error class for the audit package."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str, code: str, cause: Any = None) -> None:
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.message = message
|
|
61
|
+
self.code = code
|
|
62
|
+
self.cause = cause
|
|
63
|
+
self.name = "AuditError"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class KobilAgentAudit:
|
|
67
|
+
"""Top-level entry point — instantiates and exposes all sub-systems."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
platform_url: Optional[str] = None,
|
|
72
|
+
agent_id: Optional[str] = None,
|
|
73
|
+
api_token: str = "",
|
|
74
|
+
sink_type: SinkType = "platform",
|
|
75
|
+
sink_url: Optional[str] = None,
|
|
76
|
+
batch_size: int = 50,
|
|
77
|
+
flush_interval_ms: int = 10_000,
|
|
78
|
+
cert_path: Optional[str] = None,
|
|
79
|
+
key_path: Optional[str] = None,
|
|
80
|
+
timeout_ms: int = 30_000,
|
|
81
|
+
) -> None:
|
|
82
|
+
if not platform_url:
|
|
83
|
+
raise AuditError("AuditConfig.platformUrl is required", "CONFIG_MISSING")
|
|
84
|
+
if not agent_id:
|
|
85
|
+
raise AuditError("AuditConfig.agentId is required", "CONFIG_MISSING")
|
|
86
|
+
|
|
87
|
+
self.config = AuditConfig(
|
|
88
|
+
platform_url=platform_url,
|
|
89
|
+
agent_id=agent_id,
|
|
90
|
+
api_token=api_token,
|
|
91
|
+
sink_type=sink_type,
|
|
92
|
+
sink_url=sink_url,
|
|
93
|
+
batch_size=batch_size,
|
|
94
|
+
flush_interval_ms=flush_interval_ms,
|
|
95
|
+
cert_path=cert_path,
|
|
96
|
+
key_path=key_path,
|
|
97
|
+
timeout_ms=timeout_ms,
|
|
98
|
+
)
|
|
99
|
+
self.logger = AuditLogger(self.config)
|
|
100
|
+
self.token_tracker = TokenTracker(self.config)
|
|
101
|
+
self.notifications = CIBANotificationService(self.config)
|
|
102
|
+
self.async_auth = AsyncAuthorization(self.config)
|
|
103
|
+
self.compliance = ComplianceReporter(self.config)
|
|
104
|
+
|
|
105
|
+
def log_action(
|
|
106
|
+
self,
|
|
107
|
+
action: str,
|
|
108
|
+
resource: str,
|
|
109
|
+
outcome: AuditOutcome,
|
|
110
|
+
metadata: Optional[dict] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Convenience helper to log a single tool_call action event."""
|
|
113
|
+
event: dict = {
|
|
114
|
+
"agentId": self.config.agent_id,
|
|
115
|
+
"eventType": "tool_call",
|
|
116
|
+
"action": action,
|
|
117
|
+
"resource": resource,
|
|
118
|
+
"outcome": outcome,
|
|
119
|
+
}
|
|
120
|
+
if metadata is not None:
|
|
121
|
+
event["metadata"] = metadata
|
|
122
|
+
self.logger.log(event)
|
|
123
|
+
|
|
124
|
+
async def log_event(self, input: "AuditEventInput") -> None:
|
|
125
|
+
"""Log a single arbitrary audit event and flush immediately.
|
|
126
|
+
|
|
127
|
+
Mirrors the TS ``KobilAgentAudit.logEvent`` facade added in 0.2.16 —
|
|
128
|
+
callers use this instead of hand-rolling ``POST /v1/events`` so the
|
|
129
|
+
SDK owns transport (incl. mTLS) and endpoint resolution. The immediate
|
|
130
|
+
flush gives fire-and-forget parity with pre-batched inline emit paths.
|
|
131
|
+
"""
|
|
132
|
+
event: dict = {
|
|
133
|
+
"agentId": input.get("agentId") or self.config.agent_id,
|
|
134
|
+
"eventType": input.get("eventType"),
|
|
135
|
+
"action": input.get("action"),
|
|
136
|
+
"resource": input.get("resource") or input.get("resourceType") or "",
|
|
137
|
+
"outcome": input.get("outcome", "success"),
|
|
138
|
+
}
|
|
139
|
+
# Top-level optional fields — mirror TS widening.
|
|
140
|
+
for key in ("resourceType", "resourceId", "correlationId", "actorId", "userId", "details"):
|
|
141
|
+
if input.get(key) is not None:
|
|
142
|
+
event[key] = input[key]
|
|
143
|
+
# ``metadata`` falls back to ``details`` when the caller only provided the latter.
|
|
144
|
+
metadata = input.get("metadata")
|
|
145
|
+
if metadata is None:
|
|
146
|
+
metadata = input.get("details")
|
|
147
|
+
if metadata is not None:
|
|
148
|
+
event["metadata"] = metadata
|
|
149
|
+
self.logger.log(event)
|
|
150
|
+
await self.logger.flush()
|
|
151
|
+
|
|
152
|
+
async def destroy(self) -> None:
|
|
153
|
+
"""Gracefully tear down all sub-systems."""
|
|
154
|
+
errors: list[Exception] = []
|
|
155
|
+
for fn in (
|
|
156
|
+
self.logger.destroy,
|
|
157
|
+
self.token_tracker.destroy,
|
|
158
|
+
self.notifications.destroy,
|
|
159
|
+
self.async_auth.destroy,
|
|
160
|
+
self.compliance.destroy,
|
|
161
|
+
):
|
|
162
|
+
try:
|
|
163
|
+
result = fn()
|
|
164
|
+
if hasattr(result, "__await__"):
|
|
165
|
+
await result
|
|
166
|
+
except Exception as e: # noqa: BLE001
|
|
167
|
+
errors.append(e if isinstance(e, Exception) else Exception(str(e)))
|
|
168
|
+
if errors:
|
|
169
|
+
msg = "; ".join(str(e) for e in errors)
|
|
170
|
+
raise AuditError(
|
|
171
|
+
f"Audit destroy encountered {len(errors)} error(s): {msg}", "DESTROY_ERROR"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
__all__ = [
|
|
176
|
+
"AuditError",
|
|
177
|
+
"KobilAgentAudit",
|
|
178
|
+
"AuditLogger",
|
|
179
|
+
"AuditLoggerError",
|
|
180
|
+
"AuditQueryFilter",
|
|
181
|
+
"AuditQueryResult",
|
|
182
|
+
"TokenTracker",
|
|
183
|
+
"TokenTrackerError",
|
|
184
|
+
"CIBANotificationService",
|
|
185
|
+
"CIBANotificationError",
|
|
186
|
+
"AsyncAuthorization",
|
|
187
|
+
"AsyncAuthError",
|
|
188
|
+
"ComplianceReporter",
|
|
189
|
+
"ComplianceError",
|
|
190
|
+
"AuditConfig",
|
|
191
|
+
"AuditEvent",
|
|
192
|
+
"AuditEventInput",
|
|
193
|
+
"AuditOutcome",
|
|
194
|
+
"EventType",
|
|
195
|
+
"SinkType",
|
|
196
|
+
"TokenStatus",
|
|
197
|
+
"TokenTrackingEntry",
|
|
198
|
+
"CIBAStatus",
|
|
199
|
+
"CIBANotification",
|
|
200
|
+
"Urgency",
|
|
201
|
+
"AsyncAuthRequest",
|
|
202
|
+
"AsyncAuthResponse",
|
|
203
|
+
"AsyncAuthStatus",
|
|
204
|
+
"TokenStats",
|
|
205
|
+
"ApprovalStats",
|
|
206
|
+
"AnomalyType",
|
|
207
|
+
"AnomalySeverity",
|
|
208
|
+
"AnomalyDetection",
|
|
209
|
+
"ComplianceReport",
|
|
210
|
+
"ALL_EVENT_TYPES",
|
|
211
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shared helpers for the kobil-agent-audit Python package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid as _uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def uuid() -> str:
|
|
10
|
+
"""Mirror of crypto.randomUUID()."""
|
|
11
|
+
return str(_uuid.uuid4())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def iso_now() -> str:
|
|
15
|
+
"""Mirror of ``new Date().toISOString()`` — always millisecond + 'Z'."""
|
|
16
|
+
dt = datetime.now(timezone.utc)
|
|
17
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def iso_from_dt(dt: datetime) -> str:
|
|
21
|
+
"""Mirror of ``date.toISOString()`` for an arbitrary datetime."""
|
|
22
|
+
if dt.tzinfo is None:
|
|
23
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
24
|
+
dt = dt.astimezone(timezone.utc)
|
|
25
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_iso_ms(value: str) -> int:
|
|
29
|
+
"""Parse an ISO-8601 string to epoch milliseconds (mirror Date.parse)."""
|
|
30
|
+
normalized = value.replace("Z", "+00:00")
|
|
31
|
+
dt = datetime.fromisoformat(normalized)
|
|
32
|
+
if dt.tzinfo is None:
|
|
33
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
34
|
+
return int(dt.timestamp() * 1000)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def now_ms() -> int:
|
|
38
|
+
return int(datetime.now(timezone.utc).timestamp() * 1000)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""AsyncAuthorization — human-in-the-loop approval gates (Python mirror).
|
|
2
|
+
|
|
3
|
+
Mirror of ``kobil-agent-audit/src/async-auth.ts``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Awaitable, Callable
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._common import iso_now, now_ms, uuid
|
|
16
|
+
from .types import AsyncAuthRequest, AsyncAuthResponse, AuditConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncAuthError(Exception):
|
|
20
|
+
def __init__(self, message: str, code: str, cause: Any = None) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.code = code
|
|
24
|
+
self.cause = cause
|
|
25
|
+
self.name = "AsyncAuthError"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncAuthorization:
|
|
29
|
+
def __init__(self, config: AuditConfig) -> None:
|
|
30
|
+
self.config = config
|
|
31
|
+
self._requests: dict[str, AsyncAuthResponse] = {}
|
|
32
|
+
|
|
33
|
+
def destroy(self) -> None:
|
|
34
|
+
self._requests.clear()
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
# Public API
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
async def request_approval(self, request: AsyncAuthRequest) -> AsyncAuthResponse:
|
|
41
|
+
request_id = uuid()
|
|
42
|
+
full: AsyncAuthRequest = {**request, "requestId": request_id}
|
|
43
|
+
response = await self._submit_remote(full)
|
|
44
|
+
self._requests[request_id] = response
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
async def check_approval(self, request_id: str) -> AsyncAuthResponse:
|
|
48
|
+
try:
|
|
49
|
+
remote = await self._fetch_remote_status(request_id)
|
|
50
|
+
self._requests[request_id] = remote
|
|
51
|
+
return remote
|
|
52
|
+
except AsyncAuthError:
|
|
53
|
+
raise
|
|
54
|
+
except Exception:
|
|
55
|
+
local = self._requests.get(request_id)
|
|
56
|
+
if not local:
|
|
57
|
+
raise AsyncAuthError(
|
|
58
|
+
f"Approval request not found: {request_id}", "REQUEST_NOT_FOUND"
|
|
59
|
+
)
|
|
60
|
+
return dict(local) # type: ignore[return-value]
|
|
61
|
+
|
|
62
|
+
async def wait_for_approval(
|
|
63
|
+
self, request_id: str, timeout_ms: int = 300_000
|
|
64
|
+
) -> AsyncAuthResponse:
|
|
65
|
+
deadline = now_ms() + timeout_ms
|
|
66
|
+
poll_interval_ms = 2000
|
|
67
|
+
|
|
68
|
+
while now_ms() < deadline:
|
|
69
|
+
response = await self.check_approval(request_id)
|
|
70
|
+
if response.get("status") != "pending":
|
|
71
|
+
return response
|
|
72
|
+
await asyncio.sleep(min(poll_interval_ms, deadline - now_ms()) / 1000)
|
|
73
|
+
|
|
74
|
+
expired: AsyncAuthResponse = {
|
|
75
|
+
"requestId": request_id,
|
|
76
|
+
"status": "expired",
|
|
77
|
+
"respondedAt": iso_now(),
|
|
78
|
+
}
|
|
79
|
+
self._requests[request_id] = expired
|
|
80
|
+
return expired
|
|
81
|
+
|
|
82
|
+
async def cancel_request(self, request_id: str) -> None:
|
|
83
|
+
url = f"{self.config.platform_url}/v1/async-auth/requests/{quote(request_id, safe='')}"
|
|
84
|
+
async with httpx.AsyncClient() as http:
|
|
85
|
+
res = await http.request(
|
|
86
|
+
"DELETE",
|
|
87
|
+
url,
|
|
88
|
+
headers={
|
|
89
|
+
"Authorization": f"Bearer {self.config.api_token}",
|
|
90
|
+
"X-Agent-Id": self.config.agent_id,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
if not res.is_success:
|
|
94
|
+
raise AsyncAuthError(
|
|
95
|
+
f"Failed to cancel request: {res.status_code} — {res.text}", "CANCEL_ERROR"
|
|
96
|
+
)
|
|
97
|
+
self._requests.pop(request_id, None)
|
|
98
|
+
|
|
99
|
+
def wrap_with_approval(
|
|
100
|
+
self,
|
|
101
|
+
action: str,
|
|
102
|
+
tool_fn: Callable[..., Awaitable[Any]],
|
|
103
|
+
) -> Callable[..., Awaitable[Any]]:
|
|
104
|
+
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
105
|
+
response = await self.request_approval(
|
|
106
|
+
{
|
|
107
|
+
"agentId": self.config.agent_id,
|
|
108
|
+
"userId": "system",
|
|
109
|
+
"action": action,
|
|
110
|
+
"reason": f"Agent {self.config.agent_id} wants to execute: {action}",
|
|
111
|
+
"urgency": "normal",
|
|
112
|
+
"timeout": 300,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
resolved = await self.wait_for_approval(response["requestId"], 300_000)
|
|
116
|
+
if resolved.get("status") != "approved":
|
|
117
|
+
raise AsyncAuthError(
|
|
118
|
+
f'Approval {resolved.get("status")} for action "{action}" '
|
|
119
|
+
f'(request {resolved.get("requestId")})',
|
|
120
|
+
"APPROVAL_DENIED",
|
|
121
|
+
)
|
|
122
|
+
return await tool_fn(*args, **kwargs)
|
|
123
|
+
|
|
124
|
+
return wrapped
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Remote helpers
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
async def _submit_remote(self, request: AsyncAuthRequest) -> AsyncAuthResponse:
|
|
131
|
+
url = f"{self.config.platform_url}/v1/async-auth/requests"
|
|
132
|
+
async with httpx.AsyncClient() as http:
|
|
133
|
+
res = await http.post(
|
|
134
|
+
url,
|
|
135
|
+
headers={
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
"Authorization": f"Bearer {self.config.api_token}",
|
|
138
|
+
"X-Agent-Id": self.config.agent_id,
|
|
139
|
+
},
|
|
140
|
+
content=json.dumps(request, separators=(",", ":")),
|
|
141
|
+
)
|
|
142
|
+
if not res.is_success:
|
|
143
|
+
raise AsyncAuthError(
|
|
144
|
+
f"Failed to submit approval request: {res.status_code} — {res.text}", "SUBMIT_ERROR"
|
|
145
|
+
)
|
|
146
|
+
return res.json()
|
|
147
|
+
|
|
148
|
+
async def _fetch_remote_status(self, request_id: str) -> AsyncAuthResponse:
|
|
149
|
+
url = f"{self.config.platform_url}/v1/async-auth/requests/{quote(request_id, safe='')}"
|
|
150
|
+
async with httpx.AsyncClient() as http:
|
|
151
|
+
res = await http.get(
|
|
152
|
+
url,
|
|
153
|
+
headers={
|
|
154
|
+
"Authorization": f"Bearer {self.config.api_token}",
|
|
155
|
+
"X-Agent-Id": self.config.agent_id,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
if not res.is_success:
|
|
159
|
+
raise AsyncAuthError(f"Remote status check failed: {res.status_code}", "STATUS_FETCH_ERROR")
|
|
160
|
+
return res.json()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = ["AsyncAuthorization", "AsyncAuthError"]
|