aidr-agent 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aidr_agent-0.1.0/.gitignore +37 -0
- aidr_agent-0.1.0/LICENSE +202 -0
- aidr_agent-0.1.0/PKG-INFO +93 -0
- aidr_agent-0.1.0/README.md +67 -0
- aidr_agent-0.1.0/aidr_agent/README.md +67 -0
- aidr_agent-0.1.0/aidr_agent/__init__.py +22 -0
- aidr_agent-0.1.0/aidr_agent/__main__.py +76 -0
- aidr_agent-0.1.0/aidr_agent/agent.py +156 -0
- aidr_agent-0.1.0/aidr_agent/http.py +41 -0
- aidr_agent-0.1.0/aidr_agent/identity.py +81 -0
- aidr_agent-0.1.0/aidr_agent/keystore.py +99 -0
- aidr_agent-0.1.0/aidr_agent/langchain.py +157 -0
- aidr_agent-0.1.0/aidr_agent/registry.py +172 -0
- aidr_agent-0.1.0/pyproject.toml +37 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.ruff_cache/
|
|
6
|
+
*.egg-info/
|
|
7
|
+
node_modules/
|
|
8
|
+
# Wrangler / miniflare local state
|
|
9
|
+
.wrangler/
|
|
10
|
+
.env
|
|
11
|
+
# Key material must never reach the repository, even in development.
|
|
12
|
+
*.pem
|
|
13
|
+
*.key
|
|
14
|
+
*.jwk
|
|
15
|
+
secrets/
|
|
16
|
+
|
|
17
|
+
# Terraform: local state, provider cache, and variable files (which name real accounts).
|
|
18
|
+
.terraform/
|
|
19
|
+
*.tfstate
|
|
20
|
+
*.tfstate.*
|
|
21
|
+
terraform.tfvars
|
|
22
|
+
.terraform.lock.hcl.bak
|
|
23
|
+
|
|
24
|
+
# Local rehearsal of the staging stack (deploy/staging/rehearsal/rehearse.py): generated keys.
|
|
25
|
+
.rehearsal/
|
|
26
|
+
|
|
27
|
+
# Evoliv Core Client (client/desktop): Rust build output, Tauri-generated schemas, the web build.
|
|
28
|
+
client/desktop/target/
|
|
29
|
+
client/desktop/src-tauri/gen/
|
|
30
|
+
client/desktop/dist/
|
|
31
|
+
|
|
32
|
+
# Python packages for PyPI (scripts/build_python_packages.py): staging and output.
|
|
33
|
+
/build/
|
|
34
|
+
/dist/
|
|
35
|
+
|
|
36
|
+
# The Worker/origin shared secret (edge/cloudflare-worker README, demo runbook).
|
|
37
|
+
edge-secret
|
aidr_agent-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aidr-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Give an AI agent a verifiable AIDR identity: enroll from code, sign requests, LangChain tools.
|
|
5
|
+
Project-URL: Homepage, https://aidr.evolivcore.com
|
|
6
|
+
Project-URL: Documentation, https://evolivcore.com/resources
|
|
7
|
+
Author: Evoliv Core
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,aiaf,aidr,identity,langchain,rfc9421
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: aidr-crypto<0.2,>=0.1
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Provides-Extra: langchain
|
|
19
|
+
Requires-Dist: langchain-core>=1.0; extra == 'langchain'
|
|
20
|
+
Provides-Extra: langchain-community
|
|
21
|
+
Requires-Dist: langchain-community>=0.4; extra == 'langchain-community'
|
|
22
|
+
Requires-Dist: requests>=2.31; extra == 'langchain-community'
|
|
23
|
+
Provides-Extra: requests
|
|
24
|
+
Requires-Dist: requests>=2.31; extra == 'requests'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# aidr-agent (Python)
|
|
28
|
+
|
|
29
|
+
Give an AI agent a verifiable AIDR identity from its first run. Apache-2.0.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install "aidr-agent[requests]" # add ,langchain for the LangChain tools
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from aidr_agent import Agent
|
|
37
|
+
|
|
38
|
+
agent = Agent.load_or_enroll("research-01", audiences=["https://shop.example"])
|
|
39
|
+
agent.session().get("https://shop.example/products") # signed; other sites get anonymous requests
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
1. An organisation admin issues an **enrollment token** in the console (Organisations → Enrollment tokens).
|
|
43
|
+
2. Put it in `AIDR_ENROLLMENT_TOKEN` where the agent runs. The first run generates the key **on that
|
|
44
|
+
machine**, enrolls, proves the key and saves the identity; later runs load it. The token is then
|
|
45
|
+
no longer needed.
|
|
46
|
+
3. Requests to the agent's allowed sites carry an RFC 9421 signature and a short-lived credential.
|
|
47
|
+
Anything else goes out unsigned, or is refused with `on_unlisted="refuse"`. Plain http is never
|
|
48
|
+
signed, except to localhost.
|
|
49
|
+
|
|
50
|
+
Python 3.10+. Extras: `requests` (for `agent.session()` / `agent.auth()`), `langchain` (the tools, on
|
|
51
|
+
`langchain-core`), `langchain-community` (the `RequestsToolkit` wrapper). It installs `aidr-crypto`
|
|
52
|
+
and `aidr-core`, the same signing code the registry runs.
|
|
53
|
+
|
|
54
|
+
## Where the identity is kept
|
|
55
|
+
|
|
56
|
+
| Store | Use it for | How |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `FileKeyStore` (default) | laptops, VMs, persistent volumes | `~/.aidr/agents/<name>/identity.json`, mode 0600 (`AIDR_AGENT_HOME` moves it) |
|
|
59
|
+
| `EnvKeyStore` | containers, serverless | `python -m aidr_agent enroll --name <name> --print-identity`, then store the line as the secret `AIDR_AGENT_IDENTITY` |
|
|
60
|
+
|
|
61
|
+
A container that starts with an empty disk would enroll a new agent on every start and spend a use of
|
|
62
|
+
the token each time: use `AIDR_AGENT_IDENTITY` there.
|
|
63
|
+
|
|
64
|
+
## HTTP clients
|
|
65
|
+
|
|
66
|
+
- `agent.session()`: a `requests.Session`. `agent.auth()`: for `requests.get(url, auth=…)`.
|
|
67
|
+
- `agent.httpx_auth()`: for `httpx.Client` and `httpx.AsyncClient`.
|
|
68
|
+
- `agent.sign_headers(method, url)`: the headers themselves, for any other client.
|
|
69
|
+
|
|
70
|
+
## LangChain
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from aidr_agent.langchain import aidr_http_tools
|
|
74
|
+
|
|
75
|
+
tools = aidr_http_tools(agent) # http_get, http_post: signed, and limited to the agent's sites
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The tools reach only the origins they are given (by default the agent's allowed audiences). An LLM
|
|
79
|
+
choosing URLs is how a prompt-injected agent would present its identity somewhere it should not, or
|
|
80
|
+
reach internal addresses. For code still on `langchain-community`'s `RequestsToolkit`,
|
|
81
|
+
`signed_requests_wrapper(agent)` is a drop-in `TextRequestsWrapper` that signs, sync and async.
|
|
82
|
+
`langchain-community` is being sunset, so new code should use the tools.
|
|
83
|
+
|
|
84
|
+
## Command line
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
aidr-agent enroll --name research-01 --audience https://shop.example
|
|
88
|
+
aidr-agent whoami --name research-01
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
(`python -m aidr_agent …` is the same command.)
|
|
92
|
+
|
|
93
|
+
The token is read only from `AIDR_ENROLLMENT_TOKEN`, never from the command line.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# aidr-agent (Python)
|
|
2
|
+
|
|
3
|
+
Give an AI agent a verifiable AIDR identity from its first run. Apache-2.0.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install "aidr-agent[requests]" # add ,langchain for the LangChain tools
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from aidr_agent import Agent
|
|
11
|
+
|
|
12
|
+
agent = Agent.load_or_enroll("research-01", audiences=["https://shop.example"])
|
|
13
|
+
agent.session().get("https://shop.example/products") # signed; other sites get anonymous requests
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
1. An organisation admin issues an **enrollment token** in the console (Organisations → Enrollment tokens).
|
|
17
|
+
2. Put it in `AIDR_ENROLLMENT_TOKEN` where the agent runs. The first run generates the key **on that
|
|
18
|
+
machine**, enrolls, proves the key and saves the identity; later runs load it. The token is then
|
|
19
|
+
no longer needed.
|
|
20
|
+
3. Requests to the agent's allowed sites carry an RFC 9421 signature and a short-lived credential.
|
|
21
|
+
Anything else goes out unsigned, or is refused with `on_unlisted="refuse"`. Plain http is never
|
|
22
|
+
signed, except to localhost.
|
|
23
|
+
|
|
24
|
+
Python 3.10+. Extras: `requests` (for `agent.session()` / `agent.auth()`), `langchain` (the tools, on
|
|
25
|
+
`langchain-core`), `langchain-community` (the `RequestsToolkit` wrapper). It installs `aidr-crypto`
|
|
26
|
+
and `aidr-core`, the same signing code the registry runs.
|
|
27
|
+
|
|
28
|
+
## Where the identity is kept
|
|
29
|
+
|
|
30
|
+
| Store | Use it for | How |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `FileKeyStore` (default) | laptops, VMs, persistent volumes | `~/.aidr/agents/<name>/identity.json`, mode 0600 (`AIDR_AGENT_HOME` moves it) |
|
|
33
|
+
| `EnvKeyStore` | containers, serverless | `python -m aidr_agent enroll --name <name> --print-identity`, then store the line as the secret `AIDR_AGENT_IDENTITY` |
|
|
34
|
+
|
|
35
|
+
A container that starts with an empty disk would enroll a new agent on every start and spend a use of
|
|
36
|
+
the token each time: use `AIDR_AGENT_IDENTITY` there.
|
|
37
|
+
|
|
38
|
+
## HTTP clients
|
|
39
|
+
|
|
40
|
+
- `agent.session()`: a `requests.Session`. `agent.auth()`: for `requests.get(url, auth=…)`.
|
|
41
|
+
- `agent.httpx_auth()`: for `httpx.Client` and `httpx.AsyncClient`.
|
|
42
|
+
- `agent.sign_headers(method, url)`: the headers themselves, for any other client.
|
|
43
|
+
|
|
44
|
+
## LangChain
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from aidr_agent.langchain import aidr_http_tools
|
|
48
|
+
|
|
49
|
+
tools = aidr_http_tools(agent) # http_get, http_post: signed, and limited to the agent's sites
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The tools reach only the origins they are given (by default the agent's allowed audiences). An LLM
|
|
53
|
+
choosing URLs is how a prompt-injected agent would present its identity somewhere it should not, or
|
|
54
|
+
reach internal addresses. For code still on `langchain-community`'s `RequestsToolkit`,
|
|
55
|
+
`signed_requests_wrapper(agent)` is a drop-in `TextRequestsWrapper` that signs, sync and async.
|
|
56
|
+
`langchain-community` is being sunset, so new code should use the tools.
|
|
57
|
+
|
|
58
|
+
## Command line
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
aidr-agent enroll --name research-01 --audience https://shop.example
|
|
62
|
+
aidr-agent whoami --name research-01
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
(`python -m aidr_agent …` is the same command.)
|
|
66
|
+
|
|
67
|
+
The token is read only from `AIDR_ENROLLMENT_TOKEN`, never from the command line.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# aidr-agent (Python)
|
|
2
|
+
|
|
3
|
+
Give an AI agent a verifiable AIDR identity from its first run. Apache-2.0.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install "aidr-agent[requests]" # add ,langchain for the LangChain tools
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from aidr_agent import Agent
|
|
11
|
+
|
|
12
|
+
agent = Agent.load_or_enroll("research-01", audiences=["https://shop.example"])
|
|
13
|
+
agent.session().get("https://shop.example/products") # signed; other sites get anonymous requests
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
1. An organisation admin issues an **enrollment token** in the console (Organisations → Enrollment tokens).
|
|
17
|
+
2. Put it in `AIDR_ENROLLMENT_TOKEN` where the agent runs. The first run generates the key **on that
|
|
18
|
+
machine**, enrolls, proves the key and saves the identity; later runs load it. The token is then
|
|
19
|
+
no longer needed.
|
|
20
|
+
3. Requests to the agent's allowed sites carry an RFC 9421 signature and a short-lived credential.
|
|
21
|
+
Anything else goes out unsigned, or is refused with `on_unlisted="refuse"`. Plain http is never
|
|
22
|
+
signed, except to localhost.
|
|
23
|
+
|
|
24
|
+
Python 3.10+. Extras: `requests` (for `agent.session()` / `agent.auth()`), `langchain` (the tools, on
|
|
25
|
+
`langchain-core`), `langchain-community` (the `RequestsToolkit` wrapper). It installs `aidr-crypto`
|
|
26
|
+
and `aidr-core`, the same signing code the registry runs.
|
|
27
|
+
|
|
28
|
+
## Where the identity is kept
|
|
29
|
+
|
|
30
|
+
| Store | Use it for | How |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `FileKeyStore` (default) | laptops, VMs, persistent volumes | `~/.aidr/agents/<name>/identity.json`, mode 0600 (`AIDR_AGENT_HOME` moves it) |
|
|
33
|
+
| `EnvKeyStore` | containers, serverless | `python -m aidr_agent enroll --name <name> --print-identity`, then store the line as the secret `AIDR_AGENT_IDENTITY` |
|
|
34
|
+
|
|
35
|
+
A container that starts with an empty disk would enroll a new agent on every start and spend a use of
|
|
36
|
+
the token each time: use `AIDR_AGENT_IDENTITY` there.
|
|
37
|
+
|
|
38
|
+
## HTTP clients
|
|
39
|
+
|
|
40
|
+
- `agent.session()`: a `requests.Session`. `agent.auth()`: for `requests.get(url, auth=…)`.
|
|
41
|
+
- `agent.httpx_auth()`: for `httpx.Client` and `httpx.AsyncClient`.
|
|
42
|
+
- `agent.sign_headers(method, url)`: the headers themselves, for any other client.
|
|
43
|
+
|
|
44
|
+
## LangChain
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from aidr_agent.langchain import aidr_http_tools
|
|
48
|
+
|
|
49
|
+
tools = aidr_http_tools(agent) # http_get, http_post: signed, and limited to the agent's sites
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The tools reach only the origins they are given (by default the agent's allowed audiences). An LLM
|
|
53
|
+
choosing URLs is how a prompt-injected agent would present its identity somewhere it should not, or
|
|
54
|
+
reach internal addresses. For code still on `langchain-community`'s `RequestsToolkit`,
|
|
55
|
+
`signed_requests_wrapper(agent)` is a drop-in `TextRequestsWrapper` that signs, sync and async.
|
|
56
|
+
`langchain-community` is being sunset, so new code should use the tools.
|
|
57
|
+
|
|
58
|
+
## Command line
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
aidr-agent enroll --name research-01 --audience https://shop.example
|
|
62
|
+
aidr-agent whoami --name research-01
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
(`python -m aidr_agent …` is the same command.)
|
|
66
|
+
|
|
67
|
+
The token is read only from `AIDR_ENROLLMENT_TOKEN`, never from the command line.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""AIDR agent SDK — give an AI agent a verifiable identity from its first run.
|
|
2
|
+
|
|
3
|
+
from aidr_agent import Agent
|
|
4
|
+
|
|
5
|
+
agent = Agent.load_or_enroll("research-01", audiences=["https://shop.example"])
|
|
6
|
+
session = agent.session() # requests; or agent.httpx_auth() for httpx
|
|
7
|
+
session.get("https://shop.example/products") # signed (RFC 9421) with a short-lived credential
|
|
8
|
+
|
|
9
|
+
The first run enrolls with the token in AIDR_ENROLLMENT_TOKEN (issued in the AIDR console) and
|
|
10
|
+
keeps the new identity; later runs load it. The private key is generated here and never sent.
|
|
11
|
+
LangChain: ``aidr_agent.langchain``. Command line: ``python -m aidr_agent --help``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .agent import Agent, NotAllowed
|
|
15
|
+
from .identity import AgentIdentity, IdentityError
|
|
16
|
+
from .keystore import EnvKeyStore, FileKeyStore, KeyStore
|
|
17
|
+
from .registry import RegistryError
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = ["Agent", "AgentIdentity", "EnvKeyStore", "FileKeyStore", "IdentityError", "KeyStore",
|
|
22
|
+
"NotAllowed", "RegistryError"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""``python -m aidr_agent`` — enroll an agent, or show who it is.
|
|
2
|
+
|
|
3
|
+
python -m aidr_agent enroll --name research-01 [--audience https://shop.example] [--print-identity]
|
|
4
|
+
python -m aidr_agent whoami --name research-01
|
|
5
|
+
|
|
6
|
+
``enroll`` reads the token from AIDR_ENROLLMENT_TOKEN (never from the command line, where it would
|
|
7
|
+
land in shell history and process listings). ``--print-identity`` prints the whole identity,
|
|
8
|
+
private key included, as one line for AIDR_AGENT_IDENTITY in a container's secret store — and
|
|
9
|
+
saves nothing to disk.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from .agent import REGISTRY_ENV, TOKEN_ENV
|
|
19
|
+
from .identity import DEFAULT_REGISTRY, IdentityError, normalize_origin
|
|
20
|
+
from .keystore import IDENTITY_ENV, EnvKeyStore, FileKeyStore
|
|
21
|
+
from .registry import RegistryError, enroll, token_id
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: list[str] | None = None) -> int:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="python -m aidr_agent", description=__doc__,
|
|
26
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
27
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
28
|
+
enroll_cmd = commands.add_parser("enroll", help="enroll a new agent with an enrollment token")
|
|
29
|
+
enroll_cmd.add_argument("--name", required=True)
|
|
30
|
+
enroll_cmd.add_argument("--audience", action="append", default=None,
|
|
31
|
+
help="an origin the agent may identify to (repeatable)")
|
|
32
|
+
enroll_cmd.add_argument("--description", default="")
|
|
33
|
+
enroll_cmd.add_argument("--registry", default=os.environ.get(REGISTRY_ENV) or DEFAULT_REGISTRY)
|
|
34
|
+
enroll_cmd.add_argument("--print-identity", action="store_true",
|
|
35
|
+
help=f"print the identity for {IDENTITY_ENV} instead of saving it")
|
|
36
|
+
whoami = commands.add_parser("whoami", help="show the saved identity (never the key)")
|
|
37
|
+
whoami.add_argument("--name", required=True)
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
if args.command == "whoami":
|
|
42
|
+
identity = EnvKeyStore().load(args.name) or FileKeyStore().load(args.name)
|
|
43
|
+
if identity is None:
|
|
44
|
+
print(f"agent {args.name!r} is not enrolled here", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
print(f"aid: {identity.aid}\nregistry: {identity.registry}\n"
|
|
47
|
+
f"audiences: {', '.join(identity.audiences) or '(unrestricted)'}")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
token = os.environ.get(TOKEN_ENV)
|
|
51
|
+
if not token:
|
|
52
|
+
print(f"set {TOKEN_ENV} to the token from the AIDR console", file=sys.stderr)
|
|
53
|
+
return 2
|
|
54
|
+
store = FileKeyStore()
|
|
55
|
+
if not args.print_identity and store.load(args.name) is not None:
|
|
56
|
+
print(f"agent {args.name!r} is already enrolled here ({store.path(args.name)})", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
audiences = [normalize_origin(a) for a in args.audience] if args.audience else None
|
|
59
|
+
print(f"enrolling {args.name} at {args.registry} with token {token_id(token)}…", file=sys.stderr)
|
|
60
|
+
identity = enroll(token, name=args.name, registry=args.registry, audiences=audiences,
|
|
61
|
+
description=args.description)
|
|
62
|
+
if args.print_identity:
|
|
63
|
+
print(f"enrolled {identity.aid}. Store this line as the secret {IDENTITY_ENV} "
|
|
64
|
+
"(it contains the private key):", file=sys.stderr)
|
|
65
|
+
print(identity.to_portable())
|
|
66
|
+
else:
|
|
67
|
+
store.save(identity)
|
|
68
|
+
print(f"enrolled {identity.aid}; saved to {store.path(args.name)}", file=sys.stderr)
|
|
69
|
+
return 0
|
|
70
|
+
except (RegistryError, IdentityError, ValueError) as exc:
|
|
71
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
sys.exit(main())
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The agent: load its identity or enroll once, then sign the requests it is allowed to sign."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from aidr_crypto.signatures import CREDENTIAL_HEADER, sign_request
|
|
13
|
+
|
|
14
|
+
from .identity import DEFAULT_REGISTRY, AgentIdentity, normalize_origin
|
|
15
|
+
from .keystore import EnvKeyStore, FileKeyStore, KeyStore
|
|
16
|
+
from .registry import CredentialCache, enroll, token_id
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("aidr_agent")
|
|
19
|
+
|
|
20
|
+
TOKEN_ENV = "AIDR_ENROLLMENT_TOKEN" # noqa: S105 - a variable name, not a secret
|
|
21
|
+
REGISTRY_ENV = "AIDR_REGISTRY_URL"
|
|
22
|
+
|
|
23
|
+
OnUnlisted = Literal["unsigned", "refuse"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotAllowed(Exception):
|
|
27
|
+
"""The agent was asked to identify itself to a site it is not allowed to (on_unlisted="refuse")."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Agent:
|
|
31
|
+
"""An enrolled agent. Build one with :meth:`load_or_enroll`.
|
|
32
|
+
|
|
33
|
+
``sign_for`` — the origins this process presents the identity to — defaults to the
|
|
34
|
+
audiences the registry allows. An agent without audiences is unrestricted at the registry;
|
|
35
|
+
this SDK then signs for any https origin. Requests elsewhere go out unsigned (anonymous), or
|
|
36
|
+
are refused with ``on_unlisted="refuse"``. Plain http is never signed except to localhost: a
|
|
37
|
+
credential sent in clear text is a credential given away.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, identity: AgentIdentity, *, sign_for: Iterable[str] | None = None,
|
|
41
|
+
on_unlisted: OnUnlisted = "unsigned", registry_http: httpx.Client | None = None,
|
|
42
|
+
registry_async_http: httpx.AsyncClient | None = None) -> None:
|
|
43
|
+
self.identity = identity
|
|
44
|
+
allowed = identity.audiences if sign_for is None else [normalize_origin(o) for o in sign_for]
|
|
45
|
+
if identity.audiences and not set(allowed) <= set(identity.audiences):
|
|
46
|
+
raise NotAllowed(f"the registry issues credentials only for {identity.audiences}")
|
|
47
|
+
self.sign_for: frozenset[str] = frozenset(allowed)
|
|
48
|
+
self.on_unlisted = on_unlisted
|
|
49
|
+
#: ``registry_http`` / ``registry_async_http``: clients for the registry only (a proxy,
|
|
50
|
+
#: a private CA). Never the clients the agent's own requests go through.
|
|
51
|
+
self.credentials = CredentialCache(identity, http=registry_http,
|
|
52
|
+
async_http=registry_async_http)
|
|
53
|
+
|
|
54
|
+
# -- construction ----------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def load_or_enroll(cls, name: str, *, audiences: list[str] | None = None,
|
|
58
|
+
token: str | None = None, registry: str | None = None,
|
|
59
|
+
key_store: KeyStore | None = None, description: str = "",
|
|
60
|
+
on_unlisted: OnUnlisted = "unsigned",
|
|
61
|
+
registry_http: httpx.Client | None = None,
|
|
62
|
+
registry_async_http: httpx.AsyncClient | None = None) -> Agent:
|
|
63
|
+
"""The identity saved for ``name``, or — first run only — enroll with the token.
|
|
64
|
+
|
|
65
|
+
The token comes from ``token`` or AIDR_ENROLLMENT_TOKEN and is used once; after that the
|
|
66
|
+
saved identity is enough, and the token can be removed from the environment.
|
|
67
|
+
"""
|
|
68
|
+
stores: list[KeyStore] = [key_store] if key_store else [EnvKeyStore(), FileKeyStore()]
|
|
69
|
+
for store in stores:
|
|
70
|
+
identity = store.load(name)
|
|
71
|
+
if identity is not None:
|
|
72
|
+
if token or os.environ.get(TOKEN_ENV):
|
|
73
|
+
logger.info("agent %s is already enrolled (%s); %s is no longer needed",
|
|
74
|
+
name, identity.aid, TOKEN_ENV)
|
|
75
|
+
return cls(identity, on_unlisted=on_unlisted, registry_http=registry_http,
|
|
76
|
+
registry_async_http=registry_async_http)
|
|
77
|
+
|
|
78
|
+
presented = token or os.environ.get(TOKEN_ENV)
|
|
79
|
+
if not presented:
|
|
80
|
+
raise LookupError(f"agent {name!r} is not enrolled here and no enrollment token was given "
|
|
81
|
+
f"(set {TOKEN_ENV}, or pass token=)")
|
|
82
|
+
target = registry or os.environ.get(REGISTRY_ENV) or DEFAULT_REGISTRY
|
|
83
|
+
writable = next((s for s in stores if not isinstance(s, EnvKeyStore)), None)
|
|
84
|
+
if writable is None:
|
|
85
|
+
raise LookupError("no writable key store to keep the new identity in")
|
|
86
|
+
logger.info("enrolling agent %s at %s with token %s", name, target, token_id(presented))
|
|
87
|
+
identity = enroll(presented, name=name, registry=target,
|
|
88
|
+
audiences=[normalize_origin(a) for a in audiences] if audiences else None,
|
|
89
|
+
description=description, http=registry_http)
|
|
90
|
+
writable.save(identity)
|
|
91
|
+
logger.info("enrolled %s", identity.aid)
|
|
92
|
+
return cls(identity, on_unlisted=on_unlisted, registry_http=registry_http,
|
|
93
|
+
registry_async_http=registry_async_http)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def aid(self) -> str:
|
|
97
|
+
return self.identity.aid
|
|
98
|
+
|
|
99
|
+
# -- the signing decision ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def should_sign(self, url: str) -> bool:
|
|
102
|
+
origin = normalize_origin(url)
|
|
103
|
+
scheme = origin.split("://", 1)[0]
|
|
104
|
+
host = origin.split("://", 1)[1].split(":")[0]
|
|
105
|
+
if scheme != "https" and not (scheme == "http" and host in ("localhost", "127.0.0.1")):
|
|
106
|
+
return False
|
|
107
|
+
return origin in self.sign_for if self.sign_for else True
|
|
108
|
+
|
|
109
|
+
def _unlisted(self, url: str) -> None:
|
|
110
|
+
if self.on_unlisted == "refuse":
|
|
111
|
+
raise NotAllowed(f"{normalize_origin(url)} is not an origin this agent identifies to")
|
|
112
|
+
|
|
113
|
+
def sign_headers(self, method: str, url: str, headers: Mapping[str, str] | None = None,
|
|
114
|
+
*, http: httpx.Client | None = None) -> dict[str, str]:
|
|
115
|
+
"""The headers that identify this agent on one request, or ``{}`` when it should not."""
|
|
116
|
+
if not self.should_sign(url):
|
|
117
|
+
self._unlisted(url)
|
|
118
|
+
return {}
|
|
119
|
+
credential = self.credentials.get(normalize_origin(url), http)
|
|
120
|
+
signed = sign_request(method=method, url=url, headers=dict(headers or {}),
|
|
121
|
+
private_key=self.identity.private_key, keyid=self.identity.aid)
|
|
122
|
+
signed[CREDENTIAL_HEADER] = credential
|
|
123
|
+
return signed
|
|
124
|
+
|
|
125
|
+
async def asign_headers(self, method: str, url: str, headers: Mapping[str, str] | None = None,
|
|
126
|
+
*, http: httpx.AsyncClient | None = None) -> dict[str, str]:
|
|
127
|
+
if not self.should_sign(url):
|
|
128
|
+
self._unlisted(url)
|
|
129
|
+
return {}
|
|
130
|
+
credential = await self.credentials.aget(normalize_origin(url), http)
|
|
131
|
+
signed = sign_request(method=method, url=url, headers=dict(headers or {}),
|
|
132
|
+
private_key=self.identity.private_key, keyid=self.identity.aid)
|
|
133
|
+
signed[CREDENTIAL_HEADER] = credential
|
|
134
|
+
return signed
|
|
135
|
+
|
|
136
|
+
# -- adapters -----------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def auth(self) -> Any:
|
|
139
|
+
"""A ``requests`` auth object: ``requests.get(url, auth=agent.auth())``."""
|
|
140
|
+
from .http import RequestsAuth
|
|
141
|
+
|
|
142
|
+
return RequestsAuth(self)
|
|
143
|
+
|
|
144
|
+
def session(self) -> Any:
|
|
145
|
+
"""A ``requests.Session`` that identifies the agent where it may."""
|
|
146
|
+
import requests
|
|
147
|
+
|
|
148
|
+
session = requests.Session()
|
|
149
|
+
session.auth = self.auth()
|
|
150
|
+
return session
|
|
151
|
+
|
|
152
|
+
def httpx_auth(self) -> httpx.Auth:
|
|
153
|
+
"""An ``httpx.Auth`` for ``httpx.Client`` and ``httpx.AsyncClient``."""
|
|
154
|
+
from .http import HttpxAuth
|
|
155
|
+
|
|
156
|
+
return HttpxAuth(self)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""HTTP-library adapters. Each signs a request at the moment it is sent, with a fresh nonce."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator, Generator
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .agent import Agent
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from requests.auth import AuthBase as _RequestsAuthBase
|
|
15
|
+
except ImportError: # requests is optional
|
|
16
|
+
_RequestsAuthBase = object # type: ignore[assignment,misc]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RequestsAuth(_RequestsAuthBase): # type: ignore[misc,valid-type]
|
|
20
|
+
def __init__(self, agent: Agent) -> None:
|
|
21
|
+
self.agent = agent
|
|
22
|
+
|
|
23
|
+
def __call__(self, request): # noqa: ANN001, ANN204 - requests.PreparedRequest
|
|
24
|
+
request.headers.update(self.agent.sign_headers(request.method, request.url))
|
|
25
|
+
return request
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HttpxAuth(httpx.Auth):
|
|
29
|
+
"""Sync and async. Credentials are fetched with a separate client, never the one being used,
|
|
30
|
+
so a request to the registry is never itself signed for the site."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, agent: Agent) -> None:
|
|
33
|
+
self.agent = agent
|
|
34
|
+
|
|
35
|
+
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
36
|
+
request.headers.update(self.agent.sign_headers(request.method, str(request.url)))
|
|
37
|
+
yield request
|
|
38
|
+
|
|
39
|
+
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
40
|
+
request.headers.update(await self.agent.asign_headers(request.method, str(request.url)))
|
|
41
|
+
yield request
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""An agent's identity: its identifier, where it is registered, what it may sign for, and its key.
|
|
2
|
+
|
|
3
|
+
The private key is generated on the machine the agent runs on and never leaves it except where the
|
|
4
|
+
operator puts it (a key file, or the one environment variable a container reads). The registry
|
|
5
|
+
only ever received the public half.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from aidr_crypto.keys import jwk_to_private_key, private_key_to_jwk, public_key_to_jwk
|
|
17
|
+
|
|
18
|
+
#: The registry an identity belongs to when nothing else says (AIDR_REGISTRY_URL overrides it).
|
|
19
|
+
DEFAULT_REGISTRY = "https://api.evolivcore.com"
|
|
20
|
+
|
|
21
|
+
FORMAT = "aidr-agent-identity/1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IdentityError(Exception):
|
|
25
|
+
"""A stored identity is missing, unreadable, or not safe to use."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AgentIdentity:
|
|
30
|
+
aid: str
|
|
31
|
+
name: str
|
|
32
|
+
registry: str
|
|
33
|
+
private_key: Any = field(repr=False)
|
|
34
|
+
#: Origins the registry issues credentials for; empty = unrestricted.
|
|
35
|
+
audiences: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def public_jwk(self) -> dict[str, Any]:
|
|
38
|
+
return public_key_to_jwk(self.private_key.public_key())
|
|
39
|
+
|
|
40
|
+
# -- serialisation ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def to_document(self) -> dict[str, Any]:
|
|
43
|
+
"""Everything, private key included. Treat the result as a secret."""
|
|
44
|
+
return {"format": FORMAT, "aid": self.aid, "name": self.name, "registry": self.registry,
|
|
45
|
+
"audiences": list(self.audiences), "private_jwk": private_key_to_jwk(self.private_key)}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_document(cls, document: dict[str, Any]) -> AgentIdentity:
|
|
49
|
+
if document.get("format") != FORMAT:
|
|
50
|
+
raise IdentityError(f"not an {FORMAT} document")
|
|
51
|
+
try:
|
|
52
|
+
return cls(aid=document["aid"], name=document["name"], registry=document["registry"],
|
|
53
|
+
audiences=list(document.get("audiences") or []),
|
|
54
|
+
private_key=jwk_to_private_key(document["private_jwk"]))
|
|
55
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
56
|
+
raise IdentityError(f"malformed identity: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
def to_portable(self) -> str:
|
|
59
|
+
"""One line for a secret store: base64url(JSON). A secret — it holds the private key."""
|
|
60
|
+
raw = json.dumps(self.to_document(), separators=(",", ":")).encode()
|
|
61
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_portable(cls, value: str) -> AgentIdentity:
|
|
65
|
+
value = value.strip()
|
|
66
|
+
try:
|
|
67
|
+
raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
68
|
+
return cls.from_document(json.loads(raw))
|
|
69
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
70
|
+
raise IdentityError("AIDR_AGENT_IDENTITY is not a valid identity") from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize_origin(url: str) -> str:
|
|
74
|
+
"""``https://Shop.Example:443/path`` → ``https://shop.example`` (the registry's normalisation)."""
|
|
75
|
+
parts = urlsplit(url)
|
|
76
|
+
if not parts.scheme or not parts.hostname:
|
|
77
|
+
raise ValueError(f"not a URL: {url!r}")
|
|
78
|
+
host = parts.hostname.lower().rstrip(".")
|
|
79
|
+
default = {"https": 443, "http": 80}.get(parts.scheme)
|
|
80
|
+
port = f":{parts.port}" if parts.port and parts.port != default else ""
|
|
81
|
+
return f"{parts.scheme}://{host}{port}"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Where an agent's identity (and so its private key) is kept between runs.
|
|
2
|
+
|
|
3
|
+
FileKeyStore a laptop, a VM, a persistent volume: ``<home>/<name>/identity.json``, mode 0600.
|
|
4
|
+
EnvKeyStore a container or a serverless function: the whole identity in AIDR_AGENT_IDENTITY,
|
|
5
|
+
held by the platform's secret store (``python -m aidr_agent enroll --print-identity``).
|
|
6
|
+
|
|
7
|
+
An ephemeral container with a FileKeyStore on a throwaway disk enrolls a NEW agent every time it
|
|
8
|
+
starts, and spends a use of the token each time. Use EnvKeyStore there.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from .identity import AgentIdentity, IdentityError
|
|
21
|
+
|
|
22
|
+
IDENTITY_ENV = "AIDR_AGENT_IDENTITY"
|
|
23
|
+
HOME_ENV = "AIDR_AGENT_HOME"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KeyStore(Protocol):
|
|
27
|
+
def load(self, name: str) -> AgentIdentity | None: ...
|
|
28
|
+
def save(self, identity: AgentIdentity) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_home() -> Path:
|
|
32
|
+
return Path(os.environ.get(HOME_ENV) or Path.home() / ".aidr" / "agents")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FileKeyStore:
|
|
36
|
+
def __init__(self, home: str | os.PathLike[str] | None = None) -> None:
|
|
37
|
+
self.home = Path(home) if home is not None else default_home()
|
|
38
|
+
|
|
39
|
+
def path(self, name: str) -> Path:
|
|
40
|
+
if not name or "/" in name or "\\" in name or name in (".", ".."):
|
|
41
|
+
raise IdentityError(f"not a usable agent name for a directory: {name!r}")
|
|
42
|
+
return self.home / name / "identity.json"
|
|
43
|
+
|
|
44
|
+
def load(self, name: str) -> AgentIdentity | None:
|
|
45
|
+
path = self.path(name)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return None
|
|
48
|
+
_refuse_if_shared(path)
|
|
49
|
+
try:
|
|
50
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
51
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
52
|
+
raise IdentityError(f"{path} is unreadable: {exc}") from exc
|
|
53
|
+
return AgentIdentity.from_document(document)
|
|
54
|
+
|
|
55
|
+
def save(self, identity: AgentIdentity) -> None:
|
|
56
|
+
path = self.path(identity.name)
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
if sys.platform != "win32":
|
|
59
|
+
os.chmod(path.parent, 0o700)
|
|
60
|
+
# Created 0600 from the start, never widened then narrowed: there is no moment at which
|
|
61
|
+
# another user could read the key.
|
|
62
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
63
|
+
tmp = path.with_suffix(".tmp")
|
|
64
|
+
if tmp.exists():
|
|
65
|
+
tmp.unlink()
|
|
66
|
+
fd = os.open(tmp, flags, 0o600)
|
|
67
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
68
|
+
json.dump(identity.to_document(), handle, indent=2)
|
|
69
|
+
os.replace(tmp, path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EnvKeyStore:
|
|
73
|
+
"""Read-only: the identity is put in the environment by whoever deploys the agent."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, variable: str = IDENTITY_ENV) -> None:
|
|
76
|
+
self.variable = variable
|
|
77
|
+
|
|
78
|
+
def load(self, name: str) -> AgentIdentity | None:
|
|
79
|
+
value = os.environ.get(self.variable)
|
|
80
|
+
if not value:
|
|
81
|
+
return None
|
|
82
|
+
identity = AgentIdentity.from_portable(value)
|
|
83
|
+
if identity.name != name:
|
|
84
|
+
raise IdentityError(f"{self.variable} holds agent {identity.name!r}, not {name!r}")
|
|
85
|
+
return identity
|
|
86
|
+
|
|
87
|
+
def save(self, identity: AgentIdentity) -> None:
|
|
88
|
+
raise IdentityError(f"{self.variable} is read-only; enroll with "
|
|
89
|
+
"`python -m aidr_agent enroll --print-identity` and store the output")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _refuse_if_shared(path: Path) -> None:
|
|
93
|
+
"""A key file that other users can read is a key other users may have copied."""
|
|
94
|
+
if sys.platform == "win32":
|
|
95
|
+
return # NTFS ACLs, not mode bits; the profile directory is private by default
|
|
96
|
+
mode = path.stat().st_mode
|
|
97
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
98
|
+
raise IdentityError(f"{path} is readable by other users (mode {oct(mode & 0o777)}); "
|
|
99
|
+
f"run: chmod 600 {path}")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""LangChain: give an agent's HTTP tools an AIDR identity.
|
|
2
|
+
|
|
3
|
+
Built on ``langchain-core`` (``pip install langchain-core``)::
|
|
4
|
+
|
|
5
|
+
from aidr_agent import Agent
|
|
6
|
+
from aidr_agent.langchain import aidr_http_tools
|
|
7
|
+
|
|
8
|
+
agent = Agent.load_or_enroll("research-01", audiences=["https://shop.example"])
|
|
9
|
+
tools = aidr_http_tools(agent) # http_get and http_post, signed, limited to the audiences
|
|
10
|
+
|
|
11
|
+
The tools reach only the origins they are given — by default the agent's allowed audiences —
|
|
12
|
+
because an LLM choosing URLs is exactly the prompt-injection path by which an agent would present
|
|
13
|
+
its identity (or fetch internal addresses) somewhere it should not.
|
|
14
|
+
|
|
15
|
+
``langchain-community`` is being sunset; for code that still uses its ``RequestsToolkit``,
|
|
16
|
+
:func:`signed_requests_wrapper` returns a drop-in ``TextRequestsWrapper`` that signs, sync and async.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from .identity import normalize_origin
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from .agent import Agent
|
|
30
|
+
|
|
31
|
+
DEFAULT_MAX_CHARS = 20_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OriginNotAllowed(ValueError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _origins(agent: Agent, origins: Iterable[str] | None) -> frozenset[str]:
|
|
39
|
+
chosen = frozenset(normalize_origin(o) for o in origins) if origins is not None else agent.sign_for
|
|
40
|
+
if not chosen:
|
|
41
|
+
raise ValueError("name the origins these tools may reach (origins=[...]): this agent has no "
|
|
42
|
+
"allowed audiences to default to")
|
|
43
|
+
return chosen
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check(url: str, allowed: frozenset[str]) -> None:
|
|
47
|
+
try:
|
|
48
|
+
origin = normalize_origin(url)
|
|
49
|
+
except ValueError as exc:
|
|
50
|
+
raise OriginNotAllowed(f"not a URL: {url!r}") from exc
|
|
51
|
+
if origin not in allowed:
|
|
52
|
+
raise OriginNotAllowed(f"{origin} is not one of the sites this tool may reach: "
|
|
53
|
+
f"{', '.join(sorted(allowed))}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _render(response: httpx.Response, max_chars: int) -> str:
|
|
57
|
+
text = response.text
|
|
58
|
+
if len(text) > max_chars:
|
|
59
|
+
text = text[:max_chars] + f"\n…[truncated, {len(response.text)} characters in all]"
|
|
60
|
+
return f"HTTP {response.status_code}\n{text}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def aidr_http_tools(agent: Agent, *, origins: Iterable[str] | None = None,
|
|
64
|
+
max_chars: int = DEFAULT_MAX_CHARS, timeout: float = 30.0,
|
|
65
|
+
methods: Iterable[str] = ("GET", "POST")) -> list[Any]:
|
|
66
|
+
"""``http_get`` / ``http_post`` tools that identify the agent to the sites they reach."""
|
|
67
|
+
from langchain_core.tools import StructuredTool
|
|
68
|
+
from pydantic import BaseModel, Field
|
|
69
|
+
|
|
70
|
+
allowed = _origins(agent, origins)
|
|
71
|
+
auth = agent.httpx_auth()
|
|
72
|
+
sites = ", ".join(sorted(allowed))
|
|
73
|
+
tools: list[Any] = []
|
|
74
|
+
|
|
75
|
+
class GetArgs(BaseModel):
|
|
76
|
+
url: str = Field(description=f"Absolute URL on one of: {sites}")
|
|
77
|
+
|
|
78
|
+
class PostArgs(BaseModel):
|
|
79
|
+
url: str = Field(description=f"Absolute URL on one of: {sites}")
|
|
80
|
+
body: dict[str, Any] = Field(default_factory=dict, description="JSON body to send")
|
|
81
|
+
|
|
82
|
+
def get(url: str) -> str:
|
|
83
|
+
_check(url, allowed)
|
|
84
|
+
with httpx.Client(auth=auth, timeout=timeout, follow_redirects=False) as client:
|
|
85
|
+
return _render(client.get(url), max_chars)
|
|
86
|
+
|
|
87
|
+
async def aget(url: str) -> str:
|
|
88
|
+
_check(url, allowed)
|
|
89
|
+
async with httpx.AsyncClient(auth=auth, timeout=timeout, follow_redirects=False) as client:
|
|
90
|
+
return _render(await client.get(url), max_chars)
|
|
91
|
+
|
|
92
|
+
def post(url: str, body: dict[str, Any] | None = None) -> str:
|
|
93
|
+
_check(url, allowed)
|
|
94
|
+
with httpx.Client(auth=auth, timeout=timeout, follow_redirects=False) as client:
|
|
95
|
+
return _render(client.post(url, json=body or {}), max_chars)
|
|
96
|
+
|
|
97
|
+
async def apost(url: str, body: dict[str, Any] | None = None) -> str:
|
|
98
|
+
_check(url, allowed)
|
|
99
|
+
async with httpx.AsyncClient(auth=auth, timeout=timeout, follow_redirects=False) as client:
|
|
100
|
+
return _render(await client.post(url, json=body or {}), max_chars)
|
|
101
|
+
|
|
102
|
+
wanted = {m.upper() for m in methods}
|
|
103
|
+
if "GET" in wanted:
|
|
104
|
+
tools.append(StructuredTool.from_function(
|
|
105
|
+
func=get, coroutine=aget, name="http_get", args_schema=GetArgs,
|
|
106
|
+
description=f"Fetch a URL as the verified agent {agent.aid}. Sites: {sites}."))
|
|
107
|
+
if "POST" in wanted:
|
|
108
|
+
tools.append(StructuredTool.from_function(
|
|
109
|
+
func=post, coroutine=apost, name="http_post", args_schema=PostArgs,
|
|
110
|
+
description=f"POST JSON to a URL as the verified agent {agent.aid}. Sites: {sites}."))
|
|
111
|
+
return tools
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def signed_requests_wrapper(agent: Agent, *, json_responses: bool = False) -> Any:
|
|
115
|
+
"""A ``langchain_community`` ``TextRequestsWrapper`` (or ``JsonRequestsWrapper``) that signs.
|
|
116
|
+
|
|
117
|
+
Its sync methods go through ``requests`` with the agent's auth; its async ones through aiohttp,
|
|
118
|
+
which accepts only BasicAuth as ``auth=``, so there the signature is added as headers instead.
|
|
119
|
+
"""
|
|
120
|
+
from contextlib import asynccontextmanager
|
|
121
|
+
|
|
122
|
+
from langchain_community.utilities.requests import (
|
|
123
|
+
JsonRequestsWrapper,
|
|
124
|
+
Requests,
|
|
125
|
+
TextRequestsWrapper,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
class SignedRequests(Requests):
|
|
129
|
+
agent: Any = None
|
|
130
|
+
|
|
131
|
+
@asynccontextmanager
|
|
132
|
+
async def _arequest(self, method: str, url: str, **kwargs: Any): # noqa: ANN202
|
|
133
|
+
import aiohttp
|
|
134
|
+
|
|
135
|
+
headers = {**(self.headers or {}), **await self.agent.asign_headers(method, url)}
|
|
136
|
+
if self.aiosession is not None:
|
|
137
|
+
async with self.aiosession.request(method, url, headers=headers, **kwargs) as response:
|
|
138
|
+
yield response
|
|
139
|
+
else:
|
|
140
|
+
async with aiohttp.ClientSession() as session, \
|
|
141
|
+
session.request(method, url, headers=headers, **kwargs) as response:
|
|
142
|
+
yield response
|
|
143
|
+
|
|
144
|
+
base = JsonRequestsWrapper if json_responses else TextRequestsWrapper
|
|
145
|
+
|
|
146
|
+
class SignedRequestsWrapper(base): # type: ignore[valid-type,misc]
|
|
147
|
+
agent: Any = None
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def requests(self) -> Requests:
|
|
151
|
+
return SignedRequests(headers=self.headers, aiosession=self.aiosession,
|
|
152
|
+
auth=self.auth, verify=self.verify, agent=self.agent)
|
|
153
|
+
|
|
154
|
+
return SignedRequestsWrapper(auth=agent.auth(), agent=agent)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["OriginNotAllowed", "aidr_http_tools", "signed_requests_wrapper"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The two conversations an agent has with the registry: enrolling once, and asking for credentials.
|
|
2
|
+
|
|
3
|
+
Enrolling is authenticated by the enrollment token; asking for a credential by an RFC 9421
|
|
4
|
+
signature with the agent's own key. The token is never needed again after enrollment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from aidr_crypto.keys import b64url_encode, generate_keypair, public_key_to_jwk, sign_raw
|
|
17
|
+
from aidr_crypto.signatures import sign_request
|
|
18
|
+
|
|
19
|
+
from .identity import AgentIdentity
|
|
20
|
+
|
|
21
|
+
POP_CONTEXT = "aiaf-pop-v1"
|
|
22
|
+
TOKEN_PREFIX = "aidr_et_" # noqa: S105 - a format prefix, not a secret
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RegistryError(Exception):
|
|
26
|
+
"""The registry refused, with its own error code (``aidr.…``) where it gave one."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
29
|
+
super().__init__(f"{message} ({code})")
|
|
30
|
+
self.status = status
|
|
31
|
+
self.code = code
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _refusal(response: httpx.Response) -> RegistryError:
|
|
35
|
+
try:
|
|
36
|
+
error = response.json().get("error") or {}
|
|
37
|
+
except ValueError:
|
|
38
|
+
error = {}
|
|
39
|
+
return RegistryError(response.status_code, error.get("code", "aidr.unknown"),
|
|
40
|
+
error.get("message", f"the registry answered HTTP {response.status_code}"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def token_id(token: str) -> str:
|
|
44
|
+
"""The non-secret part of a token, for messages and logs: ``etk_…``."""
|
|
45
|
+
parts = token[len(TOKEN_PREFIX):].split("_", 2) if token.startswith(TOKEN_PREFIX) else []
|
|
46
|
+
return f"{parts[0]}_{parts[1]}" if len(parts) == 3 else "(not an enrollment token)"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def enroll(token: str, *, name: str, registry: str, audiences: list[str] | None = None,
|
|
50
|
+
description: str = "", http: httpx.Client | None = None,
|
|
51
|
+
private_key: Any = None) -> AgentIdentity:
|
|
52
|
+
"""Generate a key here, register the agent with the token, and prove the key. Two requests."""
|
|
53
|
+
if not token.startswith(TOKEN_PREFIX):
|
|
54
|
+
raise ValueError("that is not an enrollment token (they start with aidr_et_)")
|
|
55
|
+
if private_key is None:
|
|
56
|
+
private_key, _ = generate_keypair()
|
|
57
|
+
base = registry.rstrip("/")
|
|
58
|
+
headers = {"Authorization": f"AIDR-Enrollment {token}", "AIDR-PoP-Context": POP_CONTEXT}
|
|
59
|
+
body: dict[str, Any] = {"name": name, "description": description,
|
|
60
|
+
"public_key_jwk": public_key_to_jwk(private_key.public_key())}
|
|
61
|
+
if audiences is not None:
|
|
62
|
+
body["audiences"] = audiences
|
|
63
|
+
client = http or httpx.Client(timeout=15)
|
|
64
|
+
try:
|
|
65
|
+
started = client.post(f"{base}/v1/enroll", json=body, headers=headers)
|
|
66
|
+
if started.status_code != 201:
|
|
67
|
+
raise _refusal(started)
|
|
68
|
+
registration = started.json()
|
|
69
|
+
challenge = registration["challenge"]
|
|
70
|
+
if not challenge["sign_this"].startswith(POP_CONTEXT + ":"):
|
|
71
|
+
raise RegistryError(0, "aidr.client.bad_challenge",
|
|
72
|
+
"the registry asked for a signature this SDK does not make")
|
|
73
|
+
signature = b64url_encode(sign_raw(private_key, challenge["sign_this"].encode()))
|
|
74
|
+
aid = registration["agent"]["aid"]
|
|
75
|
+
proven = client.post(f"{base}/v1/enroll/{aid}/proof", headers=headers,
|
|
76
|
+
json={"challenge_id": challenge["challenge_id"], "signature": signature})
|
|
77
|
+
if proven.status_code != 200:
|
|
78
|
+
raise _refusal(proven)
|
|
79
|
+
finally:
|
|
80
|
+
if http is None:
|
|
81
|
+
client.close()
|
|
82
|
+
return AgentIdentity(aid=aid, name=name, registry=base, private_key=private_key,
|
|
83
|
+
audiences=list(registration.get("allowed_audiences") or []))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Credential:
|
|
88
|
+
token: str
|
|
89
|
+
renew_at: float # monotonic clock
|
|
90
|
+
expires_at: float # monotonic clock
|
|
91
|
+
|
|
92
|
+
def fresh(self, now: float) -> bool:
|
|
93
|
+
return now < self.renew_at
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _credential_request(identity: AgentIdentity, audience: str | None) -> tuple[str, dict, dict]:
|
|
97
|
+
url = f"{identity.registry}/v1/credentials"
|
|
98
|
+
headers = sign_request(method="POST", url=url, private_key=identity.private_key, keyid=identity.aid)
|
|
99
|
+
body = {"aid": identity.aid, **({"audience": audience} if audience else {})}
|
|
100
|
+
return url, headers, body
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _credential_from(response: httpx.Response, now: float) -> Credential:
|
|
104
|
+
if response.status_code != 200:
|
|
105
|
+
raise _refusal(response)
|
|
106
|
+
body = response.json()
|
|
107
|
+
return Credential(token=body["credential"], renew_at=now + body["renew_after"],
|
|
108
|
+
expires_at=now + body["expires_in"])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CredentialCache:
|
|
112
|
+
"""Short-lived credentials (15 minutes or less), one per audience, renewed early.
|
|
113
|
+
|
|
114
|
+
Agents with allowed audiences get a credential bound to each site, because the registry
|
|
115
|
+
issues only those. An unrestricted agent gets one unbound credential (key ``None``): one
|
|
116
|
+
issuance instead of one per site (credentials issued are what is billed), and accepted by
|
|
117
|
+
relying parties still on verifiers older than the ones that check a bound credential against
|
|
118
|
+
the site being visited.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, identity: AgentIdentity, *, http: httpx.Client | None = None,
|
|
122
|
+
async_http: httpx.AsyncClient | None = None) -> None:
|
|
123
|
+
self.identity = identity
|
|
124
|
+
self._http = http
|
|
125
|
+
self._async_http = async_http
|
|
126
|
+
self._cache: dict[str | None, Credential] = {}
|
|
127
|
+
self._lock = threading.Lock()
|
|
128
|
+
|
|
129
|
+
def key_for(self, origin: str) -> str | None:
|
|
130
|
+
return origin if self.identity.audiences else None
|
|
131
|
+
|
|
132
|
+
def get(self, origin: str, http: httpx.Client | None = None) -> str:
|
|
133
|
+
key = self.key_for(origin)
|
|
134
|
+
with self._lock:
|
|
135
|
+
cached = self._cache.get(key)
|
|
136
|
+
if cached and cached.fresh(time.monotonic()):
|
|
137
|
+
return cached.token
|
|
138
|
+
url, headers, body = _credential_request(self.identity, key)
|
|
139
|
+
given = http or self._http
|
|
140
|
+
client = given or httpx.Client(timeout=15)
|
|
141
|
+
try:
|
|
142
|
+
credential = _credential_from(client.post(url, json=body, headers=headers),
|
|
143
|
+
time.monotonic())
|
|
144
|
+
finally:
|
|
145
|
+
if given is None:
|
|
146
|
+
client.close()
|
|
147
|
+
self._cache[key] = credential
|
|
148
|
+
return credential.token
|
|
149
|
+
|
|
150
|
+
async def aget(self, origin: str, http: httpx.AsyncClient | None = None) -> str:
|
|
151
|
+
key = self.key_for(origin)
|
|
152
|
+
cached = self._cache.get(key)
|
|
153
|
+
if cached and cached.fresh(time.monotonic()):
|
|
154
|
+
return cached.token
|
|
155
|
+
url, headers, body = _credential_request(self.identity, key)
|
|
156
|
+
given = http or self._async_http
|
|
157
|
+
if given is None:
|
|
158
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
159
|
+
response = await client.post(url, json=body, headers=headers)
|
|
160
|
+
else:
|
|
161
|
+
response = await given.post(url, json=body, headers=headers)
|
|
162
|
+
credential = _credential_from(response, time.monotonic())
|
|
163
|
+
self._cache[key] = credential
|
|
164
|
+
return credential.token
|
|
165
|
+
|
|
166
|
+
def forget(self, origin: str | None = None) -> None:
|
|
167
|
+
"""Drop cached credentials (after a relying party refuses one: it may be revoked)."""
|
|
168
|
+
with self._lock:
|
|
169
|
+
if origin is None:
|
|
170
|
+
self._cache.clear()
|
|
171
|
+
else:
|
|
172
|
+
self._cache.pop(self.key_for(origin), None)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aidr-agent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Give an AI agent a verifiable AIDR identity: enroll from code, sign requests, LangChain tools."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Evoliv Core" }]
|
|
14
|
+
keywords = ["ai-agents", "identity", "langchain", "rfc9421", "aiaf", "aidr"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["aidr-crypto>=0.1,<0.2", "httpx>=0.27"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
requests = ["requests>=2.31"]
|
|
25
|
+
langchain = ["langchain-core>=1.0"]
|
|
26
|
+
# For code still on langchain-community's RequestsToolkit (the package is being sunset).
|
|
27
|
+
langchain-community = ["langchain-community>=0.4", "requests>=2.31"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
aidr-agent = "aidr_agent.__main__:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://aidr.evolivcore.com"
|
|
34
|
+
Documentation = "https://evolivcore.com/resources"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["aidr_agent"]
|