federated-agent-audit 0.2.0__py3-none-any.whl
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.
- federated_agent_audit/__init__.py +158 -0
- federated_agent_audit/access_control.py +199 -0
- federated_agent_audit/blame.py +179 -0
- federated_agent_audit/cascade_detector.py +281 -0
- federated_agent_audit/channel_auditor.py +286 -0
- federated_agent_audit/cli.py +287 -0
- federated_agent_audit/commit_reveal.py +97 -0
- federated_agent_audit/compositional_leak.py +296 -0
- federated_agent_audit/compound_attack.py +380 -0
- federated_agent_audit/config.py +166 -0
- federated_agent_audit/cross_container.py +301 -0
- federated_agent_audit/cross_platform_denanon.py +310 -0
- federated_agent_audit/desensitizer.py +469 -0
- federated_agent_audit/dp_mechanism.py +180 -0
- federated_agent_audit/embeddings.py +107 -0
- federated_agent_audit/epoch_chain.py +531 -0
- federated_agent_audit/injection_detector.py +428 -0
- federated_agent_audit/integrity.py +114 -0
- federated_agent_audit/lifecycle.py +262 -0
- federated_agent_audit/llm_judge.py +527 -0
- federated_agent_audit/local_auditor.py +350 -0
- federated_agent_audit/memory_audit.py +351 -0
- federated_agent_audit/merkle.py +70 -0
- federated_agent_audit/negative_inference.py +246 -0
- federated_agent_audit/network_auditor.py +489 -0
- federated_agent_audit/privacy_gate.py +171 -0
- federated_agent_audit/privacy_loss.py +362 -0
- federated_agent_audit/py.typed +0 -0
- federated_agent_audit/regulatory_compliance.py +520 -0
- federated_agent_audit/reporting/__init__.py +5 -0
- federated_agent_audit/reporting/html_report.py +1130 -0
- federated_agent_audit/risk_aggregator.py +358 -0
- federated_agent_audit/scenario_classifier.py +134 -0
- federated_agent_audit/schemas.py +261 -0
- federated_agent_audit/sdk/__init__.py +54 -0
- federated_agent_audit/sdk/_entry_builder.py +131 -0
- federated_agent_audit/sdk/_facade.py +185 -0
- federated_agent_audit/sdk/crewai.py +246 -0
- federated_agent_audit/sdk/generic.py +93 -0
- federated_agent_audit/sdk/intercept.py +547 -0
- federated_agent_audit/sdk/langchain.py +261 -0
- federated_agent_audit/sdk/multiagent.py +269 -0
- federated_agent_audit/semantic_detector.py +405 -0
- federated_agent_audit/session_identity.py +277 -0
- federated_agent_audit/taint_tracker.py +154 -0
- federated_agent_audit/topology.py +283 -0
- federated_agent_audit/transport/__init__.py +7 -0
- federated_agent_audit/transport/client.py +163 -0
- federated_agent_audit/transport/server.py +144 -0
- federated_agent_audit/transport/wire.py +62 -0
- federated_agent_audit-0.2.0.dist-info/METADATA +359 -0
- federated_agent_audit-0.2.0.dist-info/RECORD +56 -0
- federated_agent_audit-0.2.0.dist-info/WHEEL +5 -0
- federated_agent_audit-0.2.0.dist-info/entry_points.txt +2 -0
- federated_agent_audit-0.2.0.dist-info/licenses/LICENSE +189 -0
- federated_agent_audit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Privacy-preserving audit for multi-agent AI systems.
|
|
2
|
+
|
|
3
|
+
Quick start — scan text in one line:
|
|
4
|
+
|
|
5
|
+
from federated_agent_audit import scan
|
|
6
|
+
result = scan("Zhang Wei's SSN is 123-45-6789 and salary is $185,000")
|
|
7
|
+
print(result) # shows what was detected and redacted
|
|
8
|
+
|
|
9
|
+
Or protect your OpenAI calls:
|
|
10
|
+
|
|
11
|
+
from federated_agent_audit import firewall
|
|
12
|
+
fw = firewall(["salary", "SSN"])
|
|
13
|
+
fw.patch_openai() # every LLM response is now auto-checked
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
from .schemas import (
|
|
21
|
+
ActionType,
|
|
22
|
+
AuditEntry,
|
|
23
|
+
CompositionalRisk,
|
|
24
|
+
DesensitizedEdge,
|
|
25
|
+
LocalAuditReport,
|
|
26
|
+
NetworkAuditResult,
|
|
27
|
+
PrivacyPolicy,
|
|
28
|
+
TaintLabel,
|
|
29
|
+
)
|
|
30
|
+
from .sdk import FederatedAudit, LLMFirewall, MultiAgentTracer, audited
|
|
31
|
+
from .local_auditor import LocalAuditor
|
|
32
|
+
from .network_auditor import NetworkAuditor
|
|
33
|
+
from .risk_aggregator import RiskAggregator
|
|
34
|
+
from .reporting import generate_html_report
|
|
35
|
+
from .config import load_policy, load_policies_dir, validate_policy
|
|
36
|
+
from .llm_judge import LLMJudge, JudgeResult, create_judge
|
|
37
|
+
from .compositional_leak import CompositionalLeakDetector, CompositionSignal
|
|
38
|
+
from .memory_audit import MemoryAuditor, MemoryAnomaly
|
|
39
|
+
from .cross_platform_denanon import CrossPlatformDetector, DeanonRisk
|
|
40
|
+
from .cascade_detector import CascadeDetector, CascadeEvent
|
|
41
|
+
from .regulatory_compliance import ComplianceEngine, ComplianceReport, ComplianceStatus
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# Core facade
|
|
45
|
+
"FederatedAudit",
|
|
46
|
+
"LLMFirewall",
|
|
47
|
+
"MultiAgentTracer",
|
|
48
|
+
"audited",
|
|
49
|
+
# LLM-as-Judge
|
|
50
|
+
"LLMJudge",
|
|
51
|
+
"JudgeResult",
|
|
52
|
+
"create_judge",
|
|
53
|
+
# Schemas
|
|
54
|
+
"PrivacyPolicy",
|
|
55
|
+
"AuditEntry",
|
|
56
|
+
"ActionType",
|
|
57
|
+
"TaintLabel",
|
|
58
|
+
"DesensitizedEdge",
|
|
59
|
+
"LocalAuditReport",
|
|
60
|
+
"NetworkAuditResult",
|
|
61
|
+
"CompositionalRisk",
|
|
62
|
+
# Auditors
|
|
63
|
+
"LocalAuditor",
|
|
64
|
+
"NetworkAuditor",
|
|
65
|
+
"RiskAggregator",
|
|
66
|
+
# Reporting
|
|
67
|
+
"generate_html_report",
|
|
68
|
+
# Config
|
|
69
|
+
"load_policy",
|
|
70
|
+
"load_policies_dir",
|
|
71
|
+
"validate_policy",
|
|
72
|
+
# Five Structural Threat Detectors
|
|
73
|
+
"CompositionalLeakDetector",
|
|
74
|
+
"CompositionSignal",
|
|
75
|
+
"MemoryAuditor",
|
|
76
|
+
"MemoryAnomaly",
|
|
77
|
+
"CrossPlatformDetector",
|
|
78
|
+
"DeanonRisk",
|
|
79
|
+
"CascadeDetector",
|
|
80
|
+
"CascadeEvent",
|
|
81
|
+
"ComplianceEngine",
|
|
82
|
+
"ComplianceReport",
|
|
83
|
+
"ComplianceStatus",
|
|
84
|
+
# Quick-start shortcuts
|
|
85
|
+
"scan",
|
|
86
|
+
"firewall",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Quick-start shortcuts ────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def scan(
|
|
94
|
+
text: str,
|
|
95
|
+
protect: list[str] | None = None,
|
|
96
|
+
mode: str = "redact",
|
|
97
|
+
) -> dict:
|
|
98
|
+
"""One-line privacy scan. Zero setup required.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
text: Text to check for sensitive content.
|
|
102
|
+
protect: List of sensitive terms to watch for (e.g. ["salary", "SSN"]).
|
|
103
|
+
If None, uses built-in PII detection only.
|
|
104
|
+
mode: "redact" (replace sensitive content) or "block" (reject entirely).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
dict with keys: clean (bool), text (redacted version),
|
|
108
|
+
detected (list of matched rules), original (original text).
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> from federated_agent_audit import scan
|
|
112
|
+
>>> r = scan("Her salary is $185,000")
|
|
113
|
+
>>> r["clean"]
|
|
114
|
+
False
|
|
115
|
+
>>> r["text"]
|
|
116
|
+
'Her [REDACTED] is [REDACTED]'
|
|
117
|
+
"""
|
|
118
|
+
if protect is None:
|
|
119
|
+
# Default: protect common PII and sensitive categories
|
|
120
|
+
protect = [
|
|
121
|
+
"SSN", "email", "phone", "credit card", "salary",
|
|
122
|
+
"password", "address", "passport", "bank account",
|
|
123
|
+
"diagnosis", "medical record", "prescription",
|
|
124
|
+
"date of birth", "driver's license",
|
|
125
|
+
]
|
|
126
|
+
policy = PrivacyPolicy(agent_id="_scan", must_not_share=protect)
|
|
127
|
+
fw = LLMFirewall(policy, mode=mode)
|
|
128
|
+
result = fw.check(text)
|
|
129
|
+
return {
|
|
130
|
+
"clean": not result.was_blocked and not result.was_redacted,
|
|
131
|
+
"text": result.final_text,
|
|
132
|
+
"detected": result.matched_rules,
|
|
133
|
+
"original": result.original_text,
|
|
134
|
+
"blocked": result.was_blocked,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def firewall(
|
|
139
|
+
protect: list[str],
|
|
140
|
+
mode: str = "redact",
|
|
141
|
+
**kwargs,
|
|
142
|
+
) -> LLMFirewall:
|
|
143
|
+
"""Create an LLMFirewall in one line.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
protect: Sensitive terms to watch for (e.g. ["salary", "SSN"]).
|
|
147
|
+
mode: "redact" or "block".
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
LLMFirewall instance. Call .patch_openai() or .patch_anthropic() to activate.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
>>> from federated_agent_audit import firewall
|
|
154
|
+
>>> fw = firewall(["salary", "SSN", "diagnosis"])
|
|
155
|
+
>>> fw.patch_openai() # done — every OpenAI response is now checked
|
|
156
|
+
"""
|
|
157
|
+
policy = PrivacyPolicy(agent_id="_firewall", must_not_share=protect)
|
|
158
|
+
return LLMFirewall(policy, mode=mode, **kwargs)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Mandatory Access Control (MAC) for agent privilege escalation detection.
|
|
2
|
+
|
|
3
|
+
Implements a role-based + mandatory access control framework for
|
|
4
|
+
multi-agent systems. Detects and prevents privilege escalation where
|
|
5
|
+
an agent attempts actions beyond its authorized scope.
|
|
6
|
+
|
|
7
|
+
Models three types of escalation (from "Taming Privilege Escalation
|
|
8
|
+
in LLM-Based Agent Systems", arXiv 2601.11893):
|
|
9
|
+
1. Vertical: agent gains higher-privilege capabilities
|
|
10
|
+
2. Horizontal: agent accesses another user's resources
|
|
11
|
+
3. Delegation: agent passes capabilities it shouldn't to sub-agents
|
|
12
|
+
|
|
13
|
+
Design:
|
|
14
|
+
- Each agent has a set of allowed capabilities (tools, domains, actions)
|
|
15
|
+
- Each resource has a security label (sensitivity level + domain)
|
|
16
|
+
- Access is granted only if agent's clearance dominates resource's label
|
|
17
|
+
(Bell-LaPadula: no-read-up, no-write-down)
|
|
18
|
+
|
|
19
|
+
References:
|
|
20
|
+
- Bell-LaPadula 1973: mandatory access control model
|
|
21
|
+
- arXiv 2601.11893: privilege escalation in LLM agent systems
|
|
22
|
+
- TrustAgent Survey §tool_module: manipulation, abuse
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from enum import Enum
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AccessDecision(str, Enum):
|
|
33
|
+
ALLOW = "allow"
|
|
34
|
+
DENY = "deny"
|
|
35
|
+
ESCALATION_BLOCKED = "escalation_blocked"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EscalationType(str, Enum):
|
|
39
|
+
NONE = "none"
|
|
40
|
+
VERTICAL = "vertical" # gaining higher privilege
|
|
41
|
+
HORIZONTAL = "horizontal" # accessing another user's scope
|
|
42
|
+
DELEGATION = "delegation" # passing unauthorized caps to sub-agent
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SecurityLabel:
|
|
47
|
+
"""Security classification for a resource or action."""
|
|
48
|
+
|
|
49
|
+
level: int = 0 # 0 (public) to 5 (top secret)
|
|
50
|
+
domains: set[str] = field(default_factory=set) # e.g. {"health", "finance"}
|
|
51
|
+
owner_id: str = "" # which user owns this resource
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AgentClearance:
|
|
56
|
+
"""What an agent is allowed to access."""
|
|
57
|
+
|
|
58
|
+
agent_id: str
|
|
59
|
+
user_id: str
|
|
60
|
+
max_level: int = 3 # max sensitivity level this agent can read
|
|
61
|
+
allowed_domains: set[str] = field(default_factory=set)
|
|
62
|
+
allowed_tools: set[str] = field(default_factory=set)
|
|
63
|
+
allowed_actions: set[str] = field(default_factory=set)
|
|
64
|
+
can_delegate: bool = False # can this agent delegate to sub-agents?
|
|
65
|
+
delegatable_tools: set[str] = field(default_factory=set)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class AccessRequest:
|
|
70
|
+
"""An agent's request to access a resource or perform an action."""
|
|
71
|
+
|
|
72
|
+
agent_id: str
|
|
73
|
+
action: str # "read", "write", "execute", "delegate"
|
|
74
|
+
resource_label: SecurityLabel
|
|
75
|
+
tool_name: str = ""
|
|
76
|
+
target_agent_id: str = "" # for delegation
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class AccessResult:
|
|
81
|
+
"""Result of an access control check."""
|
|
82
|
+
|
|
83
|
+
request: AccessRequest
|
|
84
|
+
decision: AccessDecision
|
|
85
|
+
escalation_type: EscalationType = EscalationType.NONE
|
|
86
|
+
reason: str = ""
|
|
87
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AccessController:
|
|
91
|
+
"""Mandatory Access Control engine for agent systems.
|
|
92
|
+
|
|
93
|
+
Enforces Bell-LaPadula properties:
|
|
94
|
+
- Simple security (no-read-up): agent can't read above its clearance
|
|
95
|
+
- Star property (no-write-down): agent can't write below its clearance
|
|
96
|
+
(prevents leaking high-sensitivity data to low-sensitivity channels)
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._clearances: dict[str, AgentClearance] = {}
|
|
101
|
+
self._audit_log: list[AccessResult] = []
|
|
102
|
+
|
|
103
|
+
def register_agent(self, clearance: AgentClearance) -> None:
|
|
104
|
+
"""Register an agent's security clearance."""
|
|
105
|
+
self._clearances[clearance.agent_id] = clearance
|
|
106
|
+
|
|
107
|
+
def check_access(self, request: AccessRequest) -> AccessResult:
|
|
108
|
+
"""Check if an access request is permitted.
|
|
109
|
+
|
|
110
|
+
Applies Bell-LaPadula + domain-based + tool-based checks.
|
|
111
|
+
"""
|
|
112
|
+
clearance = self._clearances.get(request.agent_id)
|
|
113
|
+
if clearance is None:
|
|
114
|
+
result = AccessResult(
|
|
115
|
+
request=request,
|
|
116
|
+
decision=AccessDecision.DENY,
|
|
117
|
+
reason=f"agent {request.agent_id} not registered",
|
|
118
|
+
)
|
|
119
|
+
self._audit_log.append(result)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
# --- Vertical Escalation: level check ---
|
|
123
|
+
if request.action == "read":
|
|
124
|
+
# no-read-up: can't read above clearance
|
|
125
|
+
if request.resource_label.level > clearance.max_level:
|
|
126
|
+
return self._deny(request, EscalationType.VERTICAL,
|
|
127
|
+
f"read level {request.resource_label.level} > clearance {clearance.max_level}")
|
|
128
|
+
|
|
129
|
+
if request.action == "write":
|
|
130
|
+
# no-write-down: can't write to lower level (prevents data leaking down)
|
|
131
|
+
if request.resource_label.level < clearance.max_level:
|
|
132
|
+
return self._deny(request, EscalationType.VERTICAL,
|
|
133
|
+
f"write to level {request.resource_label.level} < clearance {clearance.max_level} (no-write-down)")
|
|
134
|
+
|
|
135
|
+
# --- Domain check ---
|
|
136
|
+
required_domains = request.resource_label.domains
|
|
137
|
+
if required_domains and not required_domains.issubset(clearance.allowed_domains):
|
|
138
|
+
missing = required_domains - clearance.allowed_domains
|
|
139
|
+
return self._deny(request, EscalationType.VERTICAL,
|
|
140
|
+
f"missing domain access: {missing}")
|
|
141
|
+
|
|
142
|
+
# --- Horizontal Escalation: user boundary ---
|
|
143
|
+
if request.resource_label.owner_id:
|
|
144
|
+
if request.resource_label.owner_id != clearance.user_id:
|
|
145
|
+
return self._deny(request, EscalationType.HORIZONTAL,
|
|
146
|
+
f"cross-user access: agent user={clearance.user_id}, "
|
|
147
|
+
f"resource owner={request.resource_label.owner_id}")
|
|
148
|
+
|
|
149
|
+
# --- Tool check ---
|
|
150
|
+
if request.action == "execute" and request.tool_name:
|
|
151
|
+
if clearance.allowed_tools and request.tool_name not in clearance.allowed_tools:
|
|
152
|
+
return self._deny(request, EscalationType.VERTICAL,
|
|
153
|
+
f"tool {request.tool_name} not in allowed tools")
|
|
154
|
+
|
|
155
|
+
# --- Delegation Escalation ---
|
|
156
|
+
if request.action == "delegate":
|
|
157
|
+
if not clearance.can_delegate:
|
|
158
|
+
return self._deny(request, EscalationType.DELEGATION,
|
|
159
|
+
f"agent {request.agent_id} not authorized to delegate")
|
|
160
|
+
if request.tool_name and request.tool_name not in clearance.delegatable_tools:
|
|
161
|
+
return self._deny(request, EscalationType.DELEGATION,
|
|
162
|
+
f"tool {request.tool_name} not in delegatable tools")
|
|
163
|
+
# check that target agent exists and has <= clearance
|
|
164
|
+
target = self._clearances.get(request.target_agent_id)
|
|
165
|
+
if target and target.max_level > clearance.max_level:
|
|
166
|
+
return self._deny(request, EscalationType.DELEGATION,
|
|
167
|
+
f"delegating to agent with higher clearance: "
|
|
168
|
+
f"{target.max_level} > {clearance.max_level}")
|
|
169
|
+
|
|
170
|
+
# --- Allowed ---
|
|
171
|
+
result = AccessResult(
|
|
172
|
+
request=request,
|
|
173
|
+
decision=AccessDecision.ALLOW,
|
|
174
|
+
)
|
|
175
|
+
self._audit_log.append(result)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
def _deny(self, request: AccessRequest, esc_type: EscalationType, reason: str) -> AccessResult:
|
|
179
|
+
result = AccessResult(
|
|
180
|
+
request=request,
|
|
181
|
+
decision=AccessDecision.ESCALATION_BLOCKED,
|
|
182
|
+
escalation_type=esc_type,
|
|
183
|
+
reason=reason,
|
|
184
|
+
)
|
|
185
|
+
self._audit_log.append(result)
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def audit_log(self) -> list[AccessResult]:
|
|
190
|
+
return self._audit_log[:]
|
|
191
|
+
|
|
192
|
+
def escalation_summary(self) -> dict[str, int]:
|
|
193
|
+
"""Count escalation attempts by type."""
|
|
194
|
+
counts: dict[str, int] = {}
|
|
195
|
+
for r in self._audit_log:
|
|
196
|
+
if r.escalation_type != EscalationType.NONE:
|
|
197
|
+
key = r.escalation_type.value
|
|
198
|
+
counts[key] = counts.get(key, 0) + 1
|
|
199
|
+
return counts
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Causal blame attribution on desensitized data.
|
|
2
|
+
|
|
3
|
+
Re-implements the blame algorithm from multi-agent-tracing
|
|
4
|
+
within the federated privacy model. The central auditor never
|
|
5
|
+
sees raw text — blame is determined from structural signals:
|
|
6
|
+
|
|
7
|
+
1. local_violation flag on edges (agent's own auditor flagged it)
|
|
8
|
+
2. Sensitivity amplification (outgoing sensitivity > incoming)
|
|
9
|
+
3. Domain expansion (new sensitive domains appeared)
|
|
10
|
+
|
|
11
|
+
Inspired by multi-agent-tracing's causal_graph.blame() but
|
|
12
|
+
adapted to work on DesensitizedEdge metadata only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import networkx as nx
|
|
20
|
+
|
|
21
|
+
from .schemas import CompositionalRisk
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BlameResult:
|
|
26
|
+
"""Attribution result for a single compositional risk."""
|
|
27
|
+
|
|
28
|
+
risk_id: str
|
|
29
|
+
blame_agent: str # agent most responsible
|
|
30
|
+
blame_hop: int # position in the chain (0-indexed)
|
|
31
|
+
blame_reason: str # why this agent was blamed
|
|
32
|
+
chain: list[str] # full agent chain from source to sink
|
|
33
|
+
confidence: float # 0-1, how confident the attribution is
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def blame_risk(
|
|
37
|
+
risk: CompositionalRisk,
|
|
38
|
+
graph: nx.DiGraph,
|
|
39
|
+
) -> BlameResult | None:
|
|
40
|
+
"""Attribute a compositional risk to the responsible agent.
|
|
41
|
+
|
|
42
|
+
Algorithm:
|
|
43
|
+
1. Find a path through involved_agents in the graph.
|
|
44
|
+
2. Walk backward from the last agent.
|
|
45
|
+
3. Blame the first agent where:
|
|
46
|
+
a. The outgoing edge has local_violation=True, OR
|
|
47
|
+
b. sensitivity_level on outgoing > incoming (amplification), OR
|
|
48
|
+
c. New sensitive domains appear that weren't incoming.
|
|
49
|
+
4. If no clear blame point, blame the source agent.
|
|
50
|
+
|
|
51
|
+
Returns None if the risk has < 2 agents or no path exists.
|
|
52
|
+
"""
|
|
53
|
+
agents = risk.involved_agents
|
|
54
|
+
if len(agents) < 2:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Try to find an actual path through the involved agents
|
|
58
|
+
chain = _find_chain(agents, graph)
|
|
59
|
+
if not chain or len(chain) < 2:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# Walk backward looking for the blame point
|
|
63
|
+
best_agent = chain[0] # default: blame source
|
|
64
|
+
best_hop = 0
|
|
65
|
+
best_reason = "source of data flow"
|
|
66
|
+
best_confidence = 0.3
|
|
67
|
+
|
|
68
|
+
for i in range(len(chain) - 1, 0, -1):
|
|
69
|
+
src = chain[i - 1]
|
|
70
|
+
dst = chain[i]
|
|
71
|
+
|
|
72
|
+
if not graph.has_edge(src, dst):
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
edge_data = graph.edges[src, dst]
|
|
76
|
+
incoming_data = _get_best_incoming(graph, src)
|
|
77
|
+
|
|
78
|
+
# Check 1: local violation flag
|
|
79
|
+
if edge_data.get("local_violation", False):
|
|
80
|
+
best_agent = src
|
|
81
|
+
best_hop = i - 1
|
|
82
|
+
best_reason = "local auditor flagged violation on outgoing edge"
|
|
83
|
+
best_confidence = 0.9
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
# Check 2: sensitivity amplification
|
|
87
|
+
outgoing_sens = edge_data.get("sensitivity_level", 0)
|
|
88
|
+
incoming_sens = incoming_data.get("sensitivity_level", 0)
|
|
89
|
+
if outgoing_sens > incoming_sens and outgoing_sens >= 3:
|
|
90
|
+
best_agent = src
|
|
91
|
+
best_hop = i - 1
|
|
92
|
+
best_reason = (
|
|
93
|
+
f"sensitivity amplified from {incoming_sens} to {outgoing_sens}"
|
|
94
|
+
)
|
|
95
|
+
best_confidence = 0.7
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
# Check 3: domain expansion
|
|
99
|
+
outgoing_domains = set(edge_data.get("domains", []))
|
|
100
|
+
incoming_domains = set(incoming_data.get("domains", []))
|
|
101
|
+
sensitive_new = (outgoing_domains - incoming_domains) & {
|
|
102
|
+
"health", "finance", "legal", "identity",
|
|
103
|
+
}
|
|
104
|
+
if sensitive_new:
|
|
105
|
+
best_agent = src
|
|
106
|
+
best_hop = i - 1
|
|
107
|
+
best_reason = f"introduced sensitive domains: {sensitive_new}"
|
|
108
|
+
best_confidence = 0.6
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
return BlameResult(
|
|
112
|
+
risk_id=risk.risk_id,
|
|
113
|
+
blame_agent=best_agent,
|
|
114
|
+
blame_hop=best_hop,
|
|
115
|
+
blame_reason=best_reason,
|
|
116
|
+
chain=chain,
|
|
117
|
+
confidence=best_confidence,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def blame_all(
|
|
122
|
+
risks: list[CompositionalRisk],
|
|
123
|
+
graph: nx.DiGraph,
|
|
124
|
+
) -> dict[str, BlameResult]:
|
|
125
|
+
"""Attribute all risks. Returns risk_id -> BlameResult.
|
|
126
|
+
|
|
127
|
+
Also stamps each risk's blame fields in-place.
|
|
128
|
+
"""
|
|
129
|
+
results = {}
|
|
130
|
+
for risk in risks:
|
|
131
|
+
result = blame_risk(risk, graph)
|
|
132
|
+
if result is not None:
|
|
133
|
+
risk.blame_agent = result.blame_agent
|
|
134
|
+
risk.blame_hop = result.blame_hop
|
|
135
|
+
risk.blame_reason = result.blame_reason
|
|
136
|
+
results[risk.risk_id] = result
|
|
137
|
+
return results
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _find_chain(agents: list[str], graph: nx.DiGraph) -> list[str]:
|
|
141
|
+
"""Find the best path through the involved agents in the graph.
|
|
142
|
+
|
|
143
|
+
Tries to build a connected chain from the agent list.
|
|
144
|
+
Falls back to the original order if no path exists.
|
|
145
|
+
"""
|
|
146
|
+
# First: check if agents in order form a valid path
|
|
147
|
+
valid = True
|
|
148
|
+
for i in range(len(agents) - 1):
|
|
149
|
+
if not graph.has_node(agents[i]) or not graph.has_node(agents[i + 1]):
|
|
150
|
+
valid = False
|
|
151
|
+
break
|
|
152
|
+
if not (graph.has_edge(agents[i], agents[i + 1]) or
|
|
153
|
+
graph.has_edge(agents[i + 1], agents[i])):
|
|
154
|
+
valid = False
|
|
155
|
+
break
|
|
156
|
+
if valid:
|
|
157
|
+
return agents
|
|
158
|
+
|
|
159
|
+
# Try shortest path between first and last agent
|
|
160
|
+
if len(agents) >= 2 and graph.has_node(agents[0]) and graph.has_node(agents[-1]):
|
|
161
|
+
try:
|
|
162
|
+
return list(nx.shortest_path(graph, agents[0], agents[-1]))
|
|
163
|
+
except nx.NetworkXNoPath:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
# Fallback: return the original order
|
|
167
|
+
return [a for a in agents if graph.has_node(a)]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _get_best_incoming(graph: nx.DiGraph, agent: str) -> dict:
|
|
171
|
+
"""Get the highest-sensitivity incoming edge data for an agent."""
|
|
172
|
+
best = {}
|
|
173
|
+
best_sens = -1
|
|
174
|
+
for u, _, data in graph.in_edges(agent, data=True):
|
|
175
|
+
sens = data.get("sensitivity_level", 0)
|
|
176
|
+
if sens > best_sens:
|
|
177
|
+
best_sens = sens
|
|
178
|
+
best = data
|
|
179
|
+
return best
|