werktools 0.1.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.
- werktools/__init__.py +3 -0
- werktools/__main__.py +16 -0
- werktools/catalog.py +223 -0
- werktools/classify.py +179 -0
- werktools/cli.py +2090 -0
- werktools/envelope.py +42 -0
- werktools/hub/__init__.py +2 -0
- werktools/hub/allowlist.py +318 -0
- werktools/hub/approvals.py +461 -0
- werktools/hub/capabilities.py +38 -0
- werktools/hub/capability_select.py +135 -0
- werktools/hub/contracts.py +474 -0
- werktools/hub/dashboard.py +1467 -0
- werktools/hub/dashboard_fonts.py +16 -0
- werktools/hub/diagnose.py +120 -0
- werktools/hub/discovery.py +367 -0
- werktools/hub/export_rules.py +246 -0
- werktools/hub/invariants.py +176 -0
- werktools/hub/ledger.py +64 -0
- werktools/hub/lifecycle.py +658 -0
- werktools/hub/onboarding.py +505 -0
- werktools/hub/policy.py +146 -0
- werktools/hub/pool.py +138 -0
- werktools/hub/registry.py +215 -0
- werktools/hub/registry_db.py +289 -0
- werktools/hub/registry_seed.json +1592 -0
- werktools/hub/relay.py +289 -0
- werktools/hub/render.py +139 -0
- werktools/hub/runtime_row.py +36 -0
- werktools/hub/runtimes.py +376 -0
- werktools/hub/server.py +654 -0
- werktools/hub/status.py +111 -0
- werktools/hub/store_bridge.py +87 -0
- werktools/hub/workers.py +323 -0
- werktools/ledger.py +167 -0
- werktools/policy.py +102 -0
- werktools/profile.py +104 -0
- werktools/redaction.py +107 -0
- werktools/server.py +123 -0
- werktools/tools/__init__.py +2 -0
- werktools/tools/audit.py +186 -0
- werktools/tools/bench.py +1205 -0
- werktools/tools/canon.py +242 -0
- werktools/tools/cost.py +267 -0
- werktools/tools/data_gate.py +392 -0
- werktools/tools/eval.py +124 -0
- werktools/tools/integration_gate.py +318 -0
- werktools/tools/mine.py +255 -0
- werktools/tools/skills.py +73 -0
- werktools/tools/skills_discover.py +322 -0
- werktools/tools/swarm.py +257 -0
- werktools/tools/trace.py +227 -0
- werktools/tools/truth.py +274 -0
- werktools/tools/vault.py +388 -0
- werktools-0.1.0.dist-info/METADATA +421 -0
- werktools-0.1.0.dist-info/RECORD +59 -0
- werktools-0.1.0.dist-info/WHEEL +4 -0
- werktools-0.1.0.dist-info/entry_points.txt +3 -0
- werktools-0.1.0.dist-info/licenses/LICENSE +661 -0
werktools/__init__.py
ADDED
werktools/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""werktools-swarm entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .cli import main as cli_main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
12
|
+
return cli_main(["swarm", *args])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
raise SystemExit(main())
|
werktools/catalog.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Generic local catalog primitive for Wave-5 catalog tools.
|
|
2
|
+
|
|
3
|
+
A catalog is a directory of cards. Cards are plain JSON files or Markdown
|
|
4
|
+
files with a small `Key: value` header block. Everything here is local,
|
|
5
|
+
deterministic, and read-only: catalogs describe assets, they never run them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import warnings
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
_HEADER_PATTERN = re.compile(r"^(?P<key>[A-Za-z][A-Za-z _-]*):\s*(?P<value>.*)$")
|
|
18
|
+
_RISK_VALUES = ("read", "write", "destructive", "external", "secret", "unknown")
|
|
19
|
+
|
|
20
|
+
# Trust taxonomy (metadata only — never an enforcement decision in P1).
|
|
21
|
+
# Ordered most- to least-trusted; the default and every unknown value fail
|
|
22
|
+
# closed to the lowest tier.
|
|
23
|
+
TRUST_TIERS = ("Official", "Security-Scanned", "Community-Unverified")
|
|
24
|
+
DEFAULT_TRUST_TIER = "Community-Unverified"
|
|
25
|
+
_TRUST_SOURCE_MAX = 64
|
|
26
|
+
_TRUST_NOTE_MAX = 200
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_trust_tier(value: Any) -> str:
|
|
30
|
+
"""Return a known trust tier, failing closed to Community-Unverified."""
|
|
31
|
+
text = str(value).strip() if value is not None else ""
|
|
32
|
+
return text if text in TRUST_TIERS else DEFAULT_TRUST_TIER
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CatalogCard:
|
|
37
|
+
"""One described local asset (skill, connector, hook, ...)."""
|
|
38
|
+
|
|
39
|
+
card_id: str
|
|
40
|
+
kind: str
|
|
41
|
+
title: str
|
|
42
|
+
summary: str
|
|
43
|
+
tags: tuple[str, ...]
|
|
44
|
+
profiles: tuple[str, ...]
|
|
45
|
+
source: str
|
|
46
|
+
risk: str
|
|
47
|
+
requires_approval: bool
|
|
48
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
created_at: str = ""
|
|
50
|
+
trust_tier: str = DEFAULT_TRUST_TIER
|
|
51
|
+
trust_source: str = ""
|
|
52
|
+
trust_note: str = ""
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, raw: dict[str, Any]) -> "CatalogCard":
|
|
56
|
+
metadata = raw.get("metadata", {})
|
|
57
|
+
return cls(
|
|
58
|
+
card_id=str(raw["card_id"]),
|
|
59
|
+
kind=str(raw.get("kind", "card")),
|
|
60
|
+
title=str(raw.get("title", raw["card_id"])),
|
|
61
|
+
summary=str(raw.get("summary", "")),
|
|
62
|
+
tags=tuple(str(item) for item in raw.get("tags", ())),
|
|
63
|
+
# Missing profiles means public; an EXPLICIT empty list means
|
|
64
|
+
# visible to nobody and must not silently widen to public.
|
|
65
|
+
profiles=tuple(str(item) for item in raw.get("profiles", ("*",))),
|
|
66
|
+
source=str(raw.get("source", "")),
|
|
67
|
+
risk=str(raw.get("risk", "unknown")),
|
|
68
|
+
requires_approval=bool(raw.get("requires_approval", False)),
|
|
69
|
+
metadata=dict(metadata) if isinstance(metadata, dict) else {},
|
|
70
|
+
created_at=str(raw.get("created_at", "")),
|
|
71
|
+
trust_tier=normalize_trust_tier(raw.get("trust_tier")),
|
|
72
|
+
trust_source=str(raw.get("trust_source", ""))[:_TRUST_SOURCE_MAX],
|
|
73
|
+
trust_note=str(raw.get("trust_note", ""))[:_TRUST_NOTE_MAX],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"card_id": self.card_id,
|
|
79
|
+
"kind": self.kind,
|
|
80
|
+
"title": self.title,
|
|
81
|
+
"summary": self.summary,
|
|
82
|
+
"tags": list(self.tags),
|
|
83
|
+
"profiles": list(self.profiles),
|
|
84
|
+
"source": self.source,
|
|
85
|
+
"risk": self.risk,
|
|
86
|
+
"requires_approval": self.requires_approval,
|
|
87
|
+
"metadata": self.metadata,
|
|
88
|
+
"created_at": self.created_at,
|
|
89
|
+
"trust_tier": self.trust_tier,
|
|
90
|
+
"trust_source": self.trust_source,
|
|
91
|
+
"trust_note": self.trust_note,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _csv(value: str) -> tuple[str, ...]:
|
|
96
|
+
return tuple(part.strip() for part in value.split(",") if part.strip())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_markdown_card(path: Path, kind: str) -> CatalogCard:
|
|
100
|
+
text = path.read_text(encoding="utf-8")
|
|
101
|
+
title = path.stem
|
|
102
|
+
headers: dict[str, str] = {}
|
|
103
|
+
summary = ""
|
|
104
|
+
body_lines: list[str] = []
|
|
105
|
+
# Only the FIRST contiguous run of `Key: value` lines is the header
|
|
106
|
+
# block; a later KV-looking block is body and must not override headers.
|
|
107
|
+
state = "preamble"
|
|
108
|
+
for line in text.splitlines():
|
|
109
|
+
stripped = line.strip()
|
|
110
|
+
if stripped.startswith("# ") and title == path.stem:
|
|
111
|
+
title = stripped[2:].strip()
|
|
112
|
+
continue
|
|
113
|
+
if state == "preamble":
|
|
114
|
+
if not stripped:
|
|
115
|
+
continue
|
|
116
|
+
matched = _HEADER_PATTERN.match(stripped)
|
|
117
|
+
if matched:
|
|
118
|
+
headers[matched.group("key").strip().lower()] = matched.group("value").strip()
|
|
119
|
+
state = "headers"
|
|
120
|
+
continue
|
|
121
|
+
state = "body"
|
|
122
|
+
body_lines.append(stripped)
|
|
123
|
+
continue
|
|
124
|
+
if state == "headers":
|
|
125
|
+
if not stripped:
|
|
126
|
+
state = "body"
|
|
127
|
+
continue
|
|
128
|
+
matched = _HEADER_PATTERN.match(stripped)
|
|
129
|
+
if matched:
|
|
130
|
+
headers[matched.group("key").strip().lower()] = matched.group("value").strip()
|
|
131
|
+
continue
|
|
132
|
+
state = "body"
|
|
133
|
+
body_lines.append(stripped)
|
|
134
|
+
continue
|
|
135
|
+
if stripped:
|
|
136
|
+
body_lines.append(stripped)
|
|
137
|
+
elif body_lines:
|
|
138
|
+
break
|
|
139
|
+
if body_lines:
|
|
140
|
+
summary = " ".join(body_lines)
|
|
141
|
+
risk = headers.get("risk", "unknown").lower()
|
|
142
|
+
return CatalogCard(
|
|
143
|
+
card_id=path.stem,
|
|
144
|
+
kind=kind,
|
|
145
|
+
title=title,
|
|
146
|
+
summary=summary,
|
|
147
|
+
tags=_csv(headers.get("tags", "")),
|
|
148
|
+
profiles=_csv(headers.get("profiles", "")) or ("*",),
|
|
149
|
+
source=str(path.resolve()),
|
|
150
|
+
risk=risk if risk in _RISK_VALUES else "unknown",
|
|
151
|
+
requires_approval=headers.get("requires approval", headers.get("requires_approval", "")).lower()
|
|
152
|
+
in {"true", "yes"},
|
|
153
|
+
metadata={},
|
|
154
|
+
created_at=headers.get("date", ""),
|
|
155
|
+
trust_tier=normalize_trust_tier(headers.get("trust_tier", headers.get("trust"))),
|
|
156
|
+
trust_source=headers.get("trust_source", "")[:_TRUST_SOURCE_MAX],
|
|
157
|
+
trust_note=headers.get("trust_note", "")[:_TRUST_NOTE_MAX],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_cards(directory: str | Path, kind: str) -> list[CatalogCard]:
|
|
162
|
+
"""Load JSON and Markdown cards from a local directory, skipping bad files."""
|
|
163
|
+
root = Path(directory)
|
|
164
|
+
if not root.exists():
|
|
165
|
+
return []
|
|
166
|
+
cards: list[CatalogCard] = []
|
|
167
|
+
for path in sorted(root.glob("*.json")):
|
|
168
|
+
try:
|
|
169
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
170
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
171
|
+
warnings.warn(f"load_cards: skipping {path}: {exc}", stacklevel=2)
|
|
172
|
+
continue
|
|
173
|
+
if not isinstance(raw, dict):
|
|
174
|
+
warnings.warn(f"load_cards: skipping {path}: root value is not a dict", stacklevel=2)
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
card = CatalogCard.from_dict(raw)
|
|
178
|
+
except (KeyError, TypeError) as exc:
|
|
179
|
+
warnings.warn(f"load_cards: skipping {path}: {exc}", stacklevel=2)
|
|
180
|
+
continue
|
|
181
|
+
cards.append(
|
|
182
|
+
CatalogCard.from_dict({**card.to_dict(), "kind": kind, "source": card.source or str(path.resolve())})
|
|
183
|
+
)
|
|
184
|
+
for path in sorted(root.glob("*.md")):
|
|
185
|
+
try:
|
|
186
|
+
cards.append(_parse_markdown_card(path, kind))
|
|
187
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
188
|
+
warnings.warn(f"load_cards: skipping {path}: {exc}", stacklevel=2)
|
|
189
|
+
continue
|
|
190
|
+
return sorted(cards, key=lambda card: card.card_id)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def visible_cards(cards: list[CatalogCard], profile: str) -> list[CatalogCard]:
|
|
194
|
+
"""Filter cards by profile visibility (`*` means public)."""
|
|
195
|
+
return [card for card in cards if "*" in card.profiles or profile in card.profiles]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _tokens(text: str) -> set[str]:
|
|
199
|
+
return {token for token in re.findall(r"[a-z0-9]+", text.lower()) if len(token) > 2}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def match_cards(cards: list[CatalogCard], query: str, limit: int = 5) -> list[CatalogCard]:
|
|
203
|
+
"""Rank cards by deterministic token overlap with the query."""
|
|
204
|
+
needle = _tokens(query)
|
|
205
|
+
if not needle:
|
|
206
|
+
return []
|
|
207
|
+
scored: list[tuple[int, str, CatalogCard]] = []
|
|
208
|
+
for card in cards:
|
|
209
|
+
haystack = _tokens(f"{card.title} {card.summary} {' '.join(card.tags)}")
|
|
210
|
+
score = len(needle & haystack)
|
|
211
|
+
if score:
|
|
212
|
+
scored.append((score, card.card_id, card))
|
|
213
|
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
|
214
|
+
return [card for _, _, card in scored[: max(0, limit)]]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def export_cards(cards: list[CatalogCard], out_path: str | Path) -> list[dict[str, Any]]:
|
|
218
|
+
"""Write cards as deterministic JSON and return the exported payload."""
|
|
219
|
+
payload = [card.to_dict() for card in sorted(cards, key=lambda card: card.card_id)]
|
|
220
|
+
target = Path(out_path)
|
|
221
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
223
|
+
return payload
|
werktools/classify.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Offline heuristic risk-classifier for MCP tool manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
_RISK_ORDER = ("low", "medium", "high", "critical")
|
|
9
|
+
|
|
10
|
+
_SIGNAL_RULES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|
11
|
+
(
|
|
12
|
+
"shell/exec",
|
|
13
|
+
"critical",
|
|
14
|
+
(
|
|
15
|
+
r"\bshell\b",
|
|
16
|
+
r"\bexec\b",
|
|
17
|
+
r"\bexecute\b",
|
|
18
|
+
r"\bsubprocess\b",
|
|
19
|
+
r"\beval\b",
|
|
20
|
+
# "spawn a worker/thread" is concurrency vocabulary, not shell access.
|
|
21
|
+
r"\bspawn\b(?!\s+(?:a\s+|an\s+|the\s+)?(?:worker|thread))",
|
|
22
|
+
r"\bcmd\b",
|
|
23
|
+
# bare "command" over-flags CLI help text, but run/system command
|
|
24
|
+
# phrasing is genuine shell vocabulary.
|
|
25
|
+
r"\brun(?:s|ning)?[_\-\s]+(?:a\s+|an\s+|any\s+|arbitrary\s+)?commands?\b",
|
|
26
|
+
r"\bsystem[_\-\s]commands?\b",
|
|
27
|
+
r"\bcommand[_\-\s](?:execution|injection)\b",
|
|
28
|
+
),
|
|
29
|
+
),
|
|
30
|
+
(
|
|
31
|
+
"injection-phrasing",
|
|
32
|
+
"critical",
|
|
33
|
+
(
|
|
34
|
+
r"ignore\s+previous",
|
|
35
|
+
r"system\s+prompt",
|
|
36
|
+
r"disregard\s+(your\s+)?(previous\s+)?instructions?",
|
|
37
|
+
r"forget\s+(all\s+)?previous",
|
|
38
|
+
r"new\s+instructions?",
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
(
|
|
42
|
+
"secret/credential",
|
|
43
|
+
"high",
|
|
44
|
+
(
|
|
45
|
+
r"\bapi[_\-\s]?key\b",
|
|
46
|
+
r"\bpassword\b",
|
|
47
|
+
r"\bsecret\b",
|
|
48
|
+
# bare "token" is ML-tokenizer vocabulary; require a credential qualifier.
|
|
49
|
+
r"\b(?:access|api|auth|bearer|oauth|refresh|secret|session)[_\-\s]?tokens?\b",
|
|
50
|
+
r"\bcredential\b",
|
|
51
|
+
r"\bauth[_\-]?key\b",
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
(
|
|
55
|
+
"fs-write/delete/destructive",
|
|
56
|
+
"high",
|
|
57
|
+
(
|
|
58
|
+
r"\bdelete(?:[_-]|\b)",
|
|
59
|
+
r"\bremove(?:[_-]|\b)",
|
|
60
|
+
r"\bwrite(?:[_-]|\b)",
|
|
61
|
+
r"\boverwrite\b",
|
|
62
|
+
r"\btruncate\b",
|
|
63
|
+
r"\bunlink\b",
|
|
64
|
+
r"\berase\b",
|
|
65
|
+
r"\bdestruct\b",
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"network/fetch/url",
|
|
70
|
+
"medium",
|
|
71
|
+
(
|
|
72
|
+
r"\bfetch\b",
|
|
73
|
+
r"\burl\b",
|
|
74
|
+
r"\bhttp\b",
|
|
75
|
+
r"\bdownload\b",
|
|
76
|
+
r"\brequest\b",
|
|
77
|
+
r"\bwebhook\b",
|
|
78
|
+
r"\bapi\b",
|
|
79
|
+
r"\bendpoint\b",
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _bump_risk(current: str, candidate: str) -> str:
|
|
86
|
+
try:
|
|
87
|
+
candidate_idx = _RISK_ORDER.index(candidate)
|
|
88
|
+
except ValueError:
|
|
89
|
+
warnings.warn(
|
|
90
|
+
f"_bump_risk: unknown risk level {candidate!r}; falling back to 'critical'",
|
|
91
|
+
stacklevel=2,
|
|
92
|
+
)
|
|
93
|
+
return "critical"
|
|
94
|
+
try:
|
|
95
|
+
current_idx = _RISK_ORDER.index(current)
|
|
96
|
+
except ValueError:
|
|
97
|
+
warnings.warn(
|
|
98
|
+
f"_bump_risk: unknown risk level {current!r}; falling back to 'critical'",
|
|
99
|
+
stacklevel=2,
|
|
100
|
+
)
|
|
101
|
+
return "critical"
|
|
102
|
+
if candidate_idx > current_idx:
|
|
103
|
+
return candidate
|
|
104
|
+
return current
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _collect_text(manifest: dict) -> str:
|
|
108
|
+
parts: list[str] = []
|
|
109
|
+
name = manifest.get("name")
|
|
110
|
+
if isinstance(name, str):
|
|
111
|
+
parts.append(name)
|
|
112
|
+
description = manifest.get("description")
|
|
113
|
+
if isinstance(description, str):
|
|
114
|
+
parts.append(description)
|
|
115
|
+
return " ".join(parts)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _collect_schema_keys(manifest: dict) -> list[str]:
|
|
119
|
+
schema = manifest.get("inputSchema")
|
|
120
|
+
if not isinstance(schema, dict):
|
|
121
|
+
return []
|
|
122
|
+
properties = schema.get("properties")
|
|
123
|
+
if not isinstance(properties, dict):
|
|
124
|
+
return []
|
|
125
|
+
return [key for key in properties if isinstance(key, str)]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _collect_schema_descriptions(manifest: dict) -> list[str]:
|
|
129
|
+
"""Collect 'description' and 'title' string values from inputSchema.properties.
|
|
130
|
+
|
|
131
|
+
Injection phrasing can be embedded in property metadata text, not just in
|
|
132
|
+
key names or the tool's own description. Including them closes that evasion
|
|
133
|
+
path.
|
|
134
|
+
"""
|
|
135
|
+
schema = manifest.get("inputSchema")
|
|
136
|
+
if not isinstance(schema, dict):
|
|
137
|
+
return []
|
|
138
|
+
properties = schema.get("properties")
|
|
139
|
+
if not isinstance(properties, dict):
|
|
140
|
+
return []
|
|
141
|
+
texts: list[str] = []
|
|
142
|
+
for prop_value in properties.values():
|
|
143
|
+
if not isinstance(prop_value, dict):
|
|
144
|
+
continue
|
|
145
|
+
for field in ("description", "title"):
|
|
146
|
+
val = prop_value.get(field)
|
|
147
|
+
if isinstance(val, str):
|
|
148
|
+
texts.append(val)
|
|
149
|
+
return texts
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def classify_tool(manifest: dict) -> dict:
|
|
153
|
+
"""Classify a tool manifest by static keyword heuristics only."""
|
|
154
|
+
if not isinstance(manifest, dict):
|
|
155
|
+
return {"risk": "low", "signals": [], "reasons": []}
|
|
156
|
+
|
|
157
|
+
combined = " ".join([
|
|
158
|
+
_collect_text(manifest),
|
|
159
|
+
*_collect_schema_keys(manifest),
|
|
160
|
+
*_collect_schema_descriptions(manifest),
|
|
161
|
+
])
|
|
162
|
+
signals: list[str] = []
|
|
163
|
+
reasons: list[str] = []
|
|
164
|
+
risk = "low"
|
|
165
|
+
|
|
166
|
+
for signal, signal_risk, patterns in _SIGNAL_RULES:
|
|
167
|
+
matched = [
|
|
168
|
+
pattern
|
|
169
|
+
for pattern in patterns
|
|
170
|
+
if re.search(pattern, combined, re.IGNORECASE)
|
|
171
|
+
]
|
|
172
|
+
if not matched:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
signals.append(signal)
|
|
176
|
+
reasons.append(f"{signal} detected (matched: {', '.join(matched[:3])})")
|
|
177
|
+
risk = _bump_risk(risk, signal_risk)
|
|
178
|
+
|
|
179
|
+
return {"risk": risk, "signals": signals, "reasons": reasons}
|