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.
@@ -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
@@ -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"]