skill-sentry-cli 2.0.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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Deterministic decision client for unit tests and local wiring checks.
|
|
2
|
+
|
|
3
|
+
``MockDecisionClient`` is never selected by the production decision selector.
|
|
4
|
+
It exists so tests can exercise the semantic stage without downloading a Laya
|
|
5
|
+
checkpoint. A mock-only scan must never be presented as live-model evidence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from skill_sentry_cli.core.decisions.base import (
|
|
13
|
+
DecisionAnswer,
|
|
14
|
+
DecisionQuestion,
|
|
15
|
+
DecisionResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_MALICIOUS_MARKERS = (
|
|
19
|
+
"aws/credentials",
|
|
20
|
+
"base64",
|
|
21
|
+
"curl ",
|
|
22
|
+
"dockerenv",
|
|
23
|
+
"evil",
|
|
24
|
+
"exfil",
|
|
25
|
+
"nslookup",
|
|
26
|
+
"private key",
|
|
27
|
+
"token",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MockDecisionClient:
|
|
32
|
+
"""Deterministic typed decisions with ``auto``/``benign``/``malicious`` profiles."""
|
|
33
|
+
|
|
34
|
+
backend = "mock"
|
|
35
|
+
model_version = "mock-laya-v1"
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
profile: str = "auto",
|
|
40
|
+
*,
|
|
41
|
+
answers: dict[str, DecisionAnswer] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
if profile not in {"auto", "benign", "malicious"}:
|
|
44
|
+
raise ValueError(f"unknown MockDecisionClient profile: {profile}")
|
|
45
|
+
self.profile = profile
|
|
46
|
+
self._answers = dict(answers or {})
|
|
47
|
+
|
|
48
|
+
def decide(
|
|
49
|
+
self,
|
|
50
|
+
state: str | dict[str, Any],
|
|
51
|
+
questions: dict[str, DecisionQuestion],
|
|
52
|
+
*,
|
|
53
|
+
timeout_s: float | None = None,
|
|
54
|
+
) -> DecisionResult:
|
|
55
|
+
del timeout_s
|
|
56
|
+
malicious = self._is_malicious(state)
|
|
57
|
+
answers: dict[str, DecisionAnswer] = {}
|
|
58
|
+
for qid, question in questions.items():
|
|
59
|
+
if qid in self._answers:
|
|
60
|
+
answers[qid] = self._answers[qid]
|
|
61
|
+
else:
|
|
62
|
+
answers[qid] = _mock_answer(qid, question, malicious)
|
|
63
|
+
return DecisionResult(
|
|
64
|
+
answers=answers,
|
|
65
|
+
backend=self.backend,
|
|
66
|
+
checkpoint="mock",
|
|
67
|
+
model_version=self.model_version,
|
|
68
|
+
routing={"model": "mock", "reason": "deterministic test double"},
|
|
69
|
+
usage={"input_tokens": 0, "output_tokens": 0},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def health(self) -> bool:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def _is_malicious(self, state: str | dict[str, Any]) -> bool:
|
|
79
|
+
if self.profile == "malicious":
|
|
80
|
+
return True
|
|
81
|
+
if self.profile == "benign":
|
|
82
|
+
return False
|
|
83
|
+
text = state if isinstance(state, str) else str(state)
|
|
84
|
+
lowered = text.lower()
|
|
85
|
+
return any(marker in lowered for marker in _MALICIOUS_MARKERS)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _mock_answer(qid: str, question: DecisionQuestion, malicious: bool) -> DecisionAnswer:
|
|
89
|
+
if question.type == "choice":
|
|
90
|
+
criteria = question.criteria if isinstance(question.criteria, dict) else {}
|
|
91
|
+
keys = list(criteria)
|
|
92
|
+
if qid == "deception" and "deceptive" in criteria:
|
|
93
|
+
choice = "deceptive" if malicious else "consistent"
|
|
94
|
+
elif malicious and "data_export" in criteria:
|
|
95
|
+
choice = "data_export"
|
|
96
|
+
elif not malicious and "other" in criteria:
|
|
97
|
+
choice = "other"
|
|
98
|
+
else:
|
|
99
|
+
choice = keys[0] if keys else "other"
|
|
100
|
+
if len(keys) == 1:
|
|
101
|
+
probabilities: dict[str, float] = {keys[0]: 1.0}
|
|
102
|
+
else:
|
|
103
|
+
rest = (1.0 - 0.9) / max(1, len(keys) - 1)
|
|
104
|
+
probabilities = {key: (0.9 if key == choice else rest) for key in keys}
|
|
105
|
+
return DecisionAnswer(choice=choice, probabilities=probabilities, confidence=0.9)
|
|
106
|
+
|
|
107
|
+
if question.type == "score":
|
|
108
|
+
criteria = question.criteria if isinstance(question.criteria, list) else []
|
|
109
|
+
selected = 1.0 if malicious else 0.0
|
|
110
|
+
index = 1 if malicious and len(criteria) > 1 else 0
|
|
111
|
+
rest = (1.0 - 0.9) / max(1, len(criteria) - 1)
|
|
112
|
+
probabilities = [0.9 if position == index else rest for position in range(len(criteria))]
|
|
113
|
+
return DecisionAnswer(score=selected, probabilities=probabilities, confidence=0.9)
|
|
114
|
+
|
|
115
|
+
return DecisionAnswer(noul=0.9 if malicious else 0.05, confidence=0.9)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = ["MockDecisionClient"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Fixed semantic question taxonomy for local decision backends.
|
|
2
|
+
|
|
3
|
+
The taxonomy is deliberately small. Large choice sets degrade Laya accuracy
|
|
4
|
+
and make calibration harder. Add a capability only when there is a
|
|
5
|
+
deterministic evidence source or a clear evaluation case for it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
from skill_sentry_cli.core.decisions.base import DecisionQuestion, EvidenceCandidate
|
|
13
|
+
|
|
14
|
+
INTENT_CHOICES: dict[str, str] = {
|
|
15
|
+
"text_processing": "Summarising, translating, transforming, or generating text.",
|
|
16
|
+
"file_management": "Reading, writing, moving, or organising files and directories.",
|
|
17
|
+
"network_access": "Calling APIs, downloading resources, or otherwise using the network.",
|
|
18
|
+
"credential_access": "Reading, requesting, or transmitting credentials or secrets.",
|
|
19
|
+
"code_execution": "Running commands, scripts, or arbitrary code.",
|
|
20
|
+
"ui_automation": "Interacting with a browser, desktop UI, or other graphical surface.",
|
|
21
|
+
"data_export": "Uploading, exporting, or transferring user or system data.",
|
|
22
|
+
"other": "None of the categories above is a good fit.",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
DECEPTION_CHOICES: dict[str, str] = {
|
|
26
|
+
"consistent": "The implementation is consistent with the declared purpose.",
|
|
27
|
+
"unclear": "The available evidence is insufficient to decide.",
|
|
28
|
+
"deceptive": "The implementation hides or contradicts its declared purpose.",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Question IDs are stable: they appear in telemetry, calibration files, and
|
|
32
|
+
# report metadata. Do not rename one without a migration.
|
|
33
|
+
CAPABILITY_QUESTIONS: dict[str, str] = {
|
|
34
|
+
"cap_credentials": (
|
|
35
|
+
"Does the skill read, request, or transmit credential material such as "
|
|
36
|
+
"passwords, API keys, tokens, private keys, or cloud credentials?"
|
|
37
|
+
),
|
|
38
|
+
"cap_env_secrets": (
|
|
39
|
+
"Does the skill inspect environment variables or shell profiles to discover "
|
|
40
|
+
"secrets or credentials?"
|
|
41
|
+
),
|
|
42
|
+
"cap_process": "Does the skill spawn processes or execute shell commands?",
|
|
43
|
+
"cap_network": "Does the skill make outbound network requests?",
|
|
44
|
+
"cap_upload": "Does the skill send data to a remote endpoint?",
|
|
45
|
+
"cap_dns": "Does the skill perform DNS lookups or encode data in DNS queries?",
|
|
46
|
+
"cap_filesystem": (
|
|
47
|
+
"Does the skill write, modify, or delete files outside its own package directory?"
|
|
48
|
+
),
|
|
49
|
+
"cap_persistence": (
|
|
50
|
+
"Does the skill modify startup, shell profile, scheduled task, or other "
|
|
51
|
+
"persistence mechanisms?"
|
|
52
|
+
),
|
|
53
|
+
"cap_obfuscation": (
|
|
54
|
+
"Does the skill decode, encrypt, or otherwise obfuscate content to hide its behavior?"
|
|
55
|
+
),
|
|
56
|
+
"cap_hidden_behavior": "Does the skill hide behavior from the user or operator?",
|
|
57
|
+
"cap_prompt_injection": (
|
|
58
|
+
"Does the skill contain instructions aimed at an auditing agent or language "
|
|
59
|
+
"model rather than at its own runtime?"
|
|
60
|
+
),
|
|
61
|
+
"cap_deception": "Does the implementation contradict its declared intent?",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def adjudication_questions(candidates: Iterable[EvidenceCandidate]) -> dict[str, DecisionQuestion]:
|
|
66
|
+
"""One yes/no question per candidate, keyed ``adj_<candidate_id>``.
|
|
67
|
+
|
|
68
|
+
The question names only the candidate's code-owned anchor type. The code
|
|
69
|
+
itself travels in the state, never in the instructions.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
f"adj_{candidate.candidate_id}": DecisionQuestion(
|
|
74
|
+
type="noul",
|
|
75
|
+
instructions=(
|
|
76
|
+
f"Is the code candidate {candidate.candidate_id} (anchor type "
|
|
77
|
+
f"{candidate.anchor_type}) used for a harmful purpose rather than the "
|
|
78
|
+
"skill's declared function?"
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
for candidate in candidates
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def semantic_questions() -> dict[str, DecisionQuestion]:
|
|
86
|
+
"""Return a fresh copy of the V1 semantic question set.
|
|
87
|
+
|
|
88
|
+
A fresh mapping is returned so callers cannot mutate the module-level
|
|
89
|
+
taxonomy while building a request.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
questions: dict[str, DecisionQuestion] = {
|
|
93
|
+
"intent": DecisionQuestion(
|
|
94
|
+
type="choice",
|
|
95
|
+
instructions="Which category best describes the skill's declared purpose?",
|
|
96
|
+
criteria=dict(INTENT_CHOICES),
|
|
97
|
+
),
|
|
98
|
+
"deception": DecisionQuestion(
|
|
99
|
+
type="choice",
|
|
100
|
+
instructions=(
|
|
101
|
+
"Does the implementation behave consistently with the declared purpose, "
|
|
102
|
+
"or does it hide or contradict that purpose?"
|
|
103
|
+
),
|
|
104
|
+
criteria=dict(DECEPTION_CHOICES),
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
for question_id, instructions in CAPABILITY_QUESTIONS.items():
|
|
108
|
+
questions[question_id] = DecisionQuestion(type="noul", instructions=instructions)
|
|
109
|
+
return questions
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"CAPABILITY_QUESTIONS",
|
|
114
|
+
"DECEPTION_CHOICES",
|
|
115
|
+
"INTENT_CHOICES",
|
|
116
|
+
"adjudication_questions",
|
|
117
|
+
"semantic_questions",
|
|
118
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Report metadata for the typed decision layer.
|
|
2
|
+
|
|
3
|
+
The summary is built from a whitelist of stage-telemetry keys. It never copies
|
|
4
|
+
prompts, routing payloads, or skill text, so a report cannot leak them even if
|
|
5
|
+
a backend adds fields to its response.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from skill_sentry_cli.core.contracts import StageResult
|
|
14
|
+
|
|
15
|
+
_SEMANTIC_KEYS = (
|
|
16
|
+
"decision_backend",
|
|
17
|
+
"model_backend",
|
|
18
|
+
"checkpoint",
|
|
19
|
+
"model_version",
|
|
20
|
+
"calibration_version",
|
|
21
|
+
"instruction_floor",
|
|
22
|
+
)
|
|
23
|
+
_VISION_STATUSES = frozenset({"not_applicable", "evaluated", "partial", "not_evaluated"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def decision_summary(stage_results: Iterable[StageResult]) -> dict[str, Any]:
|
|
27
|
+
"""Return the report-safe decision summary for one scan's stage results."""
|
|
28
|
+
|
|
29
|
+
summary: dict[str, Any] = {
|
|
30
|
+
"decision_backend": "legacy",
|
|
31
|
+
"checkpoint": None,
|
|
32
|
+
"model_version": None,
|
|
33
|
+
"calibration_version": "uncalibrated",
|
|
34
|
+
"semantic_degraded": False,
|
|
35
|
+
"semantic_error": None,
|
|
36
|
+
"threshold": None,
|
|
37
|
+
"capabilities": {},
|
|
38
|
+
"vision_status": "not_evaluated",
|
|
39
|
+
}
|
|
40
|
+
for result in stage_results:
|
|
41
|
+
if result.stage.value == "semantic":
|
|
42
|
+
telemetry = result.telemetry or {}
|
|
43
|
+
for key in _SEMANTIC_KEYS:
|
|
44
|
+
if telemetry.get(key) is not None:
|
|
45
|
+
summary[key] = telemetry[key]
|
|
46
|
+
decisions = telemetry.get("decisions") or {}
|
|
47
|
+
if isinstance(decisions, dict):
|
|
48
|
+
summary["threshold"] = decisions.get("threshold")
|
|
49
|
+
capabilities = decisions.get("capabilities")
|
|
50
|
+
if isinstance(capabilities, dict):
|
|
51
|
+
summary["capabilities"] = {
|
|
52
|
+
str(key): round(float(value), 4)
|
|
53
|
+
for key, value in capabilities.items()
|
|
54
|
+
if isinstance(value, (int, float))
|
|
55
|
+
}
|
|
56
|
+
summary["semantic_degraded"] = bool(result.degraded or result.error)
|
|
57
|
+
summary["semantic_error"] = str(result.error)[:300] if result.error else None
|
|
58
|
+
elif result.stage.value == "vision":
|
|
59
|
+
status = (result.telemetry or {}).get("vision_status")
|
|
60
|
+
if status in _VISION_STATUSES:
|
|
61
|
+
summary["vision_status"] = status
|
|
62
|
+
return summary
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["decision_summary"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Decision-backend selection.
|
|
2
|
+
|
|
3
|
+
This is separate from the legacy generation/vision selector. ``auto`` prefers
|
|
4
|
+
Laya when the package and router configuration are available, falls back to the
|
|
5
|
+
legacy semantic path only when the caller says it exists, and otherwise returns
|
|
6
|
+
an explicit degraded selection. A missing required decision backend never
|
|
7
|
+
silently becomes a clean scan.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from skill_sentry_cli.core.config import DecisionSettings, SkillsEntryConfig
|
|
15
|
+
from skill_sentry_cli.core.decisions.base import DecisionClient, DecisionUnavailable
|
|
16
|
+
from skill_sentry_cli.core.decisions.laya import LayaDecisionClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DecisionSelection:
|
|
21
|
+
"""Result of decision-backend selection.
|
|
22
|
+
|
|
23
|
+
``client`` is set only for a typed decision backend. ``backend`` may be
|
|
24
|
+
``laya``, ``legacy``, or ``none`` so callers can route the legacy semantic
|
|
25
|
+
analyzer separately and record the degraded reason.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
client: DecisionClient | None
|
|
29
|
+
backend: str
|
|
30
|
+
degraded: bool = False
|
|
31
|
+
reason: str | None = None
|
|
32
|
+
required: bool = False
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def available(self) -> bool:
|
|
36
|
+
return self.client is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def select_decision_model(
|
|
40
|
+
config: SkillsEntryConfig | DecisionSettings | None = None,
|
|
41
|
+
*,
|
|
42
|
+
backend: str | None = None,
|
|
43
|
+
probe: bool = True,
|
|
44
|
+
laya_client: DecisionClient | None = None,
|
|
45
|
+
legacy_available: bool = False,
|
|
46
|
+
) -> DecisionSelection:
|
|
47
|
+
"""Return the typed decision backend or an explicit fallback/degradation.
|
|
48
|
+
|
|
49
|
+
``auto`` prefers Laya if it imports and its router can be constructed. It
|
|
50
|
+
does not load checkpoint weights; the first :meth:`decide` call does that
|
|
51
|
+
and any load failure becomes a degraded stage. Explicit ``laya`` fails
|
|
52
|
+
closed with :class:`DecisionUnavailable` when the health probe fails.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
settings = _decision_settings(config)
|
|
56
|
+
choice = _backend_value(backend if backend is not None else settings.backend)
|
|
57
|
+
required = settings.required
|
|
58
|
+
|
|
59
|
+
if choice == "none":
|
|
60
|
+
return DecisionSelection(
|
|
61
|
+
client=None,
|
|
62
|
+
backend="none",
|
|
63
|
+
degraded=True,
|
|
64
|
+
reason="decision backend disabled; semantic analysis is deterministic-only",
|
|
65
|
+
required=required,
|
|
66
|
+
)
|
|
67
|
+
if choice == "legacy":
|
|
68
|
+
return DecisionSelection(
|
|
69
|
+
client=None,
|
|
70
|
+
backend="legacy",
|
|
71
|
+
degraded=not legacy_available,
|
|
72
|
+
reason=None if legacy_available else "legacy semantic model is not available",
|
|
73
|
+
required=required,
|
|
74
|
+
)
|
|
75
|
+
if choice not in {"auto", "laya"}:
|
|
76
|
+
raise ValueError(f"unsupported decision backend: {choice!r}")
|
|
77
|
+
|
|
78
|
+
client = laya_client or LayaDecisionClient(settings)
|
|
79
|
+
healthy = True if not probe else _healthy(client)
|
|
80
|
+
if healthy:
|
|
81
|
+
return DecisionSelection(
|
|
82
|
+
client=client,
|
|
83
|
+
backend="laya",
|
|
84
|
+
degraded=False,
|
|
85
|
+
required=required,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
error = _failure_detail(client)
|
|
89
|
+
if choice == "laya":
|
|
90
|
+
raise DecisionUnavailable(f"laya decision backend is not available: {error}")
|
|
91
|
+
|
|
92
|
+
if required:
|
|
93
|
+
return DecisionSelection(
|
|
94
|
+
client=None,
|
|
95
|
+
backend="none",
|
|
96
|
+
degraded=True,
|
|
97
|
+
reason=f"laya is required but unavailable: {error}",
|
|
98
|
+
required=True,
|
|
99
|
+
)
|
|
100
|
+
if legacy_available:
|
|
101
|
+
return DecisionSelection(
|
|
102
|
+
client=None,
|
|
103
|
+
backend="legacy",
|
|
104
|
+
degraded=False,
|
|
105
|
+
reason=f"laya unavailable; using legacy semantic path: {error}",
|
|
106
|
+
required=required,
|
|
107
|
+
)
|
|
108
|
+
return DecisionSelection(
|
|
109
|
+
client=None,
|
|
110
|
+
backend="none",
|
|
111
|
+
degraded=True,
|
|
112
|
+
reason=f"no decision backend available: {error}",
|
|
113
|
+
required=required,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _decision_settings(
|
|
118
|
+
config: SkillsEntryConfig | DecisionSettings | None,
|
|
119
|
+
) -> DecisionSettings:
|
|
120
|
+
if config is None:
|
|
121
|
+
return DecisionSettings()
|
|
122
|
+
if isinstance(config, DecisionSettings):
|
|
123
|
+
return config
|
|
124
|
+
return config.decision
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _backend_value(value: str) -> str:
|
|
128
|
+
normalised = str(value).strip().lower()
|
|
129
|
+
return {
|
|
130
|
+
"laya-local": "laya",
|
|
131
|
+
"local": "laya",
|
|
132
|
+
"off": "none",
|
|
133
|
+
"disabled": "none",
|
|
134
|
+
}.get(normalised, normalised)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _healthy(client: DecisionClient) -> bool:
|
|
138
|
+
try:
|
|
139
|
+
return bool(client.health())
|
|
140
|
+
except Exception:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _failure_detail(client: DecisionClient) -> str:
|
|
145
|
+
detail = getattr(client, "last_error", None)
|
|
146
|
+
if isinstance(detail, str) and detail.strip():
|
|
147
|
+
return detail
|
|
148
|
+
return "health probe failed"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["DecisionSelection", "select_decision_model"]
|