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 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.
@@ -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,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,9 @@
1
+ LICENSE
2
+ README.md
3
+ agentkey.py
4
+ pyproject.toml
5
+ agentkey.egg-info/PKG-INFO
6
+ agentkey.egg-info/SOURCES.txt
7
+ agentkey.egg-info/dependency_links.txt
8
+ agentkey.egg-info/top_level.txt
9
+ tests/test_agentkey.py
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()