securevector-sdk-crewai 1.2.1__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {securevector_sdk_crewai-1.2.1/src/securevector_sdk_crewai.egg-info → securevector_sdk_crewai-1.2.2}/PKG-INFO +1 -1
  2. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/pyproject.toml +1 -1
  3. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/__init__.py +2 -0
  4. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/_version.py +1 -1
  5. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/client.py +25 -8
  6. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/config.py +7 -0
  7. securevector_sdk_crewai-1.2.2/src/securevector_sdk_crewai/tool_id.py +77 -0
  8. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2/src/securevector_sdk_crewai.egg-info}/PKG-INFO +1 -1
  9. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai.egg-info/SOURCES.txt +1 -0
  10. securevector_sdk_crewai-1.2.2/tests/test_auth.py +57 -0
  11. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/tests/test_tool_id.py +22 -1
  12. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/tests/test_verdict.py +35 -0
  13. securevector_sdk_crewai-1.2.1/src/securevector_sdk_crewai/tool_id.py +0 -40
  14. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/LICENSE +0 -0
  15. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/NOTICE +0 -0
  16. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/README.md +0 -0
  17. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/setup.cfg +0 -0
  18. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/auto.py +0 -0
  19. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/core.py +0 -0
  20. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/costs.py +0 -0
  21. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/errors.py +0 -0
  22. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai/wrapper.py +0 -0
  23. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai.egg-info/dependency_links.txt +0 -0
  24. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai.egg-info/requires.txt +0 -0
  25. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/src/securevector_sdk_crewai.egg-info/top_level.txt +0 -0
  26. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/tests/test_analyze.py +0 -0
  27. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/tests/test_costs.py +0 -0
  28. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/tests/test_engine_endpoint.py +0 -0
  29. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/tests/test_interceptor.py +0 -0
  30. {securevector_sdk_crewai-1.2.1 → securevector_sdk_crewai-1.2.2}/tests/test_wrapper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: securevector-sdk-crewai
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: SecureVector SDK for CrewAI — brings the local threat monitor's three controls (tool-call permissions, secret/data-leak detection, threat detection) to every CrewAI tool call, with tamper-evident audit logging.
5
5
  Author: SecureVector
6
6
  License: Apache-2.0
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
  # `crewai-*` prefix that could imply an official CrewAI integration. See README
9
9
  # "Trademarks".
10
10
  name = "securevector-sdk-crewai"
11
- version = "1.2.1"
11
+ version = "1.2.2"
12
12
  description = "SecureVector SDK for CrewAI — brings the local threat monitor's three controls (tool-call permissions, secret/data-leak detection, threat detection) to every CrewAI tool call, with tamper-evident audit logging."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.10"
@@ -37,6 +37,7 @@ from .config import Config
37
37
  from .costs import CostTracker, install_kickoff_tracking, track_crew_usage
38
38
  from .errors import AppUnreachable, SecureVectorError, ToolBlocked
39
39
  from .wrapper import SecureCrew
40
+ from .tool_id import candidate_tool_ids
40
41
 
41
42
  log = logging.getLogger("securevector_sdk_crewai")
42
43
 
@@ -48,6 +49,7 @@ __all__ = [
48
49
  "track_crew_usage",
49
50
  "SecureCrew",
50
51
  "Config",
52
+ "candidate_tool_ids",
51
53
  "SecureVectorError",
52
54
  "ToolBlocked",
53
55
  "AppUnreachable",
@@ -4,4 +4,4 @@ The published version is stamped from pyproject.toml / the release tag by CI;
4
4
  this constant is what `securevector_sdk_crewai.__version__` reports.
5
5
  """
6
6
 
7
- __version__ = "1.2.1"
7
+ __version__ = "1.2.2"
@@ -26,7 +26,7 @@ from typing import Any, Dict, List, Optional
26
26
 
27
27
  from .config import Config
28
28
  from .errors import AppUnreachable
29
- from .tool_id import RUNTIME_KIND
29
+ from .tool_id import RUNTIME_KIND, candidate_tool_ids
30
30
 
31
31
  log = logging.getLogger("securevector_sdk_crewai")
32
32
 
@@ -63,11 +63,16 @@ class LocalAppClient:
63
63
  def _request(self, method: str, path: str, body: Optional[dict]) -> Any:
64
64
  url = f"{self.cfg.base_url.rstrip('/')}{path}"
65
65
  data = json.dumps(body).encode("utf-8") if body is not None else None
66
+ headers = {"Content-Type": "application/json"}
67
+ # Forward a credential to remote, token-gated deployments (no-op for the
68
+ # default loopback app, which has no inbound auth).
69
+ if getattr(self.cfg, "api_key", ""):
70
+ headers["Authorization"] = f"Bearer {self.cfg.api_key}"
66
71
  req = urllib.request.Request(
67
72
  url,
68
73
  data=data,
69
74
  method=method,
70
- headers={"Content-Type": "application/json"},
75
+ headers=headers,
71
76
  )
72
77
  timeout = max(self.cfg.timeout_ms / 1000.0, 0.1)
73
78
  with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (localhost)
@@ -117,15 +122,27 @@ class LocalAppClient:
117
122
  out[str(k)] = item
118
123
  return out
119
124
 
125
+ @staticmethod
126
+ def _lookup(index: Dict[str, dict], candidates: List[str]) -> Optional[dict]:
127
+ """First candidate that matches (exact casing, then lowercased)."""
128
+ for cand in candidates:
129
+ hit = index.get(cand) or index.get(cand.lower())
130
+ if hit:
131
+ return hit
132
+ return None
133
+
120
134
  def _resolve(self, tool_id, essential, overrides, synced) -> Verdict:
121
135
  name = tool_id
122
- low = tool_id.lower()
136
+ # Tier precedence dominates candidate specificity: a synced rule that
137
+ # matches ANY candidate beats an override that matches the raw name.
138
+ candidates = candidate_tool_ids(tool_id)
123
139
  emap = self._index(essential.get("tools"), "tool_id")
124
140
  omap = self._index(overrides.get("overrides"), "tool_id")
125
141
  smap = self._index(synced.get("synced"), "tool_id")
142
+ in_essential = self._lookup(emap, candidates) is not None
126
143
 
127
144
  # 1. Cloud-pushed synced policy wins.
128
- s = smap.get(name) or smap.get(low)
145
+ s = self._lookup(smap, candidates)
129
146
  if s:
130
147
  effect = str(s.get("effect", "")).lower()
131
148
  action = "allow" if effect == "allow" else "block"
@@ -133,18 +150,18 @@ class LocalAppClient:
133
150
  ver = f" v{s['policy_version']}" if s.get("policy_version") is not None else ""
134
151
  return Verdict(
135
152
  action, "synced", f"Synced policy '{policy}'{ver}: {effect}",
136
- (name in emap or low in emap), name,
153
+ in_essential, name,
137
154
  )
138
155
  # 2. Local user override.
139
- o = omap.get(name) or omap.get(low)
156
+ o = self._lookup(omap, candidates)
140
157
  if o:
141
158
  return Verdict(
142
159
  o.get("action", "allow"), "overridden",
143
160
  f"User override: {o.get('action')}",
144
- (name in emap or low in emap), name,
161
+ in_essential, name,
145
162
  )
146
163
  # 3. Essential registry default.
147
- e = emap.get(name) or emap.get(low)
164
+ e = self._lookup(emap, candidates)
148
165
  if e:
149
166
  return Verdict(
150
167
  e.get("effective_action") or e.get("default_action") or "allow",
@@ -17,6 +17,11 @@ Environment variables (all optional):
17
17
  SECUREVECTOR_SDK_AGENT_ID agent id for Cost Tracking attribution
18
18
  (default "<runtime>-agent")
19
19
  SECUREVECTOR_SDK_DISABLED set truthy to no-op entirely
20
+ SECUREVECTOR_API_KEY credential forwarded to the app as
21
+ Authorization: Bearer — required when the
22
+ app is a remote, token-gated deployment
23
+ (e.g. the Terraform self-host modules);
24
+ unused for the default loopback app
20
25
 
21
26
  Note: SECUREVECTOR_ENGINE_ENDPOINT (and its legacy alias
22
27
  SECUREVECTOR_SDK_APP_URL) address the *engine* — the local app or a self-host
@@ -43,6 +48,7 @@ class Config:
43
48
  analyze_mode: str = "local" # SecureVectorClient mode used for /analyze
44
49
  agent_id: str = "" # Cost Tracking attribution ("" → "<runtime>-agent")
45
50
  enabled: bool = True
51
+ api_key: str = "" # forwarded as Authorization: Bearer to the app
46
52
 
47
53
  @classmethod
48
54
  def from_env(cls, **overrides) -> "Config":
@@ -57,6 +63,7 @@ class Config:
57
63
  analyze_mode=os.environ.get("SECUREVECTOR_SDK_ANALYZE_MODE", "local"),
58
64
  agent_id=os.environ.get("SECUREVECTOR_SDK_AGENT_ID", ""),
59
65
  enabled=not _truthy(os.environ.get("SECUREVECTOR_SDK_DISABLED", "")),
66
+ api_key=os.environ.get("SECUREVECTOR_API_KEY", ""),
60
67
  )
61
68
  # Explicit kwargs win over env, but only when actually provided.
62
69
  for key, value in overrides.items():
@@ -0,0 +1,77 @@
1
+ """Canonical tool-id normalization — the ONLY framework-specific mapping.
2
+
3
+ The whole SecureVector fleet keys permissions and audit on a single canonical
4
+ ``tool_id``. CrewAI surfaces a tool's name as ``BaseTool.name`` (passed here as
5
+ the flat ``name`` argument). Normalizing it here is the one piece of
6
+ CrewAI-specific code; every downstream step (permission resolution, analysis,
7
+ audit) is shared engine behaviour, so a policy authored once — "allow
8
+ web_search, block shell" — applies identically across LangChain / LangGraph /
9
+ CrewAI.
10
+
11
+ Casing is preserved: the local app matches tool ids case-insensitively, so a
12
+ rule authored ``tool_id="Bash"`` still governs a CrewAI tool named ``bash``.
13
+
14
+ MCP tools commonly surface with a sanitized flat name (``mcp_<server>_<tool>``,
15
+ hyphens/dots collapsed to underscores). The sanitization is lossy, so a single
16
+ function name cannot be split back into ``server``/``tool`` unambiguously.
17
+ ``candidate_tool_ids`` therefore emits every plausible ``<server>:<tool>``
18
+ split (most-specific first) so rules authored against the cloud
19
+ ``<server>:<tool>`` form, the bare tool name, or the raw function name all
20
+ match — identical behaviour across the sibling SDKs.
21
+ """
22
+
23
+ from typing import Any, List, Optional
24
+
25
+ # The audit/Bill-of-Tools/OCSF pipeline groups by this attribution tag.
26
+ RUNTIME_KIND = "crewai"
27
+
28
+ _MCP_PREFIX = "mcp_"
29
+
30
+
31
+ def normalize_tool_id(serialized: Any, name: Optional[str] = None) -> str:
32
+ """Resolve a canonical tool id.
33
+
34
+ Accepts a flat ``name`` (CrewAI ``BaseTool.name``) and/or a ``serialized``
35
+ dict (``{"name": ...}`` or a dotted ``{"id": [...]}`` path) for symmetry
36
+ with the other SDKs. Falls back to ``"unknown"`` so a missing name never
37
+ crashes the agent.
38
+ """
39
+ raw: Optional[str] = None
40
+ if isinstance(serialized, dict):
41
+ raw = serialized.get("name")
42
+ if not raw:
43
+ ident = serialized.get("id")
44
+ if isinstance(ident, (list, tuple)) and ident:
45
+ raw = str(ident[-1])
46
+ if not raw:
47
+ raw = name
48
+ if not raw:
49
+ return "unknown"
50
+ return str(raw).strip() or "unknown"
51
+
52
+
53
+ def candidate_tool_ids(tool_id: str) -> List[str]:
54
+ """Expand one tool name into every rule key it should match.
55
+
56
+ Ordered most-specific first (the caller resolves tier-first, then takes
57
+ the first candidate that matches within a tier):
58
+
59
+ * the raw tool name itself (``mcp_my_api_list_items`` or ``terminal``);
60
+ * for MCP names, every ``<server>:<tool>`` split of the sanitized
61
+ remainder — the cloud policy form. The split point is ambiguous after
62
+ sanitization, so all splits are emitted; the app also aliases the bare
63
+ tool suffix of each ``<server>:<tool>`` key server-side.
64
+ """
65
+ # The caller passes an already-normalized name (normalize_tool_id here
66
+ # takes the framework payload, not a bare string — unlike the hermes SDK).
67
+ tid = str(tool_id or "").strip() or "unknown"
68
+ candidates = [tid]
69
+ if tid.lower().startswith(_MCP_PREFIX):
70
+ rest = tid[len(_MCP_PREFIX):]
71
+ parts = rest.split("_")
72
+ for i in range(1, len(parts)):
73
+ server = "_".join(parts[:i])
74
+ tool = "_".join(parts[i:])
75
+ if server and tool:
76
+ candidates.append(f"{server}:{tool}")
77
+ return candidates
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: securevector-sdk-crewai
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: SecureVector SDK for CrewAI — brings the local threat monitor's three controls (tool-call permissions, secret/data-leak detection, threat detection) to every CrewAI tool call, with tamper-evident audit logging.
5
5
  Author: SecureVector
6
6
  License: Apache-2.0
@@ -18,6 +18,7 @@ src/securevector_sdk_crewai.egg-info/dependency_links.txt
18
18
  src/securevector_sdk_crewai.egg-info/requires.txt
19
19
  src/securevector_sdk_crewai.egg-info/top_level.txt
20
20
  tests/test_analyze.py
21
+ tests/test_auth.py
21
22
  tests/test_costs.py
22
23
  tests/test_engine_endpoint.py
23
24
  tests/test_interceptor.py
@@ -0,0 +1,57 @@
1
+ """Auth-forwarding: SECUREVECTOR_API_KEY -> Authorization: Bearer.
2
+
3
+ The SDK talks to the local app over loopback by default (no auth). When the app
4
+ is a remote, token-gated deployment (e.g. the Terraform self-host modules), the
5
+ client forwards SECUREVECTOR_API_KEY as a Bearer credential. These tests pin
6
+ that contract without needing a running app.
7
+ """
8
+
9
+ import urllib.request
10
+
11
+ from securevector_sdk_crewai.client import LocalAppClient
12
+ from securevector_sdk_crewai.config import Config
13
+
14
+
15
+ class _FakeResp:
16
+ def __init__(self, payload=b"{}"):
17
+ self._payload = payload
18
+
19
+ def read(self):
20
+ return self._payload
21
+
22
+ def __enter__(self):
23
+ return self
24
+
25
+ def __exit__(self, *exc):
26
+ return False
27
+
28
+
29
+ def _capture_headers(monkeypatch):
30
+ captured = {}
31
+
32
+ def fake_urlopen(req, timeout=None):
33
+ captured.update({k.lower(): v for k, v in req.header_items()})
34
+ return _FakeResp()
35
+
36
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
37
+ return captured
38
+
39
+
40
+ def test_api_key_forwarded_as_bearer(monkeypatch):
41
+ headers = _capture_headers(monkeypatch)
42
+ LocalAppClient(Config.from_env(api_key="svpk_explicit"))._get("/health")
43
+ assert headers.get("authorization") == "Bearer svpk_explicit"
44
+
45
+
46
+ def test_api_key_read_from_env(monkeypatch):
47
+ monkeypatch.setenv("SECUREVECTOR_API_KEY", "svpk_fromenv")
48
+ headers = _capture_headers(monkeypatch)
49
+ LocalAppClient(Config.from_env())._get("/health")
50
+ assert headers.get("authorization") == "Bearer svpk_fromenv"
51
+
52
+
53
+ def test_no_auth_header_when_unset(monkeypatch):
54
+ monkeypatch.delenv("SECUREVECTOR_API_KEY", raising=False)
55
+ headers = _capture_headers(monkeypatch)
56
+ LocalAppClient(Config.from_env(api_key=""))._get("/health")
57
+ assert "authorization" not in headers
@@ -1,4 +1,8 @@
1
- from securevector_sdk_crewai.tool_id import RUNTIME_KIND, normalize_tool_id
1
+ from securevector_sdk_crewai.tool_id import (
2
+ RUNTIME_KIND,
3
+ candidate_tool_ids,
4
+ normalize_tool_id,
5
+ )
2
6
 
3
7
 
4
8
  def test_runtime_kind_is_langchain():
@@ -25,3 +29,20 @@ def test_missing_everything_is_unknown():
25
29
  assert normalize_tool_id(None) == "unknown"
26
30
  assert normalize_tool_id({}) == "unknown"
27
31
  assert normalize_tool_id({"name": " "}) == "unknown"
32
+
33
+
34
+ def test_non_mcp_candidates_are_just_the_name():
35
+ assert candidate_tool_ids("terminal") == ["terminal"]
36
+
37
+
38
+ def test_mcp_candidates_emit_every_server_tool_split():
39
+ cands = candidate_tool_ids("mcp_my_api_list_items")
40
+ assert cands[0] == "mcp_my_api_list_items" # raw name is most specific
41
+ assert "my:api_list_items" in cands
42
+ assert "my_api:list_items" in cands
43
+ assert "my_api_list:items" in cands
44
+ assert len(cands) == 4
45
+
46
+
47
+ def test_mcp_single_segment_has_no_split():
48
+ assert candidate_tool_ids("mcp_solo") == ["mcp_solo"]
@@ -54,3 +54,38 @@ def test_synced_non_allow_effect_falls_back_to_block():
54
54
  synced = {"synced": [{"tool_id": "x", "effect": "warn"}]}
55
55
  v = _client()._resolve("x", {}, {}, synced)
56
56
  assert v.action == "block" # engine has no warn action → safer block
57
+
58
+
59
+ # --- MCP candidate matching ---------------------------------------------- #
60
+ # mcp_<server>_<tool> is lossy; a rule authored in the cloud <server>:<tool>
61
+ # form must still govern the call.
62
+
63
+ def test_mcp_call_matches_cloud_server_tool_rule():
64
+ synced = {"synced": [{"tool_id": "github:create_issue", "effect": "block", "policy_name": "corp"}]}
65
+ v = _client()._resolve("mcp_github_create_issue", {}, {}, synced)
66
+ assert v.action == "block"
67
+ assert v.risk == "synced"
68
+
69
+
70
+ def test_mcp_call_matches_raw_function_name_rule():
71
+ overrides = {"overrides": [{"tool_id": "mcp_github_create_issue", "action": "block"}]}
72
+ v = _client()._resolve("mcp_github_create_issue", {}, overrides, {})
73
+ assert v.action == "block"
74
+
75
+
76
+ def test_mcp_ambiguous_split_still_matches_underscored_server():
77
+ # server "my-api" sanitized to my_api: rule my_api:list_items must match
78
+ # mcp_my_api_list_items even though the split point is ambiguous.
79
+ synced = {"synced": [{"tool_id": "my_api:list_items", "effect": "block"}]}
80
+ v = _client()._resolve("mcp_my_api_list_items", {}, {}, synced)
81
+ assert v.action == "block"
82
+
83
+
84
+ def test_tier_precedence_dominates_candidate_specificity():
85
+ # A synced rule matching a LESS specific candidate still beats an
86
+ # override matching the raw name.
87
+ overrides = {"overrides": [{"tool_id": "mcp_github_create_issue", "action": "allow"}]}
88
+ synced = {"synced": [{"tool_id": "github:create_issue", "effect": "block"}]}
89
+ v = _client()._resolve("mcp_github_create_issue", {}, overrides, synced)
90
+ assert v.action == "block"
91
+ assert v.risk == "synced"
@@ -1,40 +0,0 @@
1
- """Canonical tool-id normalization — the ONLY framework-specific mapping.
2
-
3
- The whole SecureVector fleet keys permissions and audit on a single canonical
4
- ``tool_id``. CrewAI surfaces a tool's name as ``BaseTool.name`` (passed here as
5
- the flat ``name`` argument). Normalizing it here is the one piece of
6
- CrewAI-specific code; every downstream step (permission resolution, analysis,
7
- audit) is shared engine behaviour, so a policy authored once — "allow
8
- web_search, block shell" — applies identically across LangChain / LangGraph /
9
- CrewAI.
10
-
11
- Casing is preserved: the local app matches tool ids case-insensitively, so a
12
- rule authored ``tool_id="Bash"`` still governs a CrewAI tool named ``bash``.
13
- """
14
-
15
- from typing import Any, Optional
16
-
17
- # The audit/Bill-of-Tools/OCSF pipeline groups by this attribution tag.
18
- RUNTIME_KIND = "crewai"
19
-
20
-
21
- def normalize_tool_id(serialized: Any, name: Optional[str] = None) -> str:
22
- """Resolve a canonical tool id.
23
-
24
- Accepts a flat ``name`` (CrewAI ``BaseTool.name``) and/or a ``serialized``
25
- dict (``{"name": ...}`` or a dotted ``{"id": [...]}`` path) for symmetry
26
- with the other SDKs. Falls back to ``"unknown"`` so a missing name never
27
- crashes the agent.
28
- """
29
- raw: Optional[str] = None
30
- if isinstance(serialized, dict):
31
- raw = serialized.get("name")
32
- if not raw:
33
- ident = serialized.get("id")
34
- if isinstance(ident, (list, tuple)) and ident:
35
- raw = str(ident[-1])
36
- if not raw:
37
- raw = name
38
- if not raw:
39
- return "unknown"
40
- return str(raw).strip() or "unknown"