agentkey 1.0.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.
- agentkey-1.0.0/LICENSE +21 -0
- agentkey-1.0.0/PKG-INFO +193 -0
- agentkey-1.0.0/README.md +167 -0
- agentkey-1.0.0/agentkey.egg-info/PKG-INFO +193 -0
- agentkey-1.0.0/agentkey.egg-info/SOURCES.txt +9 -0
- agentkey-1.0.0/agentkey.egg-info/dependency_links.txt +1 -0
- agentkey-1.0.0/agentkey.egg-info/top_level.txt +1 -0
- agentkey-1.0.0/agentkey.py +432 -0
- agentkey-1.0.0/pyproject.toml +35 -0
- agentkey-1.0.0/setup.cfg +4 -0
- agentkey-1.0.0/tests/test_agentkey.py +179 -0
agentkey-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentKey
|
|
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.
|
agentkey-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentkey
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Authorization and evidence SDK for AI agents: check permissions before every action, record what agents actually did.
|
|
5
|
+
Author: AgentKey
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://agentkey.base44.app
|
|
8
|
+
Project-URL: Documentation, https://agentkey.base44.app/docs
|
|
9
|
+
Keywords: ai,agents,authorization,permissions,audit,mcp,security,llm
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# AgentKey SDK
|
|
28
|
+
|
|
29
|
+
AgentKey is an authorization and evidence layer for AI agents. Before an agent takes an action, the SDK asks the AgentKey API whether it is allowed, denied, or requires human approval. Both the decision and what the agent actually did are recorded as hash-chained evidence events you can inspect in a dashboard.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Python (3.8+, zero dependencies):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install agentkey
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
JavaScript / TypeScript (Node 18+, zero dependencies, ESM):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install agentkey
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Get an API key
|
|
46
|
+
|
|
47
|
+
1. Sign up at the AgentKey dashboard: https://agentkey.base44.app
|
|
48
|
+
2. Open the Connect wizard (or Agents, then create an agent).
|
|
49
|
+
3. Generate an API key. It is shown once; store it as an environment variable and do not hard-code it.
|
|
50
|
+
|
|
51
|
+
## First authorization (Python)
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from agentkey import AgentKeyClient
|
|
55
|
+
|
|
56
|
+
ak = AgentKeyClient(api_key="agent_live_xxxxx") # production API by default
|
|
57
|
+
|
|
58
|
+
result = ak.check_permission(action="send_email", resource="gmail", arguments={"to": "x@company.com"})
|
|
59
|
+
if result["allowed"]:
|
|
60
|
+
send_email(...) # your code
|
|
61
|
+
else:
|
|
62
|
+
print("Blocked:", result["reason"], "approval_required:", result.get("approval_required", False))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## First authorization (JavaScript / TypeScript)
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { AgentKeyClient } from "agentkey";
|
|
69
|
+
|
|
70
|
+
const ak = new AgentKeyClient({ apiKey: "agent_live_xxxxx" }); // production API by default
|
|
71
|
+
|
|
72
|
+
const result = await ak.checkPermission({
|
|
73
|
+
action: "send_email",
|
|
74
|
+
resource: "gmail",
|
|
75
|
+
arguments: { to: "x@company.com" },
|
|
76
|
+
});
|
|
77
|
+
if (result.allowed) {
|
|
78
|
+
await sendEmail(); // your code
|
|
79
|
+
} else {
|
|
80
|
+
console.log("Blocked:", result.reason, "approval_required:", result.approval_required ?? false);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Decisions: allow, deny, ask
|
|
85
|
+
|
|
86
|
+
`check_permission` / `checkPermission` evaluates the permissions you configured for the agent:
|
|
87
|
+
|
|
88
|
+
- **allow**: `allowed: true`. Run the action, then record the execution (below).
|
|
89
|
+
- **deny**: `allowed: false` with the server's reason. Do not run the action.
|
|
90
|
+
- **ask** (human approval): `allowed: false`, `approval_required: true`. The request appears on the Approvals page in the dashboard, where a human approves or denies it. Do not run the action until it is approved.
|
|
91
|
+
|
|
92
|
+
Fail-closed: if the service cannot return a valid decision within 5 seconds, the SDK returns:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{ "allowed": false, "reason": "agentkey_unreachable", "fail_closed": true }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Treat `fail_closed: true` as an infrastructure failure and `allowed: false` without it as an authorization decision. Either way the agent must not proceed.
|
|
99
|
+
|
|
100
|
+
## wrap(): authorize every tool call in one line
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
agent = ak.wrap(my_agent) # observe (default): records, blocks nothing
|
|
104
|
+
agent = ak.wrap(my_agent, mode="enforce") # raises AgentKeyDenied on a denial
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const agent = ak.wrap(myAgent); // observe (default)
|
|
109
|
+
const agent = ak.wrap(myAgent, { mode: "enforce" }); // raises AgentKeyDenied on a denial
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`wrap()` detects an MCP client (`callTool` / `call_tool`), a LangChain agent or tool list, a plain object or dict of functions, or a single function. Observe mode records every call and blocks nothing, so you can see what AgentKey would have caught before trusting it with enforcement. Enforce mode raises `AgentKeyDenied` and does not run the tool. If no session id is passed, a session is started automatically and ended best-effort at process exit.
|
|
113
|
+
|
|
114
|
+
## Sessions
|
|
115
|
+
|
|
116
|
+
Every decision and execution is recorded as an evidence event on the session's hash chain. Group a task into one session:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
s = ak.start_session()
|
|
120
|
+
# ... checks and actions ...
|
|
121
|
+
ak.end_session(s["session_id"])
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
const s = await ak.startSession();
|
|
126
|
+
// ... checks and actions ...
|
|
127
|
+
await ak.endSession({ sessionId: s.session_id });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Recording what the agent actually did
|
|
131
|
+
|
|
132
|
+
After an allowed action runs, record the execution, linked back to its decision by `authorization_id`:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
auth = ak.check_permission(action="send_email", resource="gmail", session_id=sid)
|
|
136
|
+
if auth["allowed"]:
|
|
137
|
+
send_email(...)
|
|
138
|
+
ak.record_action(session_id=sid, authorization_id=auth["event_id"], tool="gmail",
|
|
139
|
+
action="send_email", resource="gmail", result_status="success")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`record_action` never refuses to record, so evidence is not lost for billing reasons. Actions that run without any authorization decision are surfaced on the dashboard as findings (`executions_without_authorization`), because the SDK is self-reported: an agent that bypasses the wrapped functions produces no evidence.
|
|
143
|
+
|
|
144
|
+
## guard(): authorize, run, record in one call
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
out = ak.guard(sid, "gmail", "send_email", lambda: send_email(...), arguments={"to": "x@company.com"})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
const out = await ak.guard({ sessionId: sid, resource: "gmail", action: "send_email" }, async () => sendEmail());
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If authorize denies, `guard` returns the denial and does not run the function.
|
|
155
|
+
|
|
156
|
+
## Delegated authorization
|
|
157
|
+
|
|
158
|
+
A parent agent can delegate a scoped subset of its permissions to a child agent. Scopes are `resource:action` strings, must be a subset of the parent's own permissions, and chains are depth-limited. See `delegate()` in the source docstrings.
|
|
159
|
+
|
|
160
|
+
## Production API
|
|
161
|
+
|
|
162
|
+
Base URL: `https://agentkey.base44.app` (the SDK default). Override it with `base_url` (Python) or `baseUrl` (JavaScript) if you self-host.
|
|
163
|
+
|
|
164
|
+
All endpoints are POST with a Bearer API key, under `/api/functions/`:
|
|
165
|
+
|
|
166
|
+
- `authorize`
|
|
167
|
+
- `record_action`
|
|
168
|
+
- `start_session`
|
|
169
|
+
- `end_session`
|
|
170
|
+
- `delegate`
|
|
171
|
+
- `validate_api_key` (GET)
|
|
172
|
+
|
|
173
|
+
Raw HTTP:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
curl -X POST https://agentkey.base44.app/api/functions/authorize \
|
|
177
|
+
-H "Authorization: Bearer agent_live_xxxxx" \
|
|
178
|
+
-H "Content-Type: application/json" \
|
|
179
|
+
-d '{"action":"send_email","resource":"gmail","arguments":{"to":"x@company.com"}}'
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Dashboard
|
|
183
|
+
|
|
184
|
+
Sessions, evidence events, approvals, findings and permission settings: https://agentkey.base44.app
|
|
185
|
+
|
|
186
|
+
## Security limitations (current, accurate)
|
|
187
|
+
|
|
188
|
+
- Evidence events are hash-chained per session, and session Merkle roots are attested with HMAC-SHA256 under a server-side key. This detects altered or missing events in stored evidence. It is not an asymmetric digital signature scheme, it does not make records forgery-proof against a compromised server, and it is not a non-repudiation guarantee.
|
|
189
|
+
- SDK instrumentation is self-reported. `wrap()` and `record_action` record what the agent reports; an agent that calls tools outside the SDK produces no evidence. This is reported as an `executions_without_authorization` finding, but not prevented.
|
|
190
|
+
- Checks fail closed on network errors and timeouts. In observe mode the SDK still lets the call run; only enforce mode blocks it.
|
|
191
|
+
- No SOC 2 or other third-party compliance audit has been completed.
|
|
192
|
+
|
|
193
|
+
License: MIT.
|
agentkey-1.0.0/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# AgentKey SDK
|
|
2
|
+
|
|
3
|
+
AgentKey is an authorization and evidence layer for AI agents. Before an agent takes an action, the SDK asks the AgentKey API whether it is allowed, denied, or requires human approval. Both the decision and what the agent actually did are recorded as hash-chained evidence events you can inspect in a dashboard.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Python (3.8+, zero dependencies):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install agentkey
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
JavaScript / TypeScript (Node 18+, zero dependencies, ESM):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install agentkey
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Get an API key
|
|
20
|
+
|
|
21
|
+
1. Sign up at the AgentKey dashboard: https://agentkey.base44.app
|
|
22
|
+
2. Open the Connect wizard (or Agents, then create an agent).
|
|
23
|
+
3. Generate an API key. It is shown once; store it as an environment variable and do not hard-code it.
|
|
24
|
+
|
|
25
|
+
## First authorization (Python)
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from agentkey import AgentKeyClient
|
|
29
|
+
|
|
30
|
+
ak = AgentKeyClient(api_key="agent_live_xxxxx") # production API by default
|
|
31
|
+
|
|
32
|
+
result = ak.check_permission(action="send_email", resource="gmail", arguments={"to": "x@company.com"})
|
|
33
|
+
if result["allowed"]:
|
|
34
|
+
send_email(...) # your code
|
|
35
|
+
else:
|
|
36
|
+
print("Blocked:", result["reason"], "approval_required:", result.get("approval_required", False))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## First authorization (JavaScript / TypeScript)
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { AgentKeyClient } from "agentkey";
|
|
43
|
+
|
|
44
|
+
const ak = new AgentKeyClient({ apiKey: "agent_live_xxxxx" }); // production API by default
|
|
45
|
+
|
|
46
|
+
const result = await ak.checkPermission({
|
|
47
|
+
action: "send_email",
|
|
48
|
+
resource: "gmail",
|
|
49
|
+
arguments: { to: "x@company.com" },
|
|
50
|
+
});
|
|
51
|
+
if (result.allowed) {
|
|
52
|
+
await sendEmail(); // your code
|
|
53
|
+
} else {
|
|
54
|
+
console.log("Blocked:", result.reason, "approval_required:", result.approval_required ?? false);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Decisions: allow, deny, ask
|
|
59
|
+
|
|
60
|
+
`check_permission` / `checkPermission` evaluates the permissions you configured for the agent:
|
|
61
|
+
|
|
62
|
+
- **allow**: `allowed: true`. Run the action, then record the execution (below).
|
|
63
|
+
- **deny**: `allowed: false` with the server's reason. Do not run the action.
|
|
64
|
+
- **ask** (human approval): `allowed: false`, `approval_required: true`. The request appears on the Approvals page in the dashboard, where a human approves or denies it. Do not run the action until it is approved.
|
|
65
|
+
|
|
66
|
+
Fail-closed: if the service cannot return a valid decision within 5 seconds, the SDK returns:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{ "allowed": false, "reason": "agentkey_unreachable", "fail_closed": true }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Treat `fail_closed: true` as an infrastructure failure and `allowed: false` without it as an authorization decision. Either way the agent must not proceed.
|
|
73
|
+
|
|
74
|
+
## wrap(): authorize every tool call in one line
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
agent = ak.wrap(my_agent) # observe (default): records, blocks nothing
|
|
78
|
+
agent = ak.wrap(my_agent, mode="enforce") # raises AgentKeyDenied on a denial
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
const agent = ak.wrap(myAgent); // observe (default)
|
|
83
|
+
const agent = ak.wrap(myAgent, { mode: "enforce" }); // raises AgentKeyDenied on a denial
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`wrap()` detects an MCP client (`callTool` / `call_tool`), a LangChain agent or tool list, a plain object or dict of functions, or a single function. Observe mode records every call and blocks nothing, so you can see what AgentKey would have caught before trusting it with enforcement. Enforce mode raises `AgentKeyDenied` and does not run the tool. If no session id is passed, a session is started automatically and ended best-effort at process exit.
|
|
87
|
+
|
|
88
|
+
## Sessions
|
|
89
|
+
|
|
90
|
+
Every decision and execution is recorded as an evidence event on the session's hash chain. Group a task into one session:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
s = ak.start_session()
|
|
94
|
+
# ... checks and actions ...
|
|
95
|
+
ak.end_session(s["session_id"])
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const s = await ak.startSession();
|
|
100
|
+
// ... checks and actions ...
|
|
101
|
+
await ak.endSession({ sessionId: s.session_id });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Recording what the agent actually did
|
|
105
|
+
|
|
106
|
+
After an allowed action runs, record the execution, linked back to its decision by `authorization_id`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
auth = ak.check_permission(action="send_email", resource="gmail", session_id=sid)
|
|
110
|
+
if auth["allowed"]:
|
|
111
|
+
send_email(...)
|
|
112
|
+
ak.record_action(session_id=sid, authorization_id=auth["event_id"], tool="gmail",
|
|
113
|
+
action="send_email", resource="gmail", result_status="success")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`record_action` never refuses to record, so evidence is not lost for billing reasons. Actions that run without any authorization decision are surfaced on the dashboard as findings (`executions_without_authorization`), because the SDK is self-reported: an agent that bypasses the wrapped functions produces no evidence.
|
|
117
|
+
|
|
118
|
+
## guard(): authorize, run, record in one call
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
out = ak.guard(sid, "gmail", "send_email", lambda: send_email(...), arguments={"to": "x@company.com"})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
const out = await ak.guard({ sessionId: sid, resource: "gmail", action: "send_email" }, async () => sendEmail());
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
If authorize denies, `guard` returns the denial and does not run the function.
|
|
129
|
+
|
|
130
|
+
## Delegated authorization
|
|
131
|
+
|
|
132
|
+
A parent agent can delegate a scoped subset of its permissions to a child agent. Scopes are `resource:action` strings, must be a subset of the parent's own permissions, and chains are depth-limited. See `delegate()` in the source docstrings.
|
|
133
|
+
|
|
134
|
+
## Production API
|
|
135
|
+
|
|
136
|
+
Base URL: `https://agentkey.base44.app` (the SDK default). Override it with `base_url` (Python) or `baseUrl` (JavaScript) if you self-host.
|
|
137
|
+
|
|
138
|
+
All endpoints are POST with a Bearer API key, under `/api/functions/`:
|
|
139
|
+
|
|
140
|
+
- `authorize`
|
|
141
|
+
- `record_action`
|
|
142
|
+
- `start_session`
|
|
143
|
+
- `end_session`
|
|
144
|
+
- `delegate`
|
|
145
|
+
- `validate_api_key` (GET)
|
|
146
|
+
|
|
147
|
+
Raw HTTP:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
curl -X POST https://agentkey.base44.app/api/functions/authorize \
|
|
151
|
+
-H "Authorization: Bearer agent_live_xxxxx" \
|
|
152
|
+
-H "Content-Type: application/json" \
|
|
153
|
+
-d '{"action":"send_email","resource":"gmail","arguments":{"to":"x@company.com"}}'
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Dashboard
|
|
157
|
+
|
|
158
|
+
Sessions, evidence events, approvals, findings and permission settings: https://agentkey.base44.app
|
|
159
|
+
|
|
160
|
+
## Security limitations (current, accurate)
|
|
161
|
+
|
|
162
|
+
- Evidence events are hash-chained per session, and session Merkle roots are attested with HMAC-SHA256 under a server-side key. This detects altered or missing events in stored evidence. It is not an asymmetric digital signature scheme, it does not make records forgery-proof against a compromised server, and it is not a non-repudiation guarantee.
|
|
163
|
+
- SDK instrumentation is self-reported. `wrap()` and `record_action` record what the agent reports; an agent that calls tools outside the SDK produces no evidence. This is reported as an `executions_without_authorization` finding, but not prevented.
|
|
164
|
+
- Checks fail closed on network errors and timeouts. In observe mode the SDK still lets the call run; only enforce mode blocks it.
|
|
165
|
+
- No SOC 2 or other third-party compliance audit has been completed.
|
|
166
|
+
|
|
167
|
+
License: MIT.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentkey
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Authorization and evidence SDK for AI agents: check permissions before every action, record what agents actually did.
|
|
5
|
+
Author: AgentKey
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://agentkey.base44.app
|
|
8
|
+
Project-URL: Documentation, https://agentkey.base44.app/docs
|
|
9
|
+
Keywords: ai,agents,authorization,permissions,audit,mcp,security,llm
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# AgentKey SDK
|
|
28
|
+
|
|
29
|
+
AgentKey is an authorization and evidence layer for AI agents. Before an agent takes an action, the SDK asks the AgentKey API whether it is allowed, denied, or requires human approval. Both the decision and what the agent actually did are recorded as hash-chained evidence events you can inspect in a dashboard.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
Python (3.8+, zero dependencies):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install agentkey
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
JavaScript / TypeScript (Node 18+, zero dependencies, ESM):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install agentkey
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Get an API key
|
|
46
|
+
|
|
47
|
+
1. Sign up at the AgentKey dashboard: https://agentkey.base44.app
|
|
48
|
+
2. Open the Connect wizard (or Agents, then create an agent).
|
|
49
|
+
3. Generate an API key. It is shown once; store it as an environment variable and do not hard-code it.
|
|
50
|
+
|
|
51
|
+
## First authorization (Python)
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from agentkey import AgentKeyClient
|
|
55
|
+
|
|
56
|
+
ak = AgentKeyClient(api_key="agent_live_xxxxx") # production API by default
|
|
57
|
+
|
|
58
|
+
result = ak.check_permission(action="send_email", resource="gmail", arguments={"to": "x@company.com"})
|
|
59
|
+
if result["allowed"]:
|
|
60
|
+
send_email(...) # your code
|
|
61
|
+
else:
|
|
62
|
+
print("Blocked:", result["reason"], "approval_required:", result.get("approval_required", False))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## First authorization (JavaScript / TypeScript)
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { AgentKeyClient } from "agentkey";
|
|
69
|
+
|
|
70
|
+
const ak = new AgentKeyClient({ apiKey: "agent_live_xxxxx" }); // production API by default
|
|
71
|
+
|
|
72
|
+
const result = await ak.checkPermission({
|
|
73
|
+
action: "send_email",
|
|
74
|
+
resource: "gmail",
|
|
75
|
+
arguments: { to: "x@company.com" },
|
|
76
|
+
});
|
|
77
|
+
if (result.allowed) {
|
|
78
|
+
await sendEmail(); // your code
|
|
79
|
+
} else {
|
|
80
|
+
console.log("Blocked:", result.reason, "approval_required:", result.approval_required ?? false);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Decisions: allow, deny, ask
|
|
85
|
+
|
|
86
|
+
`check_permission` / `checkPermission` evaluates the permissions you configured for the agent:
|
|
87
|
+
|
|
88
|
+
- **allow**: `allowed: true`. Run the action, then record the execution (below).
|
|
89
|
+
- **deny**: `allowed: false` with the server's reason. Do not run the action.
|
|
90
|
+
- **ask** (human approval): `allowed: false`, `approval_required: true`. The request appears on the Approvals page in the dashboard, where a human approves or denies it. Do not run the action until it is approved.
|
|
91
|
+
|
|
92
|
+
Fail-closed: if the service cannot return a valid decision within 5 seconds, the SDK returns:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{ "allowed": false, "reason": "agentkey_unreachable", "fail_closed": true }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Treat `fail_closed: true` as an infrastructure failure and `allowed: false` without it as an authorization decision. Either way the agent must not proceed.
|
|
99
|
+
|
|
100
|
+
## wrap(): authorize every tool call in one line
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
agent = ak.wrap(my_agent) # observe (default): records, blocks nothing
|
|
104
|
+
agent = ak.wrap(my_agent, mode="enforce") # raises AgentKeyDenied on a denial
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const agent = ak.wrap(myAgent); // observe (default)
|
|
109
|
+
const agent = ak.wrap(myAgent, { mode: "enforce" }); // raises AgentKeyDenied on a denial
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`wrap()` detects an MCP client (`callTool` / `call_tool`), a LangChain agent or tool list, a plain object or dict of functions, or a single function. Observe mode records every call and blocks nothing, so you can see what AgentKey would have caught before trusting it with enforcement. Enforce mode raises `AgentKeyDenied` and does not run the tool. If no session id is passed, a session is started automatically and ended best-effort at process exit.
|
|
113
|
+
|
|
114
|
+
## Sessions
|
|
115
|
+
|
|
116
|
+
Every decision and execution is recorded as an evidence event on the session's hash chain. Group a task into one session:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
s = ak.start_session()
|
|
120
|
+
# ... checks and actions ...
|
|
121
|
+
ak.end_session(s["session_id"])
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
const s = await ak.startSession();
|
|
126
|
+
// ... checks and actions ...
|
|
127
|
+
await ak.endSession({ sessionId: s.session_id });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Recording what the agent actually did
|
|
131
|
+
|
|
132
|
+
After an allowed action runs, record the execution, linked back to its decision by `authorization_id`:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
auth = ak.check_permission(action="send_email", resource="gmail", session_id=sid)
|
|
136
|
+
if auth["allowed"]:
|
|
137
|
+
send_email(...)
|
|
138
|
+
ak.record_action(session_id=sid, authorization_id=auth["event_id"], tool="gmail",
|
|
139
|
+
action="send_email", resource="gmail", result_status="success")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`record_action` never refuses to record, so evidence is not lost for billing reasons. Actions that run without any authorization decision are surfaced on the dashboard as findings (`executions_without_authorization`), because the SDK is self-reported: an agent that bypasses the wrapped functions produces no evidence.
|
|
143
|
+
|
|
144
|
+
## guard(): authorize, run, record in one call
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
out = ak.guard(sid, "gmail", "send_email", lambda: send_email(...), arguments={"to": "x@company.com"})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
const out = await ak.guard({ sessionId: sid, resource: "gmail", action: "send_email" }, async () => sendEmail());
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If authorize denies, `guard` returns the denial and does not run the function.
|
|
155
|
+
|
|
156
|
+
## Delegated authorization
|
|
157
|
+
|
|
158
|
+
A parent agent can delegate a scoped subset of its permissions to a child agent. Scopes are `resource:action` strings, must be a subset of the parent's own permissions, and chains are depth-limited. See `delegate()` in the source docstrings.
|
|
159
|
+
|
|
160
|
+
## Production API
|
|
161
|
+
|
|
162
|
+
Base URL: `https://agentkey.base44.app` (the SDK default). Override it with `base_url` (Python) or `baseUrl` (JavaScript) if you self-host.
|
|
163
|
+
|
|
164
|
+
All endpoints are POST with a Bearer API key, under `/api/functions/`:
|
|
165
|
+
|
|
166
|
+
- `authorize`
|
|
167
|
+
- `record_action`
|
|
168
|
+
- `start_session`
|
|
169
|
+
- `end_session`
|
|
170
|
+
- `delegate`
|
|
171
|
+
- `validate_api_key` (GET)
|
|
172
|
+
|
|
173
|
+
Raw HTTP:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
curl -X POST https://agentkey.base44.app/api/functions/authorize \
|
|
177
|
+
-H "Authorization: Bearer agent_live_xxxxx" \
|
|
178
|
+
-H "Content-Type: application/json" \
|
|
179
|
+
-d '{"action":"send_email","resource":"gmail","arguments":{"to":"x@company.com"}}'
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Dashboard
|
|
183
|
+
|
|
184
|
+
Sessions, evidence events, approvals, findings and permission settings: https://agentkey.base44.app
|
|
185
|
+
|
|
186
|
+
## Security limitations (current, accurate)
|
|
187
|
+
|
|
188
|
+
- Evidence events are hash-chained per session, and session Merkle roots are attested with HMAC-SHA256 under a server-side key. This detects altered or missing events in stored evidence. It is not an asymmetric digital signature scheme, it does not make records forgery-proof against a compromised server, and it is not a non-repudiation guarantee.
|
|
189
|
+
- SDK instrumentation is self-reported. `wrap()` and `record_action` record what the agent reports; an agent that calls tools outside the SDK produces no evidence. This is reported as an `executions_without_authorization` finding, but not prevented.
|
|
190
|
+
- Checks fail closed on network errors and timeouts. In observe mode the SDK still lets the call run; only enforce mode blocks it.
|
|
191
|
+
- No SOC 2 or other third-party compliance audit has been completed.
|
|
192
|
+
|
|
193
|
+
License: MIT.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentkey
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""AgentKey — minimal Python SDK. Zero dependencies (stdlib only). Drop into your project.
|
|
2
|
+
|
|
3
|
+
One-line install: wrap an existing agent and AgentKey records every tool call
|
|
4
|
+
as paired authorization + execution evidence — no call-site changes.
|
|
5
|
+
|
|
6
|
+
from agentkey import AgentKeyClient
|
|
7
|
+
ak = AgentKeyClient(api_key="agent_live_xxxxx") # base_url defaults to the production API
|
|
8
|
+
agent = ak.wrap(my_agent) # observe mode (default): records, blocks nothing
|
|
9
|
+
# agent = ak.wrap(my_agent, mode="enforce") # raises AgentKeyDenied on a denial
|
|
10
|
+
|
|
11
|
+
Fail-closed: if the AgentKey service cannot return a valid decision within 5
|
|
12
|
+
seconds (network error, timeout, non-JSON body, or a response missing the
|
|
13
|
+
`allowed` field), every check returns {"allowed": False, "reason":
|
|
14
|
+
"agentkey_unreachable", "fail_closed": True} so an agent never proceeds on a
|
|
15
|
+
missing or ambiguous decision.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import hashlib
|
|
20
|
+
import time
|
|
21
|
+
import sys
|
|
22
|
+
import atexit
|
|
23
|
+
import functools
|
|
24
|
+
import urllib.request
|
|
25
|
+
import urllib.error
|
|
26
|
+
|
|
27
|
+
FAIL_CLOSED = {"allowed": False, "reason": "agentkey_unreachable", "fail_closed": True}
|
|
28
|
+
TIMEOUT = 5.0
|
|
29
|
+
|
|
30
|
+
# Production API. Override with base_url=... only if you self-host.
|
|
31
|
+
DEFAULT_BASE_URL = "https://agentkey.base44.app"
|
|
32
|
+
|
|
33
|
+
# The production API sits behind a WAF that blocks library-default User-Agent
|
|
34
|
+
# strings (Python-urllib/*). Identify the SDK with a self-describing agent so
|
|
35
|
+
# requests are not rejected as bot traffic.
|
|
36
|
+
USER_AGENT = "Mozilla/5.0 (compatible; agentkey-python-sdk/1.0.0; +https://agentkey.base44.app)"
|
|
37
|
+
|
|
38
|
+
_wrap_first_run = {"done": False}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AgentKeyDenied(Exception):
|
|
42
|
+
"""Raised in enforce mode when AgentKey denies a wrapped tool call."""
|
|
43
|
+
def __init__(self, reason="denied by AgentKey policy", result=None):
|
|
44
|
+
super().__init__(reason)
|
|
45
|
+
self.reason = reason
|
|
46
|
+
self.result = result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AgentKeyClient:
|
|
50
|
+
def __init__(self, api_key, base_url=DEFAULT_BASE_URL):
|
|
51
|
+
if not api_key:
|
|
52
|
+
raise ValueError("AgentKey: api_key is required")
|
|
53
|
+
self.api_key = api_key
|
|
54
|
+
self.base_url = base_url.rstrip("/")
|
|
55
|
+
|
|
56
|
+
def _post(self, path, body, extra_headers=None):
|
|
57
|
+
url = f"{self.base_url}{path}"
|
|
58
|
+
data = json.dumps(body).encode("utf-8")
|
|
59
|
+
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
|
|
60
|
+
if extra_headers:
|
|
61
|
+
headers.update(extra_headers)
|
|
62
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
63
|
+
parsed = None
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
66
|
+
parsed = json.loads(resp.read().decode("utf-8"))
|
|
67
|
+
except urllib.error.HTTPError as e:
|
|
68
|
+
try:
|
|
69
|
+
parsed = json.loads(e.read().decode("utf-8"))
|
|
70
|
+
except Exception:
|
|
71
|
+
return dict(FAIL_CLOSED)
|
|
72
|
+
except Exception:
|
|
73
|
+
return dict(FAIL_CLOSED)
|
|
74
|
+
if not isinstance(parsed, dict) or "allowed" not in parsed:
|
|
75
|
+
return dict(FAIL_CLOSED)
|
|
76
|
+
return parsed
|
|
77
|
+
|
|
78
|
+
def _post_json(self, path, body, extra_headers=None):
|
|
79
|
+
url = f"{self.base_url}{path}"
|
|
80
|
+
data = json.dumps(body).encode("utf-8")
|
|
81
|
+
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
|
|
82
|
+
if extra_headers:
|
|
83
|
+
headers.update(extra_headers)
|
|
84
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
85
|
+
try:
|
|
86
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
87
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
88
|
+
except urllib.error.HTTPError as e:
|
|
89
|
+
try:
|
|
90
|
+
return json.loads(e.read().decode("utf-8"))
|
|
91
|
+
except Exception:
|
|
92
|
+
return {"error": "agentkey_unreachable"}
|
|
93
|
+
except Exception:
|
|
94
|
+
return {"error": "agentkey_unreachable"}
|
|
95
|
+
|
|
96
|
+
def check_permission(self, action, resource, metadata=None, tool=None,
|
|
97
|
+
arguments=None, session_id=None, parent_event_id=None,
|
|
98
|
+
parent_agent_id=None, source=None, scan_id=None,
|
|
99
|
+
delegation_id=None):
|
|
100
|
+
"""Evaluate a permission. Returns a dict with allowed, reason, request_id,
|
|
101
|
+
session_id, event_id, usage, plan. Fails closed on any error/timeout.
|
|
102
|
+
Pass scan_id to link an input scan to this decision (overrides a
|
|
103
|
+
session-level scan). Pass delegation_id to act under a delegation the
|
|
104
|
+
child agent received — the whole chain is revalidated server-side."""
|
|
105
|
+
if not action or not resource:
|
|
106
|
+
raise ValueError("AgentKey: action and resource are required")
|
|
107
|
+
return self._post(
|
|
108
|
+
"/api/functions/authorize",
|
|
109
|
+
{
|
|
110
|
+
"action": action, "resource": resource, "tool": tool,
|
|
111
|
+
"metadata": metadata or {}, "arguments": arguments,
|
|
112
|
+
"session_id": session_id, "parent_event_id": parent_event_id,
|
|
113
|
+
"parent_agent_id": parent_agent_id, "source": source,
|
|
114
|
+
"input_scan_id": scan_id, "delegation_id": delegation_id,
|
|
115
|
+
},
|
|
116
|
+
extra_headers={"Authorization": f"Bearer {self.api_key}"},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def delegate(self, child_agent_id, scopes, ttl_hours=None, session_id=None,
|
|
120
|
+
parent_delegation_id=None):
|
|
121
|
+
"""Delegated authorization: delegate a scoped subset of THIS agent's
|
|
122
|
+
authority (the caller is the PARENT agent) to a child agent. Scopes are
|
|
123
|
+
"resource:action" strings and must be a subset of the parent's own
|
|
124
|
+
permissions — anything broader is blocked server-side (scope escalation
|
|
125
|
+
prevention). Returns { allowed, decision, delegation_id, scopes_granted,
|
|
126
|
+
depth, expires_at }. The delegation_id is runtime-minted: the caller can
|
|
127
|
+
neither choose nor reset it. The child then passes delegation_id to
|
|
128
|
+
check_permission() to act under the delegation. Fail-closed."""
|
|
129
|
+
if not child_agent_id or not scopes or not isinstance(scopes, list):
|
|
130
|
+
raise ValueError("AgentKey: child_agent_id and a non-empty scopes list are required")
|
|
131
|
+
return self._post_json(
|
|
132
|
+
"/api/functions/delegate",
|
|
133
|
+
{
|
|
134
|
+
"child_agent_id": child_agent_id,
|
|
135
|
+
"scopes": scopes,
|
|
136
|
+
"ttl_hours": ttl_hours,
|
|
137
|
+
"session_id": session_id,
|
|
138
|
+
"parent_delegation_id": parent_delegation_id,
|
|
139
|
+
},
|
|
140
|
+
extra_headers={"Authorization": f"Bearer {self.api_key}"},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def validate(self):
|
|
144
|
+
"""Validate the key is active. Fails closed on any error/timeout."""
|
|
145
|
+
url = f"{self.base_url}/api/functions/validate_api_key"
|
|
146
|
+
req = urllib.request.Request(
|
|
147
|
+
url,
|
|
148
|
+
headers={"Authorization": f"Bearer {self.api_key}", "User-Agent": USER_AGENT},
|
|
149
|
+
)
|
|
150
|
+
parsed = None
|
|
151
|
+
try:
|
|
152
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
153
|
+
parsed = json.loads(resp.read().decode("utf-8"))
|
|
154
|
+
except Exception:
|
|
155
|
+
return {"valid": False, "reason": "agentkey_unreachable", "fail_closed": True}
|
|
156
|
+
if not isinstance(parsed, dict) or "valid" not in parsed:
|
|
157
|
+
return {"valid": False, "reason": "agentkey_unreachable", "fail_closed": True}
|
|
158
|
+
return parsed
|
|
159
|
+
|
|
160
|
+
def start_session(self, metadata=None, scan_id=None):
|
|
161
|
+
"""Start an evidence session. Pass scan_id to attach an input scan once
|
|
162
|
+
at the start of a task; it is inherited by every decision in the session
|
|
163
|
+
unless overridden per call. Returns { session_id, agent_id, started_at,
|
|
164
|
+
preceding_scan_id }."""
|
|
165
|
+
return self._post_json(
|
|
166
|
+
"/api/functions/start_session",
|
|
167
|
+
{"metadata": metadata or {}, "scan_id": scan_id},
|
|
168
|
+
extra_headers={"Authorization": f"Bearer {self.api_key}"},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def end_session(self, session_id, status=None):
|
|
172
|
+
"""End an evidence session. Pass status='failed' to mark it failed."""
|
|
173
|
+
return self._post_json(
|
|
174
|
+
"/api/functions/end_session",
|
|
175
|
+
{"session_id": session_id, "status": status},
|
|
176
|
+
extra_headers={"Authorization": f"Bearer {self.api_key}"},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def record_action(self, session_id, tool, action, result_status, resource=None,
|
|
180
|
+
arguments=None, authorization_id=None, result_hash=None,
|
|
181
|
+
duration_ms=None, error_message=None, metadata=None,
|
|
182
|
+
scan_id=None):
|
|
183
|
+
"""Record what an agent ACTUALLY did, as an execution event on the same hash
|
|
184
|
+
chain as the decision that permitted it. Self-reported: an agent that does
|
|
185
|
+
not call it produces no execution evidence — which is exactly why
|
|
186
|
+
executions_without_authorization is reported. `metadata` is sanitized to
|
|
187
|
+
primitives before storage (used by wrap() to mark would_block). Pass
|
|
188
|
+
scan_id to link an input scan to this execution."""
|
|
189
|
+
return self._post_json(
|
|
190
|
+
"/api/functions/record_action",
|
|
191
|
+
{
|
|
192
|
+
"session_id": session_id, "authorization_id": authorization_id,
|
|
193
|
+
"tool": tool, "action": action, "resource": resource,
|
|
194
|
+
"arguments": arguments, "result_status": result_status,
|
|
195
|
+
"result_hash": result_hash, "duration_ms": duration_ms,
|
|
196
|
+
"error_message": error_message, "metadata": metadata,
|
|
197
|
+
"input_scan_id": scan_id,
|
|
198
|
+
},
|
|
199
|
+
extra_headers={"Authorization": f"Bearer {self.api_key}"},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _hash_result(self, result):
|
|
203
|
+
try:
|
|
204
|
+
return hashlib.sha256(
|
|
205
|
+
json.dumps(result, sort_keys=True, default=str).encode("utf-8")
|
|
206
|
+
).hexdigest()
|
|
207
|
+
except Exception:
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
def guard(self, session_id, resource, action, fn, tool=None, arguments=None,
|
|
211
|
+
scan_id=None):
|
|
212
|
+
"""Authorize, run ``fn`` if allowed, then record the execution. Pass
|
|
213
|
+
scan_id to link an input scan to both the decision and the execution."""
|
|
214
|
+
auth = self.check_permission(action=action, resource=resource, tool=tool,
|
|
215
|
+
arguments=arguments, session_id=session_id,
|
|
216
|
+
scan_id=scan_id)
|
|
217
|
+
if not auth or not auth.get("allowed"):
|
|
218
|
+
return auth
|
|
219
|
+
t0 = time.time()
|
|
220
|
+
try:
|
|
221
|
+
result = fn()
|
|
222
|
+
duration_ms = int((time.time() - t0) * 1000)
|
|
223
|
+
try:
|
|
224
|
+
rh = self._hash_result(result)
|
|
225
|
+
self.record_action(session_id=session_id, tool=tool, action=action,
|
|
226
|
+
resource=resource, arguments=arguments,
|
|
227
|
+
result_status="success", result_hash=rh,
|
|
228
|
+
duration_ms=duration_ms,
|
|
229
|
+
authorization_id=auth.get("event_id"),
|
|
230
|
+
scan_id=scan_id)
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
return {"allowed": True, "result": result, "authorization": auth}
|
|
234
|
+
except Exception as e:
|
|
235
|
+
duration_ms = int((time.time() - t0) * 1000)
|
|
236
|
+
try:
|
|
237
|
+
self.record_action(session_id=session_id, tool=tool, action=action,
|
|
238
|
+
resource=resource, arguments=arguments,
|
|
239
|
+
result_status="error", error_message=str(e)[:200],
|
|
240
|
+
duration_ms=duration_ms,
|
|
241
|
+
authorization_id=auth.get("event_id"),
|
|
242
|
+
scan_id=scan_id)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
def wrap(self, target, session_id=None, mode="observe", scan_id=None):
|
|
248
|
+
"""One-line auto-instrumentation. Wraps an existing agent — an MCP client
|
|
249
|
+
(call_tool), a LangChain agent or tool list (tools[].invoke/run/_call),
|
|
250
|
+
a plain dict of functions, or a single function — so every tool call is
|
|
251
|
+
authorized and recorded without changing any call site.
|
|
252
|
+
|
|
253
|
+
mode='observe' (default) records everything and blocks nothing; a denial
|
|
254
|
+
is logged and the call still runs, recorded with would_block=True.
|
|
255
|
+
mode='enforce' raises AgentKeyDenied on a denial and does NOT run the tool.
|
|
256
|
+
|
|
257
|
+
If no session_id is given, a session is started automatically and ended
|
|
258
|
+
best-effort at process exit (never throws on shutdown). Wrapped functions
|
|
259
|
+
preserve name/docstring/return value — a wrapped agent behaves identically
|
|
260
|
+
when everything is allowed.
|
|
261
|
+
|
|
262
|
+
wrap() is still self-reported: an agent that bypasses the wrapped
|
|
263
|
+
functions produces no evidence, which is exactly why
|
|
264
|
+
executions_without_authorization is reported.
|
|
265
|
+
|
|
266
|
+
Pass scan_id to attach an input scan to the auto-started session so
|
|
267
|
+
every wrapped tool call inherits it."""
|
|
268
|
+
return _wrap_target(self, target, session_id, mode, scan_id)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# --- wrap() internals ---------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def _name_of(t, fallback):
|
|
274
|
+
n = getattr(t, "name", None)
|
|
275
|
+
if isinstance(n, str) and n:
|
|
276
|
+
return n
|
|
277
|
+
return fallback
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _get_invoke(t):
|
|
281
|
+
for m in ("invoke", "run", "_call"):
|
|
282
|
+
v = getattr(t, m, None)
|
|
283
|
+
if callable(v):
|
|
284
|
+
return m
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _discover(target):
|
|
289
|
+
# MCP client
|
|
290
|
+
ct = getattr(target, "call_tool", None)
|
|
291
|
+
if callable(ct):
|
|
292
|
+
return [{"name": "call_tool", "host": target, "method": "call_tool", "fn": None, "kind": "mcp"}]
|
|
293
|
+
if isinstance(target, list):
|
|
294
|
+
out = []
|
|
295
|
+
for i, t in enumerate(target):
|
|
296
|
+
out.append({"name": _name_of(t, f"tool_{i}"), "host": t, "method": _get_invoke(t), "fn": None, "kind": "list"})
|
|
297
|
+
return out
|
|
298
|
+
tools = getattr(target, "tools", None)
|
|
299
|
+
if isinstance(tools, list):
|
|
300
|
+
out = []
|
|
301
|
+
for i, t in enumerate(tools):
|
|
302
|
+
out.append({"name": _name_of(t, f"tool_{i}"), "host": t, "method": _get_invoke(t), "fn": None, "kind": "langchain"})
|
|
303
|
+
return out
|
|
304
|
+
if isinstance(target, dict):
|
|
305
|
+
out = []
|
|
306
|
+
for name, fn in target.items():
|
|
307
|
+
if callable(fn):
|
|
308
|
+
out.append({"name": name, "host": target, "method": None, "fn": fn, "kind": "dict"})
|
|
309
|
+
return out
|
|
310
|
+
if callable(target):
|
|
311
|
+
return [{"name": _name_of(target, "anonymous"), "host": target, "method": None, "fn": target, "kind": "fn"}]
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _args_to_dict(args, kwargs):
|
|
316
|
+
if kwargs:
|
|
317
|
+
return dict(kwargs)
|
|
318
|
+
if args and isinstance(args[0], dict):
|
|
319
|
+
return dict(args[0])
|
|
320
|
+
if args:
|
|
321
|
+
return {"input": list(args)}
|
|
322
|
+
return {}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _make_wrapped(client, get_session, mode, name, original):
|
|
326
|
+
enforce = mode == "enforce"
|
|
327
|
+
|
|
328
|
+
@functools.wraps(original)
|
|
329
|
+
def wrapped(*args, **kwargs):
|
|
330
|
+
sid = get_session()
|
|
331
|
+
argobj = _args_to_dict(args, kwargs)
|
|
332
|
+
try:
|
|
333
|
+
auth = client.check_permission(action="invoke", resource=name,
|
|
334
|
+
arguments=argobj, session_id=sid)
|
|
335
|
+
except Exception:
|
|
336
|
+
auth = {"allowed": False, "reason": "agentkey_unreachable", "fail_closed": True}
|
|
337
|
+
allowed = bool(auth and auth.get("allowed") is True)
|
|
338
|
+
if not allowed and enforce:
|
|
339
|
+
raise AgentKeyDenied(auth.get("reason") if auth else "denied", auth)
|
|
340
|
+
if not allowed and not enforce:
|
|
341
|
+
print(f"AgentKey would have blocked this: {auth.get('reason') if auth else 'denied'}", file=sys.stderr)
|
|
342
|
+
t0 = time.time()
|
|
343
|
+
meta = {"would_block": True} if not allowed else None
|
|
344
|
+
try:
|
|
345
|
+
result = original(*args, **kwargs)
|
|
346
|
+
except Exception as e:
|
|
347
|
+
try:
|
|
348
|
+
client.record_action(session_id=sid, tool=name, action="invoke", resource=name,
|
|
349
|
+
arguments=argobj, result_status="error",
|
|
350
|
+
error_message=str(e)[:200],
|
|
351
|
+
duration_ms=int((time.time() - t0) * 1000),
|
|
352
|
+
authorization_id=auth.get("event_id") if auth else None,
|
|
353
|
+
metadata=meta)
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
raise
|
|
357
|
+
try:
|
|
358
|
+
rh = client._hash_result(result)
|
|
359
|
+
client.record_action(session_id=sid, tool=name, action="invoke", resource=name,
|
|
360
|
+
arguments=argobj, result_status="success", result_hash=rh,
|
|
361
|
+
duration_ms=int((time.time() - t0) * 1000),
|
|
362
|
+
authorization_id=auth.get("event_id") if auth else None,
|
|
363
|
+
metadata=meta)
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
return wrapped
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _wrap_target(client, target, session_id, mode, scan_id=None):
|
|
372
|
+
tools = _discover(target)
|
|
373
|
+
if not tools:
|
|
374
|
+
return target
|
|
375
|
+
auto = session_id is None
|
|
376
|
+
state = {"session_id": session_id, "booted": False, "ended": False}
|
|
377
|
+
|
|
378
|
+
def boot():
|
|
379
|
+
if state["booted"]:
|
|
380
|
+
return
|
|
381
|
+
state["booted"] = True
|
|
382
|
+
if not state["session_id"]:
|
|
383
|
+
try:
|
|
384
|
+
s = client.start_session(scan_id=scan_id)
|
|
385
|
+
state["session_id"] = s.get("session_id") if s else None
|
|
386
|
+
except Exception:
|
|
387
|
+
state["session_id"] = None
|
|
388
|
+
if not _wrap_first_run["done"]:
|
|
389
|
+
_wrap_first_run["done"] = True
|
|
390
|
+
url = f"{client.base_url}/sessions/{state['session_id'] or ''}"
|
|
391
|
+
try:
|
|
392
|
+
print(f"AgentKey: session={state['session_id'] or '?'} tools={len(tools)} "
|
|
393
|
+
f"mode={'enforce' if mode == 'enforce' else 'observe'} dashboard={url}",
|
|
394
|
+
file=sys.stderr)
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
if auto:
|
|
398
|
+
try:
|
|
399
|
+
def _on_shutdown():
|
|
400
|
+
if state.get("ended") or not state.get("session_id"):
|
|
401
|
+
return
|
|
402
|
+
state["ended"] = True
|
|
403
|
+
try:
|
|
404
|
+
client.end_session(state["session_id"])
|
|
405
|
+
except Exception:
|
|
406
|
+
pass
|
|
407
|
+
atexit.register(_on_shutdown)
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
def get_session():
|
|
412
|
+
boot()
|
|
413
|
+
return state["session_id"]
|
|
414
|
+
|
|
415
|
+
def make(name, original):
|
|
416
|
+
return _make_wrapped(client, get_session, mode, name, original)
|
|
417
|
+
|
|
418
|
+
kinds = {t["kind"] for t in tools}
|
|
419
|
+
if "dict" in kinds:
|
|
420
|
+
return {t["name"]: make(t["name"], t["fn"]) for t in tools}
|
|
421
|
+
if "fn" in kinds:
|
|
422
|
+
return make(tools[0]["name"], tools[0]["fn"])
|
|
423
|
+
# list / langchain / mcp: mutate each tool's method in place so an agent
|
|
424
|
+
# that calls its own tools internally still routes through the wrapper.
|
|
425
|
+
for t in tools:
|
|
426
|
+
original = None
|
|
427
|
+
if t["method"]:
|
|
428
|
+
original = getattr(t["host"], t["method"], None)
|
|
429
|
+
if not callable(original):
|
|
430
|
+
continue
|
|
431
|
+
setattr(t["host"], t["method"], make(t["name"], original))
|
|
432
|
+
return target
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentkey"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Authorization and evidence SDK for AI agents: check permissions before every action, record what agents actually did."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AgentKey" }]
|
|
13
|
+
keywords = ["ai", "agents", "authorization", "permissions", "audit", "mcp", "security", "llm"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://agentkey.base44.app"
|
|
32
|
+
Documentation = "https://agentkey.base44.app/docs"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
py-modules = ["agentkey"]
|
agentkey-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
import unittest
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, ".")
|
|
8
|
+
from agentkey import AgentKeyClient, AgentKeyDenied, DEFAULT_BASE_URL, USER_AGENT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
12
|
+
def do_POST(self):
|
|
13
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
14
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
15
|
+
tool = str((body or {}).get("tool") or "")
|
|
16
|
+
resource = str((body or {}).get("resource") or "")
|
|
17
|
+
if resource == "deny_tool":
|
|
18
|
+
tool = "deny_tool"
|
|
19
|
+
if self.path == "/html":
|
|
20
|
+
self.send_response(200)
|
|
21
|
+
self.send_header("Content-Type", "text/html")
|
|
22
|
+
self.end_headers()
|
|
23
|
+
self.wfile.write(b"<html>not json</html>")
|
|
24
|
+
return
|
|
25
|
+
if tool == "deny_tool":
|
|
26
|
+
out = {"allowed": False, "reason": "denied by policy"}
|
|
27
|
+
elif tool == "ask_tool":
|
|
28
|
+
out = {"allowed": False, "reason": "approval required", "approval_required": True}
|
|
29
|
+
elif tool == "echo":
|
|
30
|
+
out = {"allowed": True, "reason": "ok", "user_agent": self.headers.get("User-Agent")}
|
|
31
|
+
else:
|
|
32
|
+
out = {"allowed": True, "reason": "ok", "event_id": "evt_1"}
|
|
33
|
+
status = 200
|
|
34
|
+
payload = json.dumps(out).encode("utf-8")
|
|
35
|
+
self.send_response(status)
|
|
36
|
+
self.send_header("Content-Type", "application/json")
|
|
37
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
38
|
+
self.end_headers()
|
|
39
|
+
self.wfile.write(payload)
|
|
40
|
+
|
|
41
|
+
def log_message(self, *a):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _start_server():
|
|
46
|
+
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
47
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
48
|
+
thread.start()
|
|
49
|
+
return server, f"http://127.0.0.1:{server.server_address[1]}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestConstructor(unittest.TestCase):
|
|
53
|
+
def test_api_key_required(self):
|
|
54
|
+
with self.assertRaises(ValueError):
|
|
55
|
+
AgentKeyClient(api_key="")
|
|
56
|
+
|
|
57
|
+
def test_default_base_url_is_production(self):
|
|
58
|
+
self.assertEqual(AgentKeyClient(api_key="k").base_url, DEFAULT_BASE_URL)
|
|
59
|
+
self.assertEqual(DEFAULT_BASE_URL, "https://agentkey.base44.app")
|
|
60
|
+
|
|
61
|
+
def test_base_url_trailing_slash_stripped(self):
|
|
62
|
+
self.assertEqual(AgentKeyClient(api_key="k", base_url="http://x/").base_url, "http://x")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestValidation(unittest.TestCase):
|
|
66
|
+
def setUp(self):
|
|
67
|
+
self.ak = AgentKeyClient(api_key="k", base_url="http://127.0.0.1:1")
|
|
68
|
+
|
|
69
|
+
def test_check_permission_requires_action_and_resource(self):
|
|
70
|
+
with self.assertRaises(ValueError):
|
|
71
|
+
self.ak.check_permission(action="", resource="gmail")
|
|
72
|
+
with self.assertRaises(ValueError):
|
|
73
|
+
self.ak.check_permission(action="send", resource="")
|
|
74
|
+
|
|
75
|
+
def test_delegate_requires_child_and_scopes(self):
|
|
76
|
+
with self.assertRaises(ValueError):
|
|
77
|
+
self.ak.delegate("", ["a:b"])
|
|
78
|
+
with self.assertRaises(ValueError):
|
|
79
|
+
self.ak.delegate("child", "not-a-list")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestFailClosed(unittest.TestCase):
|
|
83
|
+
def setUp(self):
|
|
84
|
+
self.ak = AgentKeyClient(api_key="k", base_url="http://127.0.0.1:1")
|
|
85
|
+
|
|
86
|
+
def test_check_permission_unreachable(self):
|
|
87
|
+
r = self.ak.check_permission(action="send", resource="gmail")
|
|
88
|
+
self.assertFalse(r["allowed"])
|
|
89
|
+
self.assertEqual(r["reason"], "agentkey_unreachable")
|
|
90
|
+
self.assertTrue(r["fail_closed"])
|
|
91
|
+
|
|
92
|
+
def test_validate_unreachable(self):
|
|
93
|
+
r = self.ak.validate()
|
|
94
|
+
self.assertFalse(r["valid"])
|
|
95
|
+
self.assertTrue(r["fail_closed"])
|
|
96
|
+
|
|
97
|
+
def test_non_json_body_fails_closed(self):
|
|
98
|
+
server, base = _start_server()
|
|
99
|
+
try:
|
|
100
|
+
ak = AgentKeyClient(api_key="k", base_url=base)
|
|
101
|
+
r = ak.check_permission(action="send", resource="gmail", tool="t")
|
|
102
|
+
r2 = ak._post("/html", {})
|
|
103
|
+
self.assertFalse(r2["allowed"])
|
|
104
|
+
finally:
|
|
105
|
+
server.shutdown()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestLiveServer(unittest.TestCase):
|
|
109
|
+
@classmethod
|
|
110
|
+
def setUpClass(cls):
|
|
111
|
+
cls.server, cls.base = _start_server()
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def tearDownClass(cls):
|
|
115
|
+
cls.server.shutdown()
|
|
116
|
+
|
|
117
|
+
def test_allow_response(self):
|
|
118
|
+
ak = AgentKeyClient(api_key="k", base_url=self.base)
|
|
119
|
+
r = ak.check_permission(action="send", resource="gmail")
|
|
120
|
+
self.assertTrue(r["allowed"])
|
|
121
|
+
self.assertEqual(r["event_id"], "evt_1")
|
|
122
|
+
|
|
123
|
+
def test_deny_response(self):
|
|
124
|
+
ak = AgentKeyClient(api_key="k", base_url=self.base)
|
|
125
|
+
r = ak.check_permission(action="send", resource="gmail", tool="deny_tool")
|
|
126
|
+
self.assertFalse(r["allowed"])
|
|
127
|
+
self.assertEqual(r["reason"], "denied by policy")
|
|
128
|
+
self.assertNotIn("fail_closed", r)
|
|
129
|
+
|
|
130
|
+
def test_ask_response(self):
|
|
131
|
+
ak = AgentKeyClient(api_key="k", base_url=self.base)
|
|
132
|
+
r = ak.check_permission(action="send", resource="gmail", tool="ask_tool")
|
|
133
|
+
self.assertFalse(r["allowed"])
|
|
134
|
+
self.assertTrue(r["approval_required"])
|
|
135
|
+
|
|
136
|
+
def test_user_agent_sent(self):
|
|
137
|
+
ak = AgentKeyClient(api_key="k", base_url=self.base)
|
|
138
|
+
r = ak.check_permission(action="send", resource="gmail", tool="echo")
|
|
139
|
+
self.assertEqual(r["user_agent"], USER_AGENT)
|
|
140
|
+
self.assertIn("agentkey-python-sdk", r["user_agent"])
|
|
141
|
+
|
|
142
|
+
def test_hash_result_deterministic(self):
|
|
143
|
+
ak = AgentKeyClient(api_key="k", base_url=self.base)
|
|
144
|
+
self.assertEqual(ak._hash_result({"a": 1}), ak._hash_result({"a": 1}))
|
|
145
|
+
self.assertEqual(len(ak._hash_result({"a": 1})), 64)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestWrap(unittest.TestCase):
|
|
149
|
+
@classmethod
|
|
150
|
+
def setUpClass(cls):
|
|
151
|
+
cls.server, cls.base = _start_server()
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def tearDownClass(cls):
|
|
155
|
+
cls.server.shutdown()
|
|
156
|
+
|
|
157
|
+
def test_observe_mode_runs_despite_denial(self):
|
|
158
|
+
ak = AgentKeyClient(api_key="k", base_url=self.base)
|
|
159
|
+
tools = ak.wrap({"deny_tool": lambda x: "ran(" + x + ")"}, session_id="sid")
|
|
160
|
+
self.assertEqual(tools["deny_tool"]("ok"), "ran(ok)")
|
|
161
|
+
|
|
162
|
+
def test_enforce_mode_blocks_on_denial(self):
|
|
163
|
+
ak = AgentKeyClient(api_key="k", base_url=self.base)
|
|
164
|
+
tools = ak.wrap({"deny_tool": lambda x: "should not run"}, mode="enforce", session_id="sid")
|
|
165
|
+
with self.assertRaises(AgentKeyDenied):
|
|
166
|
+
tools["deny_tool"]("x")
|
|
167
|
+
|
|
168
|
+
def test_wrapped_name_preserved(self):
|
|
169
|
+
ak = AgentKeyClient(api_key="k", base_url=self.base)
|
|
170
|
+
|
|
171
|
+
def my_tool(x):
|
|
172
|
+
return x
|
|
173
|
+
|
|
174
|
+
wrapped = ak.wrap(my_tool)
|
|
175
|
+
self.assertEqual(wrapped.__name__, "my_tool")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
unittest.main()
|