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.
- safeai/__init__.py +3 -0
- safeai/__main__.py +8 -0
- safeai/analysis/__init__.py +0 -0
- safeai/analysis/aggregation.py +205 -0
- safeai/analysis/capabilities.py +238 -0
- safeai/analysis/capability_diff.py +261 -0
- safeai/analysis/components.py +357 -0
- safeai/analysis/dependency_correlation.py +253 -0
- safeai/analysis/escalation.py +527 -0
- safeai/analysis/import_graph.py +105 -0
- safeai/analysis/project_graph.py +53 -0
- safeai/analysis/semantic.py +200 -0
- safeai/analysis/tool_identity.py +136 -0
- safeai/analysis/tool_surface.py +248 -0
- safeai/analyzers/__init__.py +0 -0
- safeai/analyzers/capability/__init__.py +0 -0
- safeai/analyzers/capability/analyzer.py +262 -0
- safeai/analyzers/claude_code/__init__.py +1 -0
- safeai/analyzers/claude_code/analyzer.py +410 -0
- safeai/analyzers/data_leakage/__init__.py +0 -0
- safeai/analyzers/data_leakage/analyzer.py +68 -0
- safeai/analyzers/env_dependency/__init__.py +10 -0
- safeai/analyzers/env_dependency/analyzer.py +202 -0
- safeai/analyzers/mcp/__init__.py +0 -0
- safeai/analyzers/mcp/analyzer.py +413 -0
- safeai/analyzers/mcp/compatibility.py +58 -0
- safeai/analyzers/mcp/schema.py +31 -0
- safeai/analyzers/mcp/validators.py +59 -0
- safeai/analyzers/model_config/__init__.py +0 -0
- safeai/analyzers/model_config/analyzer.py +196 -0
- safeai/analyzers/prompt/__init__.py +0 -0
- safeai/analyzers/prompt/analyzer.py +114 -0
- safeai/analyzers/prompt_file/__init__.py +0 -0
- safeai/analyzers/prompt_file/analyzer.py +132 -0
- safeai/analyzers/skill/__init__.py +0 -0
- safeai/analyzers/skill/analyzer.py +183 -0
- safeai/analyzers/tool_def/__init__.py +0 -0
- safeai/analyzers/tool_def/analyzer.py +177 -0
- safeai/analyzers/workflow/__init__.py +0 -0
- safeai/analyzers/workflow/analyzer.py +134 -0
- safeai/cmd/__init__.py +0 -0
- safeai/cmd/cli.py +159 -0
- safeai/cmd/postprocess.py +351 -0
- safeai/cmd/registry_cli.py +352 -0
- safeai/engine/__init__.py +0 -0
- safeai/engine/orchestrator.py +564 -0
- safeai/engine/scan.py +28 -0
- safeai/frameworks/__init__.py +79 -0
- safeai/frameworks/azure_foundry/__init__.py +0 -0
- safeai/frameworks/azure_foundry/parser.py +60 -0
- safeai/frameworks/bedrock_agent/__init__.py +0 -0
- safeai/frameworks/bedrock_agent/parser.py +33 -0
- safeai/frameworks/claude_code/__init__.py +0 -0
- safeai/frameworks/claude_code/commands.py +158 -0
- safeai/frameworks/claude_code/parser.py +274 -0
- safeai/frameworks/claude_code/permissions.py +270 -0
- safeai/frameworks/claude_code/settings.py +221 -0
- safeai/frameworks/crewai/__init__.py +0 -0
- safeai/frameworks/crewai/parser.py +118 -0
- safeai/frameworks/dify/__init__.py +0 -0
- safeai/frameworks/dify/parser.py +111 -0
- safeai/frameworks/google_adk/__init__.py +0 -0
- safeai/frameworks/google_adk/parser.py +86 -0
- safeai/frameworks/haystack/__init__.py +0 -0
- safeai/frameworks/haystack/parser.py +91 -0
- safeai/frameworks/langchain/__init__.py +0 -0
- safeai/frameworks/langchain/parser.py +116 -0
- safeai/frameworks/langgraph/__init__.py +0 -0
- safeai/frameworks/langgraph/parser.py +116 -0
- safeai/frameworks/llamaindex/__init__.py +0 -0
- safeai/frameworks/llamaindex/parser.py +85 -0
- safeai/frameworks/mastra/__init__.py +0 -0
- safeai/frameworks/mastra/parser.py +94 -0
- safeai/frameworks/microsoft_agent/__init__.py +0 -0
- safeai/frameworks/microsoft_agent/parser.py +124 -0
- safeai/frameworks/n8n/__init__.py +0 -0
- safeai/frameworks/n8n/parser.py +127 -0
- safeai/frameworks/openai_agents/__init__.py +0 -0
- safeai/frameworks/openai_agents/parser.py +117 -0
- safeai/frameworks/semantic_kernel/__init__.py +0 -0
- safeai/frameworks/semantic_kernel/parser.py +111 -0
- safeai/kya/__init__.py +19 -0
- safeai/kya/assurance.py +169 -0
- safeai/kya/baseline.py +92 -0
- safeai/kya/ci_context.py +181 -0
- safeai/kya/enrich.py +190 -0
- safeai/kya/exporter.py +86 -0
- safeai/kya/fingerprints.py +70 -0
- safeai/kya/identity.py +145 -0
- safeai/kya/manifest.py +192 -0
- safeai/kya/policy.py +236 -0
- safeai/kya/registry/__init__.py +80 -0
- safeai/kya/registry/connection.py +119 -0
- safeai/kya/registry/persist.py +354 -0
- safeai/kya/registry/queries.py +249 -0
- safeai/kya/registry/schema.py +147 -0
- safeai/kya/suppressions.py +169 -0
- safeai/kya/util.py +129 -0
- safeai/report/__init__.py +0 -0
- safeai/report/html.py +358 -0
- safeai/report/html_kit.py +294 -0
- safeai/report/json_report.py +12 -0
- safeai/report/pr_comment.py +295 -0
- safeai/report/registry_html.py +283 -0
- safeai/report/sarif.py +84 -0
- safeai/report/terminal.py +120 -0
- safeai/rules/__init__.py +0 -0
- safeai/rules/base_rules.yaml +294 -0
- safeai/rules/loader.py +32 -0
- safeai/scoring/__init__.py +0 -0
- safeai/scoring/engine.py +84 -0
- safeai/severity.py +34 -0
- safeai_static_analyzer-1.4.0b0.dist-info/METADATA +540 -0
- safeai_static_analyzer-1.4.0b0.dist-info/RECORD +118 -0
- safeai_static_analyzer-1.4.0b0.dist-info/WHEEL +5 -0
- safeai_static_analyzer-1.4.0b0.dist-info/entry_points.txt +2 -0
- safeai_static_analyzer-1.4.0b0.dist-info/licenses/LICENSE +201 -0
- safeai_static_analyzer-1.4.0b0.dist-info/top_level.txt +1 -0
safeai/__init__.py
ADDED
safeai/__main__.py
ADDED
|
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
|