SafeAI-Static-Analyzer 1.4.0b0__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 (118) hide show
  1. safeai/__init__.py +3 -0
  2. safeai/__main__.py +8 -0
  3. safeai/analysis/__init__.py +0 -0
  4. safeai/analysis/aggregation.py +205 -0
  5. safeai/analysis/capabilities.py +238 -0
  6. safeai/analysis/capability_diff.py +261 -0
  7. safeai/analysis/components.py +357 -0
  8. safeai/analysis/dependency_correlation.py +253 -0
  9. safeai/analysis/escalation.py +527 -0
  10. safeai/analysis/import_graph.py +105 -0
  11. safeai/analysis/project_graph.py +53 -0
  12. safeai/analysis/semantic.py +200 -0
  13. safeai/analysis/tool_identity.py +136 -0
  14. safeai/analysis/tool_surface.py +248 -0
  15. safeai/analyzers/__init__.py +0 -0
  16. safeai/analyzers/capability/__init__.py +0 -0
  17. safeai/analyzers/capability/analyzer.py +262 -0
  18. safeai/analyzers/claude_code/__init__.py +1 -0
  19. safeai/analyzers/claude_code/analyzer.py +410 -0
  20. safeai/analyzers/data_leakage/__init__.py +0 -0
  21. safeai/analyzers/data_leakage/analyzer.py +68 -0
  22. safeai/analyzers/env_dependency/__init__.py +10 -0
  23. safeai/analyzers/env_dependency/analyzer.py +202 -0
  24. safeai/analyzers/mcp/__init__.py +0 -0
  25. safeai/analyzers/mcp/analyzer.py +413 -0
  26. safeai/analyzers/mcp/compatibility.py +58 -0
  27. safeai/analyzers/mcp/schema.py +31 -0
  28. safeai/analyzers/mcp/validators.py +59 -0
  29. safeai/analyzers/model_config/__init__.py +0 -0
  30. safeai/analyzers/model_config/analyzer.py +196 -0
  31. safeai/analyzers/prompt/__init__.py +0 -0
  32. safeai/analyzers/prompt/analyzer.py +114 -0
  33. safeai/analyzers/prompt_file/__init__.py +0 -0
  34. safeai/analyzers/prompt_file/analyzer.py +132 -0
  35. safeai/analyzers/skill/__init__.py +0 -0
  36. safeai/analyzers/skill/analyzer.py +183 -0
  37. safeai/analyzers/tool_def/__init__.py +0 -0
  38. safeai/analyzers/tool_def/analyzer.py +177 -0
  39. safeai/analyzers/workflow/__init__.py +0 -0
  40. safeai/analyzers/workflow/analyzer.py +134 -0
  41. safeai/cmd/__init__.py +0 -0
  42. safeai/cmd/cli.py +159 -0
  43. safeai/cmd/postprocess.py +351 -0
  44. safeai/cmd/registry_cli.py +352 -0
  45. safeai/engine/__init__.py +0 -0
  46. safeai/engine/orchestrator.py +564 -0
  47. safeai/engine/scan.py +28 -0
  48. safeai/frameworks/__init__.py +79 -0
  49. safeai/frameworks/azure_foundry/__init__.py +0 -0
  50. safeai/frameworks/azure_foundry/parser.py +60 -0
  51. safeai/frameworks/bedrock_agent/__init__.py +0 -0
  52. safeai/frameworks/bedrock_agent/parser.py +33 -0
  53. safeai/frameworks/claude_code/__init__.py +0 -0
  54. safeai/frameworks/claude_code/commands.py +158 -0
  55. safeai/frameworks/claude_code/parser.py +274 -0
  56. safeai/frameworks/claude_code/permissions.py +270 -0
  57. safeai/frameworks/claude_code/settings.py +221 -0
  58. safeai/frameworks/crewai/__init__.py +0 -0
  59. safeai/frameworks/crewai/parser.py +118 -0
  60. safeai/frameworks/dify/__init__.py +0 -0
  61. safeai/frameworks/dify/parser.py +111 -0
  62. safeai/frameworks/google_adk/__init__.py +0 -0
  63. safeai/frameworks/google_adk/parser.py +86 -0
  64. safeai/frameworks/haystack/__init__.py +0 -0
  65. safeai/frameworks/haystack/parser.py +91 -0
  66. safeai/frameworks/langchain/__init__.py +0 -0
  67. safeai/frameworks/langchain/parser.py +116 -0
  68. safeai/frameworks/langgraph/__init__.py +0 -0
  69. safeai/frameworks/langgraph/parser.py +116 -0
  70. safeai/frameworks/llamaindex/__init__.py +0 -0
  71. safeai/frameworks/llamaindex/parser.py +85 -0
  72. safeai/frameworks/mastra/__init__.py +0 -0
  73. safeai/frameworks/mastra/parser.py +94 -0
  74. safeai/frameworks/microsoft_agent/__init__.py +0 -0
  75. safeai/frameworks/microsoft_agent/parser.py +124 -0
  76. safeai/frameworks/n8n/__init__.py +0 -0
  77. safeai/frameworks/n8n/parser.py +127 -0
  78. safeai/frameworks/openai_agents/__init__.py +0 -0
  79. safeai/frameworks/openai_agents/parser.py +117 -0
  80. safeai/frameworks/semantic_kernel/__init__.py +0 -0
  81. safeai/frameworks/semantic_kernel/parser.py +111 -0
  82. safeai/kya/__init__.py +19 -0
  83. safeai/kya/assurance.py +169 -0
  84. safeai/kya/baseline.py +92 -0
  85. safeai/kya/ci_context.py +181 -0
  86. safeai/kya/enrich.py +190 -0
  87. safeai/kya/exporter.py +86 -0
  88. safeai/kya/fingerprints.py +70 -0
  89. safeai/kya/identity.py +145 -0
  90. safeai/kya/manifest.py +192 -0
  91. safeai/kya/policy.py +236 -0
  92. safeai/kya/registry/__init__.py +80 -0
  93. safeai/kya/registry/connection.py +119 -0
  94. safeai/kya/registry/persist.py +354 -0
  95. safeai/kya/registry/queries.py +249 -0
  96. safeai/kya/registry/schema.py +147 -0
  97. safeai/kya/suppressions.py +169 -0
  98. safeai/kya/util.py +129 -0
  99. safeai/report/__init__.py +0 -0
  100. safeai/report/html.py +358 -0
  101. safeai/report/html_kit.py +294 -0
  102. safeai/report/json_report.py +12 -0
  103. safeai/report/pr_comment.py +295 -0
  104. safeai/report/registry_html.py +283 -0
  105. safeai/report/sarif.py +84 -0
  106. safeai/report/terminal.py +120 -0
  107. safeai/rules/__init__.py +0 -0
  108. safeai/rules/base_rules.yaml +294 -0
  109. safeai/rules/loader.py +32 -0
  110. safeai/scoring/__init__.py +0 -0
  111. safeai/scoring/engine.py +84 -0
  112. safeai/severity.py +34 -0
  113. safeai_static_analyzer-1.4.0b0.dist-info/METADATA +540 -0
  114. safeai_static_analyzer-1.4.0b0.dist-info/RECORD +118 -0
  115. safeai_static_analyzer-1.4.0b0.dist-info/WHEEL +5 -0
  116. safeai_static_analyzer-1.4.0b0.dist-info/entry_points.txt +2 -0
  117. safeai_static_analyzer-1.4.0b0.dist-info/licenses/LICENSE +201 -0
  118. safeai_static_analyzer-1.4.0b0.dist-info/top_level.txt +1 -0
safeai/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SafeAI — Static AI Capability & Risk Analyzer for AI agents and workflows."""
2
+
3
+ __version__ = "1.3.0b0"
safeai/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m safeai``."""
2
+
3
+ import sys
4
+
5
+ from safeai.cmd.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
File without changes
@@ -0,0 +1,205 @@
1
+ """Multi-parser output aggregation and capability deduplication.
2
+
3
+ When multiple framework parsers match the same file (e.g. a LangGraph
4
+ file also imports LangChain), this module merges their outputs by
5
+ deduplicating named artifacts (agents, tools, workflows, etc.) using
6
+ case-insensitive name keys and retaining the highest confidence per
7
+ artifact from any contributing parser.
8
+ """
9
+
10
+
11
+ def _as_items(values):
12
+ """Normalize heterogeneous values into a list of dicts with ``name`` and ``evidence``."""
13
+ out = []
14
+ for value in values or []:
15
+ if isinstance(value, dict):
16
+ out.append(value)
17
+ else:
18
+ out.append({"name": str(value), "evidence": str(value)})
19
+ return out
20
+
21
+
22
+ def _merge_named_items(items, framework, parser_confidence):
23
+ """Merge a list of named items from one framework parse result.
24
+
25
+ Items are keyed by case-insensitive name. Conflicting items from
26
+ the same framework keep the highest confidence; subsequent entries
27
+ accumulate evidence and provenance.
28
+ """
29
+ merged = {}
30
+ for item in _as_items(items):
31
+ name = item.get("name") or item.get("id") or item.get("evidence")
32
+ if not name:
33
+ continue
34
+ key = str(name).lower()
35
+ existing = merged.get(key)
36
+ evidence = item.get("evidence") or str(name)
37
+ confidence = float(item.get("confidence", parser_confidence))
38
+ source = item.get("source", "ast")
39
+ if not existing:
40
+ merged[key] = {
41
+ "name": name,
42
+ "evidence": [evidence],
43
+ "frameworks": [framework],
44
+ "provenance": [{
45
+ "framework": framework,
46
+ "source": source,
47
+ "confidence": confidence,
48
+ "evidence": evidence,
49
+ }],
50
+ "confidence": confidence,
51
+ }
52
+ continue
53
+ if framework not in existing["frameworks"]:
54
+ existing["frameworks"].append(framework)
55
+ if evidence not in existing["evidence"]:
56
+ existing["evidence"].append(evidence)
57
+ existing["provenance"].append({
58
+ "framework": framework,
59
+ "source": source,
60
+ "confidence": confidence,
61
+ "evidence": evidence,
62
+ })
63
+ existing["confidence"] = max(existing["confidence"], confidence)
64
+ return list(merged.values())
65
+
66
+
67
+ def aggregate_parser_models(per_file_results):
68
+ """Merge parser results for each file into a single unified model per file.
69
+
70
+ Handles the case where multiple frameworks are detected in the same
71
+ source file by merging named artifacts and accumulating provenance
72
+ metadata from each contributing parser.
73
+ """
74
+ unified_models = []
75
+ for path, parser_results in per_file_results.items():
76
+ frameworks = []
77
+ methods = set()
78
+ framework_confidences = {}
79
+ artifacts = {
80
+ "agents": [],
81
+ "workflows": [],
82
+ "planners": [],
83
+ "memory": [],
84
+ "tools": [],
85
+ "prompts": [],
86
+ "models": [],
87
+ "external_services": [],
88
+ "skills": [],
89
+ "relationships": [],
90
+ "capabilities": [],
91
+ }
92
+
93
+ for result in parser_results:
94
+ framework = result.get("framework")
95
+ if framework and framework not in frameworks:
96
+ frameworks.append(framework)
97
+ parser_conf = float(result.get("parser_confidence", 0.65))
98
+ if framework:
99
+ framework_confidences[framework] = max(framework_confidences.get(framework, 0.0), parser_conf)
100
+ methods.add(result.get("discovery_method", "regex"))
101
+
102
+ for key in ["agents", "workflows", "planners", "memory", "tools", "prompts", "models", "external_services", "skills", "relationships", "capabilities"]:
103
+ values = result.get(key)
104
+ if not values:
105
+ continue
106
+ if isinstance(values, list):
107
+ artifacts[key].extend(values)
108
+ else:
109
+ artifacts[key].append(values)
110
+
111
+ merged_artifacts = {}
112
+ for key in ["agents", "workflows", "planners", "memory", "tools", "prompts", "models", "external_services", "skills"]:
113
+ combined = []
114
+ for result in parser_results:
115
+ values = result.get(key) or []
116
+ framework = result.get("framework", "unknown")
117
+ parser_conf = float(result.get("parser_confidence", 0.65))
118
+ combined.extend(_merge_named_items(values, framework, parser_conf))
119
+
120
+ dedup = {}
121
+ for item in combined:
122
+ k = str(item.get("name", "")).lower()
123
+ if not k:
124
+ continue
125
+ if k not in dedup:
126
+ dedup[k] = item
127
+ continue
128
+ existing = dedup[k]
129
+ existing["confidence"] = max(existing["confidence"], item.get("confidence", 0.0))
130
+ for f in item.get("frameworks", []):
131
+ if f not in existing["frameworks"]:
132
+ existing["frameworks"].append(f)
133
+ for ev in item.get("evidence", []):
134
+ if ev not in existing["evidence"]:
135
+ existing["evidence"].append(ev)
136
+ existing["provenance"].extend(item.get("provenance", []))
137
+ merged_artifacts[key] = list(dedup.values())
138
+
139
+ unified_models.append({
140
+ "file": path,
141
+ "frameworks": frameworks,
142
+ "framework_confidence": framework_confidences,
143
+ "discovery_methods": sorted(methods),
144
+ "artifacts": merged_artifacts,
145
+ "relationships": artifacts["relationships"],
146
+ "capabilities": aggregate_capabilities(artifacts["capabilities"]),
147
+ })
148
+ return unified_models
149
+
150
+
151
+ def aggregate_capabilities(capabilities):
152
+ """Merge and deduplicate capability entries from multiple sources.
153
+
154
+ Capabilities are keyed by ``(name_lower, category_lower)``. When
155
+ duplicates exist the highest confidence and risk weight are kept,
156
+ and evidence, sources, and provenance are accumulated.
157
+ """
158
+ merged = {}
159
+ for cap in capabilities or []:
160
+ name = str(cap.get("name", "capability"))
161
+ category = cap.get("category", "Capability")
162
+ key = (name.lower(), str(category).lower())
163
+ conf = float(cap.get("confidence", 0.6))
164
+ framework = cap.get("source_framework")
165
+ evidence = cap.get("evidence") or name
166
+ source = cap.get("source", "ast")
167
+ resolved = cap.get("resolved_definition")
168
+ if key not in merged:
169
+ merged[key] = {
170
+ "name": name,
171
+ "category": category,
172
+ "confidence": conf,
173
+ "source_frameworks": [framework] if framework else [],
174
+ "evidence": [evidence],
175
+ "sources": [source],
176
+ "resolved_definitions": [resolved] if resolved else [],
177
+ "risk_weight": float(cap.get("risk_weight", 1.0)),
178
+ "provenance": [{
179
+ "framework": framework,
180
+ "confidence": conf,
181
+ "source": source,
182
+ "evidence": evidence,
183
+ "resolved_definition": resolved,
184
+ }],
185
+ }
186
+ continue
187
+ item = merged[key]
188
+ item["confidence"] = max(item["confidence"], conf)
189
+ item["risk_weight"] = max(item["risk_weight"], float(cap.get("risk_weight", 1.0)))
190
+ if framework and framework not in item["source_frameworks"]:
191
+ item["source_frameworks"].append(framework)
192
+ if evidence and evidence not in item["evidence"]:
193
+ item["evidence"].append(evidence)
194
+ if source and source not in item["sources"]:
195
+ item["sources"].append(source)
196
+ if resolved and resolved not in item["resolved_definitions"]:
197
+ item["resolved_definitions"].append(resolved)
198
+ item["provenance"].append({
199
+ "framework": framework,
200
+ "confidence": conf,
201
+ "source": source,
202
+ "evidence": evidence,
203
+ "resolved_definition": resolved,
204
+ })
205
+ return list(merged.values())
@@ -0,0 +1,238 @@
1
+ """Canonical capability categories, access modes, and convenience builders.
2
+
3
+ Capabilities represent what an AI agent can *do* (shell execution,
4
+ filesystem access, database queries, memory, etc.). These categories
5
+ are used by all framework parsers and the capability analyzer to
6
+ produce a consistent risk picture.
7
+
8
+ Since v1.4 a capability may additionally carry:
9
+
10
+ ``tool_identity``
11
+ Which tool / MCP server / skill holds the capability
12
+ (see :mod:`safeai.analysis.tool_identity`).
13
+ ``access_mode``
14
+ How much authority the capability grants, on the ordered scale
15
+ :data:`ACCESS_MODES`.
16
+
17
+ Both are **optional keyword-only** additions: every pre-1.4 adapter that
18
+ calls ``make_capability`` positionally keeps working unchanged.
19
+ """
20
+
21
+ import re
22
+
23
+ CAPABILITY_CATEGORIES = {
24
+ "filesystem": "Filesystem",
25
+ "shell": "Shell",
26
+ "browser": "Browser",
27
+ "planner": "Planner",
28
+ "delegation": "Delegation",
29
+ "memory": "Memory",
30
+ "rag": "RAG",
31
+ "github": "GitHub",
32
+ "slack": "Slack",
33
+ "email": "Email",
34
+ "databases": "Databases",
35
+ "cloud": "Cloud",
36
+ "external_apis": "External APIs",
37
+ "mcp": "MCP",
38
+ "human_approval": "Human Approval",
39
+ "multi_agent": "Multi-Agent",
40
+ "container": "Container",
41
+ "collaboration": "Collaboration",
42
+ "untrusted_input": "Untrusted Input",
43
+ }
44
+
45
+ #: Ordered by ascending severity of the authority granted.
46
+ ACCESS_MODES = ["none", "read", "write", "mutate", "execute"]
47
+
48
+ _ACCESS_RANK = {mode: index for index, mode in enumerate(ACCESS_MODES)}
49
+
50
+ # --- Heuristic vocabulary for infer_access_mode -------------------------
51
+ # Keep these as data, not branching logic, so the inference stays auditable.
52
+
53
+ _EXECUTE_CATEGORIES = {"shell", "container"}
54
+ _EXECUTE_TERMS = (
55
+ "shell", "exec", "subprocess", "command", "terminal", "bash", "sh(",
56
+ "eval", "compile", "spawn", "popen", "system(", "run_code", "code_exec",
57
+ )
58
+ _MUTATE_TERMS = (
59
+ "delete", "drop", "destroy", "remove", "purge", "revoke", "truncate",
60
+ "mutate", "overwrite", "rotate", "terminate",
61
+ )
62
+ _WRITE_TERMS = (
63
+ "write", "create", "update", "insert", "upsert", "patch", "put", "post",
64
+ "send", "publish", "upload", "commit", "push", "merge", "modify", "set_",
65
+ "append", "save", "store", "provision", "deploy",
66
+ )
67
+ _READONLY_TERMS = (
68
+ "read", "get", "list", "search", "query", "fetch", "retrieve", "describe",
69
+ "view", "lookup", "inspect",
70
+ )
71
+ _NONE_CATEGORIES = {"human_approval"}
72
+
73
+
74
+ def access_mode_rank(mode):
75
+ """Return the ordinal severity of ``mode``; unknown values rank as ``none``."""
76
+ return _ACCESS_RANK.get(str(mode or "none").strip().lower(), 0)
77
+
78
+
79
+ def normalize_access_mode(mode):
80
+ """Coerce an arbitrary value to a member of :data:`ACCESS_MODES`."""
81
+ candidate = str(mode or "").strip().lower()
82
+ return candidate if candidate in _ACCESS_RANK else "none"
83
+
84
+
85
+ def is_escalation(before, after):
86
+ """True when ``after`` grants strictly more authority than ``before``."""
87
+ return access_mode_rank(after) > access_mode_rank(before)
88
+
89
+
90
+ def max_access_mode(modes):
91
+ """Return the highest access mode in ``modes`` (``"none"`` when empty)."""
92
+ highest = "none"
93
+ for mode in modes or []:
94
+ if access_mode_rank(mode) > access_mode_rank(highest):
95
+ highest = normalize_access_mode(mode)
96
+ return highest
97
+
98
+
99
+ def make_capability(
100
+ name,
101
+ category,
102
+ framework,
103
+ evidence,
104
+ confidence=0.7,
105
+ risk_weight=1.0,
106
+ source="ast",
107
+ resolved_definition=None,
108
+ *,
109
+ tool_identity=None,
110
+ access_mode=None,
111
+ line=None,
112
+ ):
113
+ """Create a standardized capability dict consumed by the aggregation pipeline.
114
+
115
+ The positional signature is frozen for backward compatibility with the
116
+ framework adapters shipped before v1.4. ``tool_identity``,
117
+ ``access_mode`` and ``line`` are keyword-only and default to ``None``.
118
+ """
119
+ capability = {
120
+ "name": name,
121
+ "category": category,
122
+ "source_framework": framework,
123
+ "evidence": evidence,
124
+ "confidence": confidence,
125
+ "risk_weight": risk_weight,
126
+ "source": source,
127
+ "resolved_definition": resolved_definition,
128
+ }
129
+ if tool_identity is not None:
130
+ capability["tool_identity"] = tool_identity
131
+ if access_mode is not None:
132
+ capability["access_mode"] = normalize_access_mode(access_mode)
133
+ if line is not None:
134
+ try:
135
+ capability["line"] = int(line)
136
+ except (TypeError, ValueError):
137
+ capability["line"] = 0
138
+ return capability
139
+
140
+
141
+ def _capability_text(capability):
142
+ parts = [
143
+ capability.get("name"),
144
+ capability.get("category"),
145
+ capability.get("resolved_definition"),
146
+ ]
147
+ evidence = capability.get("evidence")
148
+ if isinstance(evidence, (list, tuple)):
149
+ parts.extend(str(item) for item in evidence)
150
+ else:
151
+ parts.append(evidence)
152
+ return " ".join(str(p) for p in parts if p).lower()
153
+
154
+
155
+ def infer_access_mode(capability):
156
+ """Conservatively infer an access mode for a capability that lacks one.
157
+
158
+ Adapters that have not yet been taught access modes still need a value
159
+ so the diff can compare like with like. The heuristic reads only the
160
+ capability's own name, category and evidence text.
161
+
162
+ When the evidence carries no directional signal at all the function
163
+ returns ``"read"`` (the least-alarming defensible value) and sets
164
+ ``access_mode_inferred = True`` on the capability. Downstream,
165
+ escalations derived from an inferred value are capped below
166
+ ``critical`` — a guess must never be presented as a certainty.
167
+ """
168
+ if not isinstance(capability, dict):
169
+ return "read"
170
+
171
+ explicit = capability.get("access_mode")
172
+ if explicit:
173
+ return normalize_access_mode(explicit)
174
+
175
+ category = str(capability.get("category") or "").strip().lower()
176
+ name = str(capability.get("name") or "").strip().lower()
177
+ text = _capability_text(capability)
178
+
179
+ capability["access_mode_inferred"] = True
180
+ if category in _NONE_CATEGORIES or name in _NONE_CATEGORIES:
181
+ return "none"
182
+ if category in _EXECUTE_CATEGORIES or name in _EXECUTE_CATEGORIES:
183
+ return "execute"
184
+ if any(term in text for term in _EXECUTE_TERMS):
185
+ return "execute"
186
+ if any(term in text for term in _MUTATE_TERMS):
187
+ return "mutate"
188
+ if any(re.search(rf"\b{re.escape(term)}", text) for term in _WRITE_TERMS):
189
+ return "write"
190
+ if any(term in text for term in _READONLY_TERMS):
191
+ return "read"
192
+ return "read"
193
+
194
+
195
+ def resolve_access_mode(capability):
196
+ """Return ``capability``'s access mode, inferring and recording it if absent.
197
+
198
+ Mutates the capability in place so the inference is visible to every
199
+ later stage (diff, escalation, assurance boundary).
200
+ """
201
+ if not isinstance(capability, dict):
202
+ return "read"
203
+ if capability.get("access_mode"):
204
+ capability["access_mode"] = normalize_access_mode(capability["access_mode"])
205
+ capability.setdefault("access_mode_inferred", False)
206
+ return capability["access_mode"]
207
+ mode = infer_access_mode(capability)
208
+ capability["access_mode"] = mode
209
+ capability.setdefault("access_mode_inferred", False)
210
+ return mode
211
+
212
+
213
+ def dedupe_capabilities(caps):
214
+ """Remove duplicate capabilities within a single parser result.
215
+
216
+ Keyed by name, category, framework, resolved definition and — since
217
+ v1.4 — the owning tool and the access mode, so that the same
218
+ capability held by two different tools is no longer collapsed.
219
+ """
220
+ from safeai.analysis.tool_identity import tool_key
221
+
222
+ out = []
223
+ seen = set()
224
+ for c in caps:
225
+ identity = c.get("tool_identity")
226
+ key = (
227
+ c.get("name"),
228
+ c.get("category"),
229
+ c.get("source_framework"),
230
+ c.get("resolved_definition"),
231
+ tool_key(identity) if identity else None,
232
+ c.get("access_mode"),
233
+ )
234
+ if key in seen:
235
+ continue
236
+ seen.add(key)
237
+ out.append(c)
238
+ return out