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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/a2a-firewall-sdk)](https://pypi.org/project/a2a-firewall-sdk/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
43
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/a2a-firewall-sdk)](https://pypi.org/project/a2a-firewall-sdk/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/a2a-firewall-sdk)](https://pypi.org/project/a2a-firewall-sdk/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
43
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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,11 @@
1
+ httpx>=0.27.0
2
+
3
+ [all]
4
+ cryptography>=42.0.0
5
+ opentelemetry-api>=1.27.0
6
+
7
+ [crypto]
8
+ cryptography>=42.0.0
9
+
10
+ [otel]
11
+ opentelemetry-api>=1.27.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"