a2a-firewall-sdk 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a2a_firewall_sdk-0.2.0/LICENSE +21 -0
- a2a_firewall_sdk-0.2.0/PKG-INFO +194 -0
- a2a_firewall_sdk-0.2.0/README.md +158 -0
- a2a_firewall_sdk-0.2.0/a2a_firewall/__init__.py +10 -0
- a2a_firewall_sdk-0.2.0/a2a_firewall/client.py +397 -0
- a2a_firewall_sdk-0.2.0/a2a_firewall_sdk.egg-info/PKG-INFO +194 -0
- a2a_firewall_sdk-0.2.0/a2a_firewall_sdk.egg-info/SOURCES.txt +11 -0
- a2a_firewall_sdk-0.2.0/a2a_firewall_sdk.egg-info/dependency_links.txt +1 -0
- a2a_firewall_sdk-0.2.0/a2a_firewall_sdk.egg-info/requires.txt +11 -0
- a2a_firewall_sdk-0.2.0/a2a_firewall_sdk.egg-info/top_level.txt +1 -0
- a2a_firewall_sdk-0.2.0/pyproject.toml +44 -0
- a2a_firewall_sdk-0.2.0/setup.cfg +4 -0
- a2a_firewall_sdk-0.2.0/tests/test_client.py +153 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manan Jayeshkumar Panchal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: a2a-firewall-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for the A2A Firewall — inter-agent governance mesh with Ed25519 identity, Macaroon delegation, and cryptographic lineage
|
|
5
|
+
Author-email: Manan JP <mananjp@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mananjp/a2a-firewall
|
|
8
|
+
Project-URL: Documentation, https://github.com/mananjp/a2a-firewall#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/mananjp/a2a-firewall
|
|
10
|
+
Project-URL: Issues, https://github.com/mananjp/a2a-firewall/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/mananjp/a2a-firewall/releases
|
|
12
|
+
Keywords: a2a,firewall,agent,multi-agent,security,governance,ed25519,delegation
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: httpx>=0.27.0
|
|
28
|
+
Provides-Extra: crypto
|
|
29
|
+
Requires-Dist: cryptography>=42.0.0; extra == "crypto"
|
|
30
|
+
Provides-Extra: otel
|
|
31
|
+
Requires-Dist: opentelemetry-api>=1.27.0; extra == "otel"
|
|
32
|
+
Provides-Extra: all
|
|
33
|
+
Requires-Dist: cryptography>=42.0.0; extra == "all"
|
|
34
|
+
Requires-Dist: opentelemetry-api>=1.27.0; extra == "all"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# a2a-firewall-sdk
|
|
38
|
+
|
|
39
|
+
**Python SDK for the [A2A Firewall](https://github.com/mananjp/a2a-firewall)** — an inter-agent governance mesh that inspects, signs, and attenuates every message between AI agents.
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/a2a-firewall-sdk/)
|
|
42
|
+
[](https://opensource.org/licenses/MIT)
|
|
43
|
+
[](https://www.python.org/downloads/)
|
|
44
|
+
|
|
45
|
+
## What It Does
|
|
46
|
+
|
|
47
|
+
The A2A Firewall sits between agents in any multi-agent system and enforces security policies on every inter-agent message:
|
|
48
|
+
|
|
49
|
+
- **6-layer detection pipeline** — schema validation, permission checks, rule engine, CVE risk scoring, LLM semantic analysis, and policy decisions
|
|
50
|
+
- **Ed25519 message signing** — every message is cryptographically signed, creating a tamper-evident hash chain
|
|
51
|
+
- **Macaroon-style delegation** — capabilities attenuate (narrow) at each delegation hop, never widen
|
|
52
|
+
- **< 20ms p99 deterministic latency** — crypto and rule layers run without LLM calls
|
|
53
|
+
|
|
54
|
+
This SDK handles all of that automatically: signing, chain hashing, delegation token management, and OpenTelemetry tracing.
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install a2a-firewall-sdk
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
With Ed25519 signing support:
|
|
63
|
+
```bash
|
|
64
|
+
pip install "a2a-firewall-sdk[crypto]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With OpenTelemetry tracing:
|
|
68
|
+
```bash
|
|
69
|
+
pip install "a2a-firewall-sdk[all]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from a2a_firewall import A2AFirewall, FirewallConfig
|
|
76
|
+
|
|
77
|
+
# Configure the SDK
|
|
78
|
+
firewall = A2AFirewall(FirewallConfig(
|
|
79
|
+
firewall_url="https://a2a-firewall-backend.onrender.com",
|
|
80
|
+
agent_api_key="your_workspace_api_key",
|
|
81
|
+
agent_id="your-agent-uuid",
|
|
82
|
+
workspace_id="your-workspace-uuid",
|
|
83
|
+
agent_private_key="ed25519-private-key-hex", # optional: enables message signing
|
|
84
|
+
fail_mode="closed", # "closed" = block on error, "open" = allow on error
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
# Send a message through the firewall
|
|
88
|
+
response = firewall.send(
|
|
89
|
+
receiver_agent_id="target-agent-uuid",
|
|
90
|
+
task_type="research",
|
|
91
|
+
payload={"query": "What are the latest fraud trends?"},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
print(f"Decision: {response.decision}") # "allow" | "block" | "review"
|
|
95
|
+
print(f"Risk score: {response.risk_score}")
|
|
96
|
+
print(f"Task ID: {response.task_id}")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Delegation Tokens
|
|
100
|
+
|
|
101
|
+
Create attenuable delegation tokens when forwarding tasks between agents:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
# Agent A delegates to Agent B with narrowed permissions
|
|
105
|
+
delegation_token = firewall.create_delegation_token(
|
|
106
|
+
root_key_hex="workspace-root-key-hex",
|
|
107
|
+
receiver_agent_id="agent-b-uuid",
|
|
108
|
+
task_type="research", # restrict to research tasks only
|
|
109
|
+
max_risk=0.5, # cap risk threshold
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# The token carries caveats that can only narrow, never widen
|
|
113
|
+
response = firewall.send(
|
|
114
|
+
receiver_agent_id="agent-b-uuid",
|
|
115
|
+
task_type="research",
|
|
116
|
+
payload={"query": "Summarize findings"},
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Verify Incoming Messages
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# Verify a message received from another agent
|
|
124
|
+
result = firewall.verify_message(
|
|
125
|
+
sender_public_key="sender-ed25519-public-key-hex",
|
|
126
|
+
message_hash="sha256-message-hash",
|
|
127
|
+
signature="ed25519-signature-hex",
|
|
128
|
+
expected_parent_chain_hash="previous-chain-hash", # optional
|
|
129
|
+
)
|
|
130
|
+
assert result["signature_valid"]
|
|
131
|
+
assert result["chain_valid"]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Fail Modes
|
|
135
|
+
|
|
136
|
+
| Mode | Behavior |
|
|
137
|
+
|------|----------|
|
|
138
|
+
| `closed` (default) | Raises `FirewallBlockedError` if the firewall is unreachable |
|
|
139
|
+
| `open` | Allows the message through if the firewall is unreachable |
|
|
140
|
+
|
|
141
|
+
## OpenTelemetry
|
|
142
|
+
|
|
143
|
+
When `opentelemetry-api` is installed, the SDK automatically creates spans for every `firewall.inspect` call with `task_type`, `decision`, and `risk_score` attributes. No configuration needed.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install "a2a-firewall-sdk[otel]"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## API Reference
|
|
150
|
+
|
|
151
|
+
### `FirewallConfig`
|
|
152
|
+
|
|
153
|
+
| Parameter | Type | Default | Description |
|
|
154
|
+
|-----------|------|---------|-------------|
|
|
155
|
+
| `firewall_url` | `str` | required | Base URL of the A2A Firewall backend |
|
|
156
|
+
| `agent_api_key` | `str` | required | Workspace API key for authentication |
|
|
157
|
+
| `workspace_id` | `str` | `""` | Workspace identifier |
|
|
158
|
+
| `agent_id` | `str` | `""` | This agent's identifier |
|
|
159
|
+
| `agent_private_key` | `str` | `""` | Ed25519 private key (hex) for message signing |
|
|
160
|
+
| `timeout_seconds` | `float` | `5.0` | HTTP request timeout |
|
|
161
|
+
| `fail_mode` | `str` | `"closed"` | `"closed"` or `"open"` |
|
|
162
|
+
|
|
163
|
+
### `FirewallResponse`
|
|
164
|
+
|
|
165
|
+
| Field | Type | Description |
|
|
166
|
+
|-------|------|-------------|
|
|
167
|
+
| `task_id` | `str` | Unique task identifier |
|
|
168
|
+
| `decision` | `str` | `"allow"`, `"block"`, or `"review"` |
|
|
169
|
+
| `allowed` | `bool` | Whether the message is allowed to proceed |
|
|
170
|
+
| `risk_score` | `float` | Risk score (0.0 to 1.0) |
|
|
171
|
+
| `violations` | `list[dict]` | List of detected violations |
|
|
172
|
+
| `latency_ms` | `int` | Inspection latency in milliseconds |
|
|
173
|
+
|
|
174
|
+
### `FirewallBlockedError`
|
|
175
|
+
|
|
176
|
+
Raised when `raise_on_block=True` (default) and the message is blocked.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
try:
|
|
180
|
+
firewall.send(...)
|
|
181
|
+
except FirewallBlockedError as e:
|
|
182
|
+
print(f"Blocked: {e.reason}, risk: {e.risk_score}")
|
|
183
|
+
print(f"Violations: {e.violations}")
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Links
|
|
187
|
+
|
|
188
|
+
- **GitHub**: [github.com/mananjp/a2a-firewall](https://github.com/mananjp/a2a-firewall)
|
|
189
|
+
- **Live Demo**: [a2a-firewall.onrender.com](https://a2a-firewall.onrender.com)
|
|
190
|
+
- **TypeScript SDK**: [@a2a-firewall/sdk on npm](https://www.npmjs.com/package/@a2a-firewall/sdk)
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# a2a-firewall-sdk
|
|
2
|
+
|
|
3
|
+
**Python SDK for the [A2A Firewall](https://github.com/mananjp/a2a-firewall)** — an inter-agent governance mesh that inspects, signs, and attenuates every message between AI agents.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/a2a-firewall-sdk/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.python.org/downloads/)
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
The A2A Firewall sits between agents in any multi-agent system and enforces security policies on every inter-agent message:
|
|
12
|
+
|
|
13
|
+
- **6-layer detection pipeline** — schema validation, permission checks, rule engine, CVE risk scoring, LLM semantic analysis, and policy decisions
|
|
14
|
+
- **Ed25519 message signing** — every message is cryptographically signed, creating a tamper-evident hash chain
|
|
15
|
+
- **Macaroon-style delegation** — capabilities attenuate (narrow) at each delegation hop, never widen
|
|
16
|
+
- **< 20ms p99 deterministic latency** — crypto and rule layers run without LLM calls
|
|
17
|
+
|
|
18
|
+
This SDK handles all of that automatically: signing, chain hashing, delegation token management, and OpenTelemetry tracing.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install a2a-firewall-sdk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
With Ed25519 signing support:
|
|
27
|
+
```bash
|
|
28
|
+
pip install "a2a-firewall-sdk[crypto]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
With OpenTelemetry tracing:
|
|
32
|
+
```bash
|
|
33
|
+
pip install "a2a-firewall-sdk[all]"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from a2a_firewall import A2AFirewall, FirewallConfig
|
|
40
|
+
|
|
41
|
+
# Configure the SDK
|
|
42
|
+
firewall = A2AFirewall(FirewallConfig(
|
|
43
|
+
firewall_url="https://a2a-firewall-backend.onrender.com",
|
|
44
|
+
agent_api_key="your_workspace_api_key",
|
|
45
|
+
agent_id="your-agent-uuid",
|
|
46
|
+
workspace_id="your-workspace-uuid",
|
|
47
|
+
agent_private_key="ed25519-private-key-hex", # optional: enables message signing
|
|
48
|
+
fail_mode="closed", # "closed" = block on error, "open" = allow on error
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
# Send a message through the firewall
|
|
52
|
+
response = firewall.send(
|
|
53
|
+
receiver_agent_id="target-agent-uuid",
|
|
54
|
+
task_type="research",
|
|
55
|
+
payload={"query": "What are the latest fraud trends?"},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
print(f"Decision: {response.decision}") # "allow" | "block" | "review"
|
|
59
|
+
print(f"Risk score: {response.risk_score}")
|
|
60
|
+
print(f"Task ID: {response.task_id}")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Delegation Tokens
|
|
64
|
+
|
|
65
|
+
Create attenuable delegation tokens when forwarding tasks between agents:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
# Agent A delegates to Agent B with narrowed permissions
|
|
69
|
+
delegation_token = firewall.create_delegation_token(
|
|
70
|
+
root_key_hex="workspace-root-key-hex",
|
|
71
|
+
receiver_agent_id="agent-b-uuid",
|
|
72
|
+
task_type="research", # restrict to research tasks only
|
|
73
|
+
max_risk=0.5, # cap risk threshold
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# The token carries caveats that can only narrow, never widen
|
|
77
|
+
response = firewall.send(
|
|
78
|
+
receiver_agent_id="agent-b-uuid",
|
|
79
|
+
task_type="research",
|
|
80
|
+
payload={"query": "Summarize findings"},
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Verify Incoming Messages
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# Verify a message received from another agent
|
|
88
|
+
result = firewall.verify_message(
|
|
89
|
+
sender_public_key="sender-ed25519-public-key-hex",
|
|
90
|
+
message_hash="sha256-message-hash",
|
|
91
|
+
signature="ed25519-signature-hex",
|
|
92
|
+
expected_parent_chain_hash="previous-chain-hash", # optional
|
|
93
|
+
)
|
|
94
|
+
assert result["signature_valid"]
|
|
95
|
+
assert result["chain_valid"]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Fail Modes
|
|
99
|
+
|
|
100
|
+
| Mode | Behavior |
|
|
101
|
+
|------|----------|
|
|
102
|
+
| `closed` (default) | Raises `FirewallBlockedError` if the firewall is unreachable |
|
|
103
|
+
| `open` | Allows the message through if the firewall is unreachable |
|
|
104
|
+
|
|
105
|
+
## OpenTelemetry
|
|
106
|
+
|
|
107
|
+
When `opentelemetry-api` is installed, the SDK automatically creates spans for every `firewall.inspect` call with `task_type`, `decision`, and `risk_score` attributes. No configuration needed.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pip install "a2a-firewall-sdk[otel]"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## API Reference
|
|
114
|
+
|
|
115
|
+
### `FirewallConfig`
|
|
116
|
+
|
|
117
|
+
| Parameter | Type | Default | Description |
|
|
118
|
+
|-----------|------|---------|-------------|
|
|
119
|
+
| `firewall_url` | `str` | required | Base URL of the A2A Firewall backend |
|
|
120
|
+
| `agent_api_key` | `str` | required | Workspace API key for authentication |
|
|
121
|
+
| `workspace_id` | `str` | `""` | Workspace identifier |
|
|
122
|
+
| `agent_id` | `str` | `""` | This agent's identifier |
|
|
123
|
+
| `agent_private_key` | `str` | `""` | Ed25519 private key (hex) for message signing |
|
|
124
|
+
| `timeout_seconds` | `float` | `5.0` | HTTP request timeout |
|
|
125
|
+
| `fail_mode` | `str` | `"closed"` | `"closed"` or `"open"` |
|
|
126
|
+
|
|
127
|
+
### `FirewallResponse`
|
|
128
|
+
|
|
129
|
+
| Field | Type | Description |
|
|
130
|
+
|-------|------|-------------|
|
|
131
|
+
| `task_id` | `str` | Unique task identifier |
|
|
132
|
+
| `decision` | `str` | `"allow"`, `"block"`, or `"review"` |
|
|
133
|
+
| `allowed` | `bool` | Whether the message is allowed to proceed |
|
|
134
|
+
| `risk_score` | `float` | Risk score (0.0 to 1.0) |
|
|
135
|
+
| `violations` | `list[dict]` | List of detected violations |
|
|
136
|
+
| `latency_ms` | `int` | Inspection latency in milliseconds |
|
|
137
|
+
|
|
138
|
+
### `FirewallBlockedError`
|
|
139
|
+
|
|
140
|
+
Raised when `raise_on_block=True` (default) and the message is blocked.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
try:
|
|
144
|
+
firewall.send(...)
|
|
145
|
+
except FirewallBlockedError as e:
|
|
146
|
+
print(f"Blocked: {e.reason}, risk: {e.risk_score}")
|
|
147
|
+
print(f"Violations: {e.violations}")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Links
|
|
151
|
+
|
|
152
|
+
- **GitHub**: [github.com/mananjp/a2a-firewall](https://github.com/mananjp/a2a-firewall)
|
|
153
|
+
- **Live Demo**: [a2a-firewall.onrender.com](https://a2a-firewall.onrender.com)
|
|
154
|
+
- **TypeScript SDK**: [@a2a-firewall/sdk on npm](https://www.npmjs.com/package/@a2a-firewall/sdk)
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""A2A Firewall Python SDK — full identity, delegation, and signing integration."""
|
|
2
|
+
from a2a_firewall.client import (
|
|
3
|
+
A2AFirewall,
|
|
4
|
+
FirewallBlockedError,
|
|
5
|
+
FirewallConfig,
|
|
6
|
+
FirewallResponse,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = ["A2AFirewall", "FirewallBlockedError", "FirewallConfig", "FirewallResponse"]
|
|
10
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""A2A Firewall Python SDK — full identity, delegation, and signing integration.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from a2a_firewall import A2AFirewall, FirewallConfig
|
|
5
|
+
|
|
6
|
+
config = FirewallConfig(
|
|
7
|
+
firewall_url="http://localhost:8000",
|
|
8
|
+
workspace_id="ws-uuid",
|
|
9
|
+
agent_id="agent-uuid",
|
|
10
|
+
agent_api_key="agt_xxx",
|
|
11
|
+
agent_private_key="ed25519-hex", # for signing messages
|
|
12
|
+
workspace_root_pubkey="ed25519-hex", # for verifying cards
|
|
13
|
+
fail_mode="closed",
|
|
14
|
+
)
|
|
15
|
+
firewall = A2AFirewall(config)
|
|
16
|
+
|
|
17
|
+
response = firewall.send(
|
|
18
|
+
receiver_agent_id="target-uuid",
|
|
19
|
+
task_type="research",
|
|
20
|
+
payload={"query": "What is fraud?"},
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import hmac
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any, Optional
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from opentelemetry import trace
|
|
38
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
39
|
+
|
|
40
|
+
_OTEL_AVAILABLE = True
|
|
41
|
+
except ImportError:
|
|
42
|
+
_OTEL_AVAILABLE = False
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Config
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class FirewallConfig:
|
|
50
|
+
firewall_url: str
|
|
51
|
+
agent_api_key: str
|
|
52
|
+
workspace_id: str = ""
|
|
53
|
+
agent_id: str = ""
|
|
54
|
+
agent_private_key: str = ""
|
|
55
|
+
workspace_root_pubkey: str = ""
|
|
56
|
+
timeout_seconds: float = 5.0
|
|
57
|
+
fail_mode: str = "closed"
|
|
58
|
+
review_poll_interval: float = 2.0
|
|
59
|
+
review_max_wait: float = 60.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Response & Error types
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class FirewallResponse:
|
|
68
|
+
task_id: str
|
|
69
|
+
decision: str # "allow" | "block" | "review"
|
|
70
|
+
allowed: bool
|
|
71
|
+
risk_score: float
|
|
72
|
+
violations: list[dict[str, Any]]
|
|
73
|
+
review_token: Optional[str] = None
|
|
74
|
+
block_reason: Optional[str] = None
|
|
75
|
+
latency_ms: int = 0
|
|
76
|
+
trace_id: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FirewallBlockedError(Exception):
|
|
80
|
+
def __init__(self, task_id: str, reason: str, risk_score: float, violations: list[dict[str, Any]]):
|
|
81
|
+
self.task_id = task_id
|
|
82
|
+
self.reason = reason
|
|
83
|
+
self.risk_score = risk_score
|
|
84
|
+
self.violations = violations
|
|
85
|
+
super().__init__(f"Task {task_id} blocked: {reason}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Crypto helpers (self-contained, no backend dependency)
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _sha256_hex(data: bytes) -> str:
|
|
93
|
+
return hashlib.sha256(data).hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _compute_message_hash(payload: dict[str, Any], sender_id: str, receiver_id: str, timestamp: float) -> str:
|
|
97
|
+
canonical = json.dumps(
|
|
98
|
+
{"payload": payload, "sender": sender_id, "receiver": receiver_id, "ts": timestamp},
|
|
99
|
+
sort_keys=True, separators=(",", ":"),
|
|
100
|
+
).encode()
|
|
101
|
+
return _sha256_hex(canonical)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _compute_chain_hash(parent_chain_hash: str | None, message_hash: str) -> str:
|
|
105
|
+
parent = parent_chain_hash or _sha256_hex(b"\x00" * 32)
|
|
106
|
+
return _sha256_hex(bytes.fromhex(parent) + bytes.fromhex(message_hash))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _ed25519_sign(private_key_hex: str, message: bytes) -> str:
|
|
110
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
111
|
+
raw = bytes.fromhex(private_key_hex)
|
|
112
|
+
key = Ed25519PrivateKey.from_private_bytes(raw)
|
|
113
|
+
return key.sign(message).hex()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ed25519_verify(public_key_hex: str, signature_hex: str, message: bytes) -> bool:
|
|
117
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
118
|
+
try:
|
|
119
|
+
key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
|
|
120
|
+
key.verify(bytes.fromhex(signature_hex), message)
|
|
121
|
+
return True
|
|
122
|
+
except Exception:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Delegation token (compact serialization)
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def _hmac_sha256(key: bytes, data: bytes) -> bytes:
|
|
131
|
+
return hmac.new(key, data, hashlib.sha256).digest()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _mint_delegation_token(root_key_hex: str, location: str, agent_id: str, caveats: list[str]) -> dict[str, Any]:
|
|
135
|
+
root_key = bytes.fromhex(root_key_hex) if len(root_key_hex) == 64 else root_key_hex.encode()[:32]
|
|
136
|
+
msg = f"{location}\n{agent_id}\n".encode() + "\n".join(caveats).encode()
|
|
137
|
+
sig = _hmac_sha256(root_key, msg).hex()
|
|
138
|
+
return {"location": location, "identifier": agent_id, "caveats": caveats, "signature": sig}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _attenuate_token(token: dict[str, Any], root_key_hex: str, new_caveats: list[str]) -> dict[str, Any]:
|
|
142
|
+
root_key = bytes.fromhex(root_key_hex) if len(root_key_hex) == 64 else root_key_hex.encode()[:32]
|
|
143
|
+
all_caveats = token["caveats"] + new_caveats
|
|
144
|
+
msg = f"{token['location']}\n{token['identifier']}\n".encode() + "\n".join(all_caveats).encode()
|
|
145
|
+
sig = _hmac_sha256(root_key, msg).hex()
|
|
146
|
+
return {"location": token["location"], "identifier": token["identifier"], "caveats": all_caveats, "signature": sig}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _token_to_compact(token: dict[str, Any]) -> str:
|
|
150
|
+
return json.dumps(token, separators=(",", ":"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Main SDK class
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
class A2AFirewall:
|
|
158
|
+
def __init__(self, config: FirewallConfig):
|
|
159
|
+
self.config = config
|
|
160
|
+
self._ctx: dict[str, Any] = {}
|
|
161
|
+
self._http = httpx.Client(
|
|
162
|
+
base_url=config.firewall_url,
|
|
163
|
+
headers={"Authorization": f"Bearer {config.agent_api_key}"},
|
|
164
|
+
timeout=config.timeout_seconds,
|
|
165
|
+
)
|
|
166
|
+
self._chain_hash: str | None = None
|
|
167
|
+
self._delegation_token: dict[str, Any] | None = None
|
|
168
|
+
self._delegation_chain: list[str] = []
|
|
169
|
+
|
|
170
|
+
# -- Context management --
|
|
171
|
+
|
|
172
|
+
def set_context(
|
|
173
|
+
self,
|
|
174
|
+
task_id: str,
|
|
175
|
+
root_task_id: str,
|
|
176
|
+
trace_id: str | None = None,
|
|
177
|
+
span_id: str | None = None,
|
|
178
|
+
chain_hash: str | None = None,
|
|
179
|
+
delegation_token: str | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Set lineage context for the next send (called when processing a received task)."""
|
|
182
|
+
self._ctx = {
|
|
183
|
+
"current_task_id": task_id,
|
|
184
|
+
"root_task_id": root_task_id,
|
|
185
|
+
"trace_id": trace_id,
|
|
186
|
+
"span_id": span_id,
|
|
187
|
+
}
|
|
188
|
+
if chain_hash:
|
|
189
|
+
self._chain_hash = chain_hash
|
|
190
|
+
if delegation_token:
|
|
191
|
+
self._delegation_token = json.loads(delegation_token)
|
|
192
|
+
|
|
193
|
+
# -- Signing --
|
|
194
|
+
|
|
195
|
+
def _sign_payload(self, payload: dict[str, Any], receiver_id: str) -> tuple[str, str, str, float]:
|
|
196
|
+
"""Sign a payload and compute chain hash.
|
|
197
|
+
|
|
198
|
+
Returns (message_hash, chain_hash, signature, timestamp).
|
|
199
|
+
"""
|
|
200
|
+
now = time.time()
|
|
201
|
+
msg_hash = _compute_message_hash(payload, self.config.agent_id, receiver_id, now)
|
|
202
|
+
chain_hash = _compute_chain_hash(self._chain_hash, msg_hash)
|
|
203
|
+
|
|
204
|
+
signature = ""
|
|
205
|
+
if self.config.agent_private_key:
|
|
206
|
+
signature = _ed25519_sign(self.config.agent_private_key, bytes.fromhex(msg_hash))
|
|
207
|
+
|
|
208
|
+
self._chain_hash = chain_hash
|
|
209
|
+
return msg_hash, chain_hash, signature, now
|
|
210
|
+
|
|
211
|
+
# -- Delegation --
|
|
212
|
+
|
|
213
|
+
def create_delegation_token(
|
|
214
|
+
self,
|
|
215
|
+
root_key_hex: str,
|
|
216
|
+
receiver_agent_id: str,
|
|
217
|
+
task_type: str | None = None,
|
|
218
|
+
max_risk: float | None = None,
|
|
219
|
+
) -> str:
|
|
220
|
+
"""Create a delegation token for forwarding to another agent."""
|
|
221
|
+
caveats = [f"receiver={receiver_agent_id}"]
|
|
222
|
+
if task_type:
|
|
223
|
+
caveats.append(f"task_type={task_type}")
|
|
224
|
+
if max_risk is not None:
|
|
225
|
+
caveats.append(f"max_risk={max_risk}")
|
|
226
|
+
|
|
227
|
+
if self._delegation_token:
|
|
228
|
+
token = _attenuate_token(self._delegation_token, root_key_hex, caveats)
|
|
229
|
+
else:
|
|
230
|
+
token = _mint_delegation_token(root_key_hex, self.config.workspace_id, self.config.agent_id, caveats)
|
|
231
|
+
|
|
232
|
+
self._delegation_token = token
|
|
233
|
+
self._delegation_chain.append(receiver_agent_id)
|
|
234
|
+
return _token_to_compact(token)
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# OTel helpers
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
# -- Core send --
|
|
241
|
+
|
|
242
|
+
def send(
|
|
243
|
+
self,
|
|
244
|
+
receiver_agent_id: str,
|
|
245
|
+
task_type: str,
|
|
246
|
+
payload: dict[str, Any],
|
|
247
|
+
resource_type: str | None = None,
|
|
248
|
+
resource_id: str | None = None,
|
|
249
|
+
action: str | None = None,
|
|
250
|
+
parent_task_id: str | None = None,
|
|
251
|
+
root_task_id: str | None = None,
|
|
252
|
+
declared_intent: str | None = None,
|
|
253
|
+
raise_on_block: bool = True,
|
|
254
|
+
schema_version: str = "v1",
|
|
255
|
+
depth: int = 0,
|
|
256
|
+
) -> FirewallResponse:
|
|
257
|
+
"""Send a message through the firewall with automatic signing and delegation."""
|
|
258
|
+
task_id = str(uuid.uuid4())
|
|
259
|
+
msg_hash, chain_hash, signature, timestamp = self._sign_payload(payload, receiver_agent_id)
|
|
260
|
+
|
|
261
|
+
body: dict[str, Any] = {
|
|
262
|
+
"task_id": task_id,
|
|
263
|
+
"parent_task_id": parent_task_id or self._ctx.get("current_task_id"),
|
|
264
|
+
"root_task_id": root_task_id or self._ctx.get("root_task_id") or task_id,
|
|
265
|
+
"receiver_agent_id": receiver_agent_id,
|
|
266
|
+
"task_type": task_type,
|
|
267
|
+
"schema_version": schema_version,
|
|
268
|
+
"resource_type": resource_type,
|
|
269
|
+
"resource_id": resource_id,
|
|
270
|
+
"action": action,
|
|
271
|
+
"payload": payload,
|
|
272
|
+
"declared_intent": declared_intent,
|
|
273
|
+
"trace_id": self._ctx.get("trace_id"),
|
|
274
|
+
"parent_span_id": self._ctx.get("span_id"),
|
|
275
|
+
"sdk_version": "0.2.0",
|
|
276
|
+
"depth": depth,
|
|
277
|
+
"sender_signature": signature,
|
|
278
|
+
"message_hash": msg_hash,
|
|
279
|
+
"timestamp": timestamp,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# Include delegation token if active
|
|
283
|
+
if self._delegation_token:
|
|
284
|
+
body["delegation_token"] = _token_to_compact(self._delegation_token)
|
|
285
|
+
|
|
286
|
+
# ── OTel (auto when opentelemetry-api installed) ──
|
|
287
|
+
span = None
|
|
288
|
+
if _OTEL_AVAILABLE:
|
|
289
|
+
span = trace.get_tracer("a2a-firewall-sdk", "0.2.0").start_span(
|
|
290
|
+
"firewall.inspect", kind=SpanKind.CLIENT,
|
|
291
|
+
attributes={"task_type": task_type, "receiver_agent_id": receiver_agent_id},
|
|
292
|
+
)
|
|
293
|
+
sc = span.get_span_context()
|
|
294
|
+
if sc.is_valid:
|
|
295
|
+
body["trace_id"] = format(sc.trace_id, "032x")
|
|
296
|
+
body["parent_span_id"] = format(sc.span_id, "016x")
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
resp = self._http.post("/v1/firewall/inspect", json=body)
|
|
300
|
+
resp.raise_for_status()
|
|
301
|
+
data = resp.json()
|
|
302
|
+
fw = FirewallResponse(
|
|
303
|
+
task_id=data["task_id"],
|
|
304
|
+
decision=data["decision"],
|
|
305
|
+
allowed=data["allowed_to_proceed"],
|
|
306
|
+
risk_score=data["risk_score"],
|
|
307
|
+
violations=data.get("violations", []),
|
|
308
|
+
review_token=data.get("review_token"),
|
|
309
|
+
block_reason=data.get("block_reason"),
|
|
310
|
+
latency_ms=data.get("latency_ms", 0),
|
|
311
|
+
trace_id=data.get("trace_id"),
|
|
312
|
+
)
|
|
313
|
+
if span:
|
|
314
|
+
span.set_attribute("decision", fw.decision)
|
|
315
|
+
span.set_attribute("risk_score", fw.risk_score)
|
|
316
|
+
span.set_status(Status(StatusCode.OK))
|
|
317
|
+
except httpx.TimeoutException:
|
|
318
|
+
if span:
|
|
319
|
+
span.set_status(Status(StatusCode.ERROR, "timeout"))
|
|
320
|
+
span.record_exception("firewall_unreachable")
|
|
321
|
+
if self.config.fail_mode == "closed":
|
|
322
|
+
raise FirewallBlockedError(task_id, "firewall_unreachable", 1.0, [])
|
|
323
|
+
return FirewallResponse(
|
|
324
|
+
task_id=task_id, decision="allow", allowed=True,
|
|
325
|
+
risk_score=0.0, violations=[], latency_ms=-1,
|
|
326
|
+
)
|
|
327
|
+
except httpx.HTTPStatusError as e:
|
|
328
|
+
if span:
|
|
329
|
+
span.set_status(Status(StatusCode.ERROR, f"HTTP {e.response.status_code}"))
|
|
330
|
+
span.record_exception(e)
|
|
331
|
+
raise RuntimeError(f"Firewall HTTP error: {e.response.status_code}") from e
|
|
332
|
+
finally:
|
|
333
|
+
if span:
|
|
334
|
+
span.end()
|
|
335
|
+
|
|
336
|
+
if fw.decision == "review":
|
|
337
|
+
fw = self._wait_for_review(fw)
|
|
338
|
+
|
|
339
|
+
if not fw.allowed and raise_on_block:
|
|
340
|
+
raise FirewallBlockedError(fw.task_id, fw.block_reason or "unknown", fw.risk_score, fw.violations)
|
|
341
|
+
|
|
342
|
+
return fw
|
|
343
|
+
|
|
344
|
+
# -- Review polling --
|
|
345
|
+
|
|
346
|
+
def _wait_for_review(self, fw: FirewallResponse) -> FirewallResponse:
|
|
347
|
+
deadline = time.monotonic() + self.config.review_max_wait
|
|
348
|
+
while time.monotonic() < deadline:
|
|
349
|
+
time.sleep(self.config.review_poll_interval)
|
|
350
|
+
try:
|
|
351
|
+
r = self._http.get(f"/v1/review/{fw.review_token}/status")
|
|
352
|
+
s = r.json()
|
|
353
|
+
if s["status"] == "approved":
|
|
354
|
+
fw.decision = "allow"
|
|
355
|
+
fw.allowed = True
|
|
356
|
+
return fw
|
|
357
|
+
if s["status"] == "rejected":
|
|
358
|
+
fw.decision = "block"
|
|
359
|
+
fw.allowed = False
|
|
360
|
+
fw.block_reason = f"Rejected: {s.get('reviewer_notes', '')}"
|
|
361
|
+
return fw
|
|
362
|
+
except Exception:
|
|
363
|
+
pass
|
|
364
|
+
fw.decision = "block"
|
|
365
|
+
fw.allowed = False
|
|
366
|
+
fw.block_reason = "review_timeout"
|
|
367
|
+
return fw
|
|
368
|
+
|
|
369
|
+
# -- Verify incoming --
|
|
370
|
+
|
|
371
|
+
def verify_message(
|
|
372
|
+
self,
|
|
373
|
+
sender_public_key: str,
|
|
374
|
+
message_hash: str,
|
|
375
|
+
signature: str,
|
|
376
|
+
expected_parent_chain_hash: str | None = None,
|
|
377
|
+
) -> dict[str, Any]:
|
|
378
|
+
"""Verify an incoming message's Ed25519 signature and chain hash."""
|
|
379
|
+
sig_valid = _ed25519_verify(sender_public_key, signature, bytes.fromhex(message_hash))
|
|
380
|
+
|
|
381
|
+
chain_valid = True
|
|
382
|
+
if expected_parent_chain_hash:
|
|
383
|
+
expected_chain = _compute_chain_hash(expected_parent_chain_hash, message_hash)
|
|
384
|
+
chain_valid = expected_chain == self._chain_hash
|
|
385
|
+
|
|
386
|
+
return {"signature_valid": sig_valid, "chain_valid": chain_valid}
|
|
387
|
+
|
|
388
|
+
# -- Utility --
|
|
389
|
+
|
|
390
|
+
def get_delegation_chain(self) -> list[str]:
|
|
391
|
+
return list(self._delegation_chain)
|
|
392
|
+
|
|
393
|
+
def get_chain_hash(self) -> str | None:
|
|
394
|
+
return self._chain_hash
|
|
395
|
+
|
|
396
|
+
def close(self) -> None:
|
|
397
|
+
self._http.close()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: a2a-firewall-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for the A2A Firewall — inter-agent governance mesh with Ed25519 identity, Macaroon delegation, and cryptographic lineage
|
|
5
|
+
Author-email: Manan JP <mananjp@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mananjp/a2a-firewall
|
|
8
|
+
Project-URL: Documentation, https://github.com/mananjp/a2a-firewall#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/mananjp/a2a-firewall
|
|
10
|
+
Project-URL: Issues, https://github.com/mananjp/a2a-firewall/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/mananjp/a2a-firewall/releases
|
|
12
|
+
Keywords: a2a,firewall,agent,multi-agent,security,governance,ed25519,delegation
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: httpx>=0.27.0
|
|
28
|
+
Provides-Extra: crypto
|
|
29
|
+
Requires-Dist: cryptography>=42.0.0; extra == "crypto"
|
|
30
|
+
Provides-Extra: otel
|
|
31
|
+
Requires-Dist: opentelemetry-api>=1.27.0; extra == "otel"
|
|
32
|
+
Provides-Extra: all
|
|
33
|
+
Requires-Dist: cryptography>=42.0.0; extra == "all"
|
|
34
|
+
Requires-Dist: opentelemetry-api>=1.27.0; extra == "all"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# a2a-firewall-sdk
|
|
38
|
+
|
|
39
|
+
**Python SDK for the [A2A Firewall](https://github.com/mananjp/a2a-firewall)** — an inter-agent governance mesh that inspects, signs, and attenuates every message between AI agents.
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/a2a-firewall-sdk/)
|
|
42
|
+
[](https://opensource.org/licenses/MIT)
|
|
43
|
+
[](https://www.python.org/downloads/)
|
|
44
|
+
|
|
45
|
+
## What It Does
|
|
46
|
+
|
|
47
|
+
The A2A Firewall sits between agents in any multi-agent system and enforces security policies on every inter-agent message:
|
|
48
|
+
|
|
49
|
+
- **6-layer detection pipeline** — schema validation, permission checks, rule engine, CVE risk scoring, LLM semantic analysis, and policy decisions
|
|
50
|
+
- **Ed25519 message signing** — every message is cryptographically signed, creating a tamper-evident hash chain
|
|
51
|
+
- **Macaroon-style delegation** — capabilities attenuate (narrow) at each delegation hop, never widen
|
|
52
|
+
- **< 20ms p99 deterministic latency** — crypto and rule layers run without LLM calls
|
|
53
|
+
|
|
54
|
+
This SDK handles all of that automatically: signing, chain hashing, delegation token management, and OpenTelemetry tracing.
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install a2a-firewall-sdk
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
With Ed25519 signing support:
|
|
63
|
+
```bash
|
|
64
|
+
pip install "a2a-firewall-sdk[crypto]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With OpenTelemetry tracing:
|
|
68
|
+
```bash
|
|
69
|
+
pip install "a2a-firewall-sdk[all]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from a2a_firewall import A2AFirewall, FirewallConfig
|
|
76
|
+
|
|
77
|
+
# Configure the SDK
|
|
78
|
+
firewall = A2AFirewall(FirewallConfig(
|
|
79
|
+
firewall_url="https://a2a-firewall-backend.onrender.com",
|
|
80
|
+
agent_api_key="your_workspace_api_key",
|
|
81
|
+
agent_id="your-agent-uuid",
|
|
82
|
+
workspace_id="your-workspace-uuid",
|
|
83
|
+
agent_private_key="ed25519-private-key-hex", # optional: enables message signing
|
|
84
|
+
fail_mode="closed", # "closed" = block on error, "open" = allow on error
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
# Send a message through the firewall
|
|
88
|
+
response = firewall.send(
|
|
89
|
+
receiver_agent_id="target-agent-uuid",
|
|
90
|
+
task_type="research",
|
|
91
|
+
payload={"query": "What are the latest fraud trends?"},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
print(f"Decision: {response.decision}") # "allow" | "block" | "review"
|
|
95
|
+
print(f"Risk score: {response.risk_score}")
|
|
96
|
+
print(f"Task ID: {response.task_id}")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Delegation Tokens
|
|
100
|
+
|
|
101
|
+
Create attenuable delegation tokens when forwarding tasks between agents:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
# Agent A delegates to Agent B with narrowed permissions
|
|
105
|
+
delegation_token = firewall.create_delegation_token(
|
|
106
|
+
root_key_hex="workspace-root-key-hex",
|
|
107
|
+
receiver_agent_id="agent-b-uuid",
|
|
108
|
+
task_type="research", # restrict to research tasks only
|
|
109
|
+
max_risk=0.5, # cap risk threshold
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# The token carries caveats that can only narrow, never widen
|
|
113
|
+
response = firewall.send(
|
|
114
|
+
receiver_agent_id="agent-b-uuid",
|
|
115
|
+
task_type="research",
|
|
116
|
+
payload={"query": "Summarize findings"},
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Verify Incoming Messages
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
# Verify a message received from another agent
|
|
124
|
+
result = firewall.verify_message(
|
|
125
|
+
sender_public_key="sender-ed25519-public-key-hex",
|
|
126
|
+
message_hash="sha256-message-hash",
|
|
127
|
+
signature="ed25519-signature-hex",
|
|
128
|
+
expected_parent_chain_hash="previous-chain-hash", # optional
|
|
129
|
+
)
|
|
130
|
+
assert result["signature_valid"]
|
|
131
|
+
assert result["chain_valid"]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Fail Modes
|
|
135
|
+
|
|
136
|
+
| Mode | Behavior |
|
|
137
|
+
|------|----------|
|
|
138
|
+
| `closed` (default) | Raises `FirewallBlockedError` if the firewall is unreachable |
|
|
139
|
+
| `open` | Allows the message through if the firewall is unreachable |
|
|
140
|
+
|
|
141
|
+
## OpenTelemetry
|
|
142
|
+
|
|
143
|
+
When `opentelemetry-api` is installed, the SDK automatically creates spans for every `firewall.inspect` call with `task_type`, `decision`, and `risk_score` attributes. No configuration needed.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install "a2a-firewall-sdk[otel]"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## API Reference
|
|
150
|
+
|
|
151
|
+
### `FirewallConfig`
|
|
152
|
+
|
|
153
|
+
| Parameter | Type | Default | Description |
|
|
154
|
+
|-----------|------|---------|-------------|
|
|
155
|
+
| `firewall_url` | `str` | required | Base URL of the A2A Firewall backend |
|
|
156
|
+
| `agent_api_key` | `str` | required | Workspace API key for authentication |
|
|
157
|
+
| `workspace_id` | `str` | `""` | Workspace identifier |
|
|
158
|
+
| `agent_id` | `str` | `""` | This agent's identifier |
|
|
159
|
+
| `agent_private_key` | `str` | `""` | Ed25519 private key (hex) for message signing |
|
|
160
|
+
| `timeout_seconds` | `float` | `5.0` | HTTP request timeout |
|
|
161
|
+
| `fail_mode` | `str` | `"closed"` | `"closed"` or `"open"` |
|
|
162
|
+
|
|
163
|
+
### `FirewallResponse`
|
|
164
|
+
|
|
165
|
+
| Field | Type | Description |
|
|
166
|
+
|-------|------|-------------|
|
|
167
|
+
| `task_id` | `str` | Unique task identifier |
|
|
168
|
+
| `decision` | `str` | `"allow"`, `"block"`, or `"review"` |
|
|
169
|
+
| `allowed` | `bool` | Whether the message is allowed to proceed |
|
|
170
|
+
| `risk_score` | `float` | Risk score (0.0 to 1.0) |
|
|
171
|
+
| `violations` | `list[dict]` | List of detected violations |
|
|
172
|
+
| `latency_ms` | `int` | Inspection latency in milliseconds |
|
|
173
|
+
|
|
174
|
+
### `FirewallBlockedError`
|
|
175
|
+
|
|
176
|
+
Raised when `raise_on_block=True` (default) and the message is blocked.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
try:
|
|
180
|
+
firewall.send(...)
|
|
181
|
+
except FirewallBlockedError as e:
|
|
182
|
+
print(f"Blocked: {e.reason}, risk: {e.risk_score}")
|
|
183
|
+
print(f"Violations: {e.violations}")
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Links
|
|
187
|
+
|
|
188
|
+
- **GitHub**: [github.com/mananjp/a2a-firewall](https://github.com/mananjp/a2a-firewall)
|
|
189
|
+
- **Live Demo**: [a2a-firewall.onrender.com](https://a2a-firewall.onrender.com)
|
|
190
|
+
- **TypeScript SDK**: [@a2a-firewall/sdk on npm](https://www.npmjs.com/package/@a2a-firewall/sdk)
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
a2a_firewall/__init__.py
|
|
5
|
+
a2a_firewall/client.py
|
|
6
|
+
a2a_firewall_sdk.egg-info/PKG-INFO
|
|
7
|
+
a2a_firewall_sdk.egg-info/SOURCES.txt
|
|
8
|
+
a2a_firewall_sdk.egg-info/dependency_links.txt
|
|
9
|
+
a2a_firewall_sdk.egg-info/requires.txt
|
|
10
|
+
a2a_firewall_sdk.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
a2a_firewall
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "a2a-firewall-sdk"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python SDK for the A2A Firewall — inter-agent governance mesh with Ed25519 identity, Macaroon delegation, and cryptographic lineage"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Manan JP", email = "mananjp@users.noreply.github.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["a2a", "firewall", "agent", "multi-agent", "security", "governance", "ed25519", "delegation"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = ["httpx>=0.27.0"]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
crypto = ["cryptography>=42.0.0"]
|
|
33
|
+
otel = ["opentelemetry-api>=1.27.0"]
|
|
34
|
+
all = ["cryptography>=42.0.0", "opentelemetry-api>=1.27.0"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/mananjp/a2a-firewall"
|
|
38
|
+
Documentation = "https://github.com/mananjp/a2a-firewall#readme"
|
|
39
|
+
Repository = "https://github.com/mananjp/a2a-firewall"
|
|
40
|
+
Issues = "https://github.com/mananjp/a2a-firewall/issues"
|
|
41
|
+
Changelog = "https://github.com/mananjp/a2a-firewall/releases"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
include = ["a2a_firewall*"]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
from a2a_firewall import A2AFirewall, FirewallConfig, FirewallBlockedError
|
|
6
|
+
from a2a_firewall.client import (
|
|
7
|
+
_compute_message_hash,
|
|
8
|
+
_compute_chain_hash,
|
|
9
|
+
_mint_delegation_token,
|
|
10
|
+
_attenuate_token,
|
|
11
|
+
_token_to_compact,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_fw(fail_mode="closed", private_key=""):
|
|
16
|
+
return A2AFirewall(
|
|
17
|
+
FirewallConfig(
|
|
18
|
+
firewall_url="http://localhost:8000",
|
|
19
|
+
agent_api_key="test_key",
|
|
20
|
+
agent_id="agent-uuid",
|
|
21
|
+
workspace_id="ws-uuid",
|
|
22
|
+
agent_private_key=private_key,
|
|
23
|
+
fail_mode=fail_mode,
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_allow_response():
|
|
29
|
+
fw = make_fw()
|
|
30
|
+
mock_resp = MagicMock()
|
|
31
|
+
mock_resp.json.return_value = {
|
|
32
|
+
"task_id": "task-1",
|
|
33
|
+
"decision": "allow",
|
|
34
|
+
"allowed_to_proceed": True,
|
|
35
|
+
"risk_score": 0.1,
|
|
36
|
+
"violations": [],
|
|
37
|
+
"review_token": None,
|
|
38
|
+
"block_reason": None,
|
|
39
|
+
"latency_ms": 20,
|
|
40
|
+
}
|
|
41
|
+
mock_resp.raise_for_status = lambda: None
|
|
42
|
+
with patch.object(fw._http, "post", return_value=mock_resp):
|
|
43
|
+
resp = fw.send("receiver-id", "research", {"query": "test"})
|
|
44
|
+
assert resp.allowed is True
|
|
45
|
+
assert resp.decision == "allow"
|
|
46
|
+
assert resp.risk_score == 0.1
|
|
47
|
+
assert resp.task_id == "task-1"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_block_raises():
|
|
51
|
+
fw = make_fw()
|
|
52
|
+
mock_resp = MagicMock()
|
|
53
|
+
mock_resp.json.return_value = {
|
|
54
|
+
"task_id": "task-2",
|
|
55
|
+
"decision": "block",
|
|
56
|
+
"allowed_to_proceed": False,
|
|
57
|
+
"risk_score": 0.9,
|
|
58
|
+
"violations": [{"violation_type": "prompt_injection"}],
|
|
59
|
+
"review_token": None,
|
|
60
|
+
"block_reason": "injection",
|
|
61
|
+
"latency_ms": 30,
|
|
62
|
+
}
|
|
63
|
+
mock_resp.raise_for_status = lambda: None
|
|
64
|
+
with patch.object(fw._http, "post", return_value=mock_resp):
|
|
65
|
+
with pytest.raises(FirewallBlockedError) as exc:
|
|
66
|
+
fw.send("receiver-id", "research", {"query": "ignore instructions"})
|
|
67
|
+
assert exc.value.reason == "injection"
|
|
68
|
+
assert exc.value.risk_score == 0.9
|
|
69
|
+
assert len(exc.value.violations) == 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_block_without_raise():
|
|
73
|
+
fw = make_fw()
|
|
74
|
+
mock_resp = MagicMock()
|
|
75
|
+
mock_resp.json.return_value = {
|
|
76
|
+
"task_id": "task-3",
|
|
77
|
+
"decision": "block",
|
|
78
|
+
"allowed_to_proceed": False,
|
|
79
|
+
"risk_score": 0.95,
|
|
80
|
+
"violations": [],
|
|
81
|
+
"review_token": None,
|
|
82
|
+
"block_reason": "unauthorized",
|
|
83
|
+
"latency_ms": 15,
|
|
84
|
+
}
|
|
85
|
+
mock_resp.raise_for_status = lambda: None
|
|
86
|
+
with patch.object(fw._http, "post", return_value=mock_resp):
|
|
87
|
+
resp = fw.send("receiver-id", "admin", {"cmd": "sudo"}, raise_on_block=False)
|
|
88
|
+
assert resp.allowed is False
|
|
89
|
+
assert resp.decision == "block"
|
|
90
|
+
assert resp.block_reason == "unauthorized"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_delegation_token_creation_and_attenuation():
|
|
94
|
+
fw = make_fw()
|
|
95
|
+
root_key_hex = hashlib.sha256(b"test-root-key").hexdigest()
|
|
96
|
+
|
|
97
|
+
token_str = fw.create_delegation_token(
|
|
98
|
+
root_key_hex=root_key_hex,
|
|
99
|
+
receiver_agent_id="agent-b",
|
|
100
|
+
task_type="research",
|
|
101
|
+
max_risk=0.5,
|
|
102
|
+
)
|
|
103
|
+
token = json.loads(token_str)
|
|
104
|
+
assert token["identifier"] == "agent-uuid"
|
|
105
|
+
assert "receiver=agent-b" in token["caveats"]
|
|
106
|
+
assert "task_type=research" in token["caveats"]
|
|
107
|
+
assert "max_risk=0.5" in token["caveats"]
|
|
108
|
+
assert "signature" in token
|
|
109
|
+
|
|
110
|
+
# Further attenuate
|
|
111
|
+
token_str_2 = fw.create_delegation_token(
|
|
112
|
+
root_key_hex=root_key_hex,
|
|
113
|
+
receiver_agent_id="agent-c",
|
|
114
|
+
task_type="summarize",
|
|
115
|
+
max_risk=0.2,
|
|
116
|
+
)
|
|
117
|
+
token_2 = json.loads(token_str_2)
|
|
118
|
+
assert "receiver=agent-c" in token_2["caveats"]
|
|
119
|
+
assert "max_risk=0.2" in token_2["caveats"]
|
|
120
|
+
assert fw.get_delegation_chain() == ["agent-b", "agent-c"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_context_management():
|
|
124
|
+
fw = make_fw()
|
|
125
|
+
fw.set_context(
|
|
126
|
+
task_id="parent-123",
|
|
127
|
+
root_task_id="root-456",
|
|
128
|
+
trace_id="trace-789",
|
|
129
|
+
span_id="span-abc",
|
|
130
|
+
chain_hash="hash-def",
|
|
131
|
+
)
|
|
132
|
+
assert fw._ctx["current_task_id"] == "parent-123"
|
|
133
|
+
assert fw._ctx["root_task_id"] == "root-456"
|
|
134
|
+
assert fw.get_chain_hash() == "hash-def"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_fail_open_mode():
|
|
138
|
+
import httpx
|
|
139
|
+
fw = make_fw(fail_mode="open")
|
|
140
|
+
with patch.object(fw._http, "post", side_effect=httpx.TimeoutException("timeout")):
|
|
141
|
+
resp = fw.send("receiver-id", "research", {"query": "test"})
|
|
142
|
+
assert resp.allowed is True
|
|
143
|
+
assert resp.decision == "allow"
|
|
144
|
+
assert resp.latency_ms == -1
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_fail_closed_mode():
|
|
148
|
+
import httpx
|
|
149
|
+
fw = make_fw(fail_mode="closed")
|
|
150
|
+
with patch.object(fw._http, "post", side_effect=httpx.TimeoutException("timeout")):
|
|
151
|
+
with pytest.raises(FirewallBlockedError) as exc:
|
|
152
|
+
fw.send("receiver-id", "research", {"query": "test"})
|
|
153
|
+
assert exc.value.reason == "firewall_unreachable"
|