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.
Files changed (56) hide show
  1. federated_agent_audit/__init__.py +158 -0
  2. federated_agent_audit/access_control.py +199 -0
  3. federated_agent_audit/blame.py +179 -0
  4. federated_agent_audit/cascade_detector.py +281 -0
  5. federated_agent_audit/channel_auditor.py +286 -0
  6. federated_agent_audit/cli.py +287 -0
  7. federated_agent_audit/commit_reveal.py +97 -0
  8. federated_agent_audit/compositional_leak.py +296 -0
  9. federated_agent_audit/compound_attack.py +380 -0
  10. federated_agent_audit/config.py +166 -0
  11. federated_agent_audit/cross_container.py +301 -0
  12. federated_agent_audit/cross_platform_denanon.py +310 -0
  13. federated_agent_audit/desensitizer.py +469 -0
  14. federated_agent_audit/dp_mechanism.py +180 -0
  15. federated_agent_audit/embeddings.py +107 -0
  16. federated_agent_audit/epoch_chain.py +531 -0
  17. federated_agent_audit/injection_detector.py +428 -0
  18. federated_agent_audit/integrity.py +114 -0
  19. federated_agent_audit/lifecycle.py +262 -0
  20. federated_agent_audit/llm_judge.py +527 -0
  21. federated_agent_audit/local_auditor.py +350 -0
  22. federated_agent_audit/memory_audit.py +351 -0
  23. federated_agent_audit/merkle.py +70 -0
  24. federated_agent_audit/negative_inference.py +246 -0
  25. federated_agent_audit/network_auditor.py +489 -0
  26. federated_agent_audit/privacy_gate.py +171 -0
  27. federated_agent_audit/privacy_loss.py +362 -0
  28. federated_agent_audit/py.typed +0 -0
  29. federated_agent_audit/regulatory_compliance.py +520 -0
  30. federated_agent_audit/reporting/__init__.py +5 -0
  31. federated_agent_audit/reporting/html_report.py +1130 -0
  32. federated_agent_audit/risk_aggregator.py +358 -0
  33. federated_agent_audit/scenario_classifier.py +134 -0
  34. federated_agent_audit/schemas.py +261 -0
  35. federated_agent_audit/sdk/__init__.py +54 -0
  36. federated_agent_audit/sdk/_entry_builder.py +131 -0
  37. federated_agent_audit/sdk/_facade.py +185 -0
  38. federated_agent_audit/sdk/crewai.py +246 -0
  39. federated_agent_audit/sdk/generic.py +93 -0
  40. federated_agent_audit/sdk/intercept.py +547 -0
  41. federated_agent_audit/sdk/langchain.py +261 -0
  42. federated_agent_audit/sdk/multiagent.py +269 -0
  43. federated_agent_audit/semantic_detector.py +405 -0
  44. federated_agent_audit/session_identity.py +277 -0
  45. federated_agent_audit/taint_tracker.py +154 -0
  46. federated_agent_audit/topology.py +283 -0
  47. federated_agent_audit/transport/__init__.py +7 -0
  48. federated_agent_audit/transport/client.py +163 -0
  49. federated_agent_audit/transport/server.py +144 -0
  50. federated_agent_audit/transport/wire.py +62 -0
  51. federated_agent_audit-0.2.0.dist-info/METADATA +359 -0
  52. federated_agent_audit-0.2.0.dist-info/RECORD +56 -0
  53. federated_agent_audit-0.2.0.dist-info/WHEEL +5 -0
  54. federated_agent_audit-0.2.0.dist-info/entry_points.txt +2 -0
  55. federated_agent_audit-0.2.0.dist-info/licenses/LICENSE +189 -0
  56. 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