mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
396 lines
13 KiB
Python
396 lines
13 KiB
Python
"""Declarative, fail-closed policy for Violin skill selection.
|
|
|
|
This module intentionally has no Hermes or state dependency. It defines the
|
|
approved vocabulary and routing rules that later delivery/enforcement layers
|
|
consume, so policy changes can be reviewed without changing execution.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .phases import Phase, normalize_phase
|
|
|
|
__all__ = [
|
|
"CATALOG",
|
|
"RouteDecision",
|
|
"SkillSpec",
|
|
"catalog_snapshot",
|
|
"resolve_skill_route",
|
|
"skill_spec",
|
|
"validate_catalog",
|
|
"validate_skill_selection",
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SkillSpec:
|
|
"""One reviewed skill dependency and its provenance requirements."""
|
|
|
|
name: str
|
|
source: str
|
|
local_name: str
|
|
trust: str
|
|
install_hint: str
|
|
approved_bundle_digest: str | None
|
|
digest_required_on_install: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RouteDecision:
|
|
"""Deterministic skill choice plus every policy-permitted alternative."""
|
|
|
|
phase: str
|
|
vulnerability_class: str
|
|
candidate_source: str
|
|
selected: str | None
|
|
allowed: tuple[str, ...]
|
|
mismatch_reasons: tuple[str, ...]
|
|
|
|
|
|
# A remote dependency is deliberately not assigned an invented content hash.
|
|
# Its bundle digest is captured only by the approved Hermes install/audit flow;
|
|
# ``digest_required_on_install`` means a later delivery layer must reject an
|
|
# unpinned installation. Bundled skills receive their content digest when the
|
|
# distributable snapshot is generated.
|
|
CATALOG: tuple[SkillSpec, ...] = (
|
|
SkillSpec(
|
|
"pentest", "bundled:skills/pentest", "pentest", "bundled", "included with Violin", None
|
|
),
|
|
SkillSpec(
|
|
"web-attacks",
|
|
"bundled:skills/web-attacks",
|
|
"web-attacks",
|
|
"bundled",
|
|
"included with Violin",
|
|
None,
|
|
),
|
|
SkillSpec(
|
|
"access-control",
|
|
"bundled:skills/access-control",
|
|
"access-control",
|
|
"bundled",
|
|
"included with Violin",
|
|
None,
|
|
),
|
|
SkillSpec(
|
|
"domain-intel",
|
|
"official/research/domain-intel",
|
|
"domain-intel",
|
|
"official",
|
|
"hermes skills install official/research/domain-intel",
|
|
None,
|
|
True,
|
|
),
|
|
SkillSpec(
|
|
"osint-investigation",
|
|
"official/research/osint-investigation",
|
|
"osint-investigation",
|
|
"official",
|
|
"hermes skills install official/research/osint-investigation",
|
|
None,
|
|
True,
|
|
),
|
|
SkillSpec(
|
|
"sherlock",
|
|
"official/security/sherlock",
|
|
"sherlock",
|
|
"official",
|
|
"hermes skills install official/security/sherlock",
|
|
None,
|
|
True,
|
|
),
|
|
SkillSpec(
|
|
"oss-forensics",
|
|
"official/security/oss-forensics",
|
|
"oss-forensics",
|
|
"official",
|
|
"hermes skills install official/security/oss-forensics",
|
|
None,
|
|
True,
|
|
),
|
|
SkillSpec(
|
|
"audit-context-building",
|
|
"trailofbits/skills/plugins/audit-context-building/skills/audit-context-building",
|
|
"audit-context-building",
|
|
"reviewed-third-party",
|
|
"hermes skills install trailofbits/skills/plugins/audit-context-building/skills/audit-context-building",
|
|
None,
|
|
True,
|
|
),
|
|
SkillSpec(
|
|
"semgrep",
|
|
"trailofbits/skills/plugins/static-analysis/skills/semgrep",
|
|
"semgrep",
|
|
"reviewed-third-party",
|
|
"hermes skills install trailofbits/skills/plugins/static-analysis/skills/semgrep",
|
|
None,
|
|
True,
|
|
),
|
|
SkillSpec(
|
|
"codeql",
|
|
"trailofbits/skills/plugins/static-analysis/skills/codeql",
|
|
"codeql",
|
|
"reviewed-third-party",
|
|
"hermes skills install trailofbits/skills/plugins/static-analysis/skills/codeql",
|
|
None,
|
|
True,
|
|
),
|
|
SkillSpec(
|
|
"sarif-parsing",
|
|
"trailofbits/skills/plugins/static-analysis/skills/sarif-parsing",
|
|
"sarif-parsing",
|
|
"reviewed-third-party",
|
|
"hermes skills install trailofbits/skills/plugins/static-analysis/skills/sarif-parsing",
|
|
None,
|
|
True,
|
|
),
|
|
SkillSpec(
|
|
"fp-check",
|
|
"trailofbits/skills/plugins/fp-check/skills/fp-check",
|
|
"fp-check",
|
|
"reviewed-third-party",
|
|
"hermes skills install trailofbits/skills/plugins/fp-check/skills/fp-check",
|
|
None,
|
|
True,
|
|
),
|
|
)
|
|
|
|
_CATALOG_BY_NAME = {spec.name: spec for spec in CATALOG}
|
|
_EXCLUDED_SOURCES = frozenset(
|
|
{
|
|
"official/security/godmode",
|
|
"official/security/web-pentest",
|
|
"yayalingo/kali-pentest-agent/skills",
|
|
"yaklang/hack-skills",
|
|
}
|
|
)
|
|
|
|
_VULNERABILITY_ROUTES = {
|
|
"access-control": "access-control",
|
|
"auth-bypass": "access-control",
|
|
"authentication": "access-control",
|
|
"authorization": "access-control",
|
|
"broken-access-control": "access-control",
|
|
"broken-object-level-authorization": "access-control",
|
|
"idor": "access-control",
|
|
"jwt": "access-control",
|
|
"command-injection": "web-attacks",
|
|
"path-traversal": "web-attacks",
|
|
"sqli": "web-attacks",
|
|
"sql-injection": "web-attacks",
|
|
"ssrf": "web-attacks",
|
|
"server-side-request-forgery": "web-attacks",
|
|
"xss": "web-attacks",
|
|
"cross-site-scripting": "web-attacks",
|
|
"business-logic": "web-attacks",
|
|
"business-logic-flaw": "web-attacks",
|
|
"logic-flaw": "web-attacks",
|
|
"source-analysis": "audit-context-building",
|
|
"static-analysis": "semgrep",
|
|
"sarif": "sarif-parsing",
|
|
"false-positive": "fp-check",
|
|
}
|
|
_SOURCE_ROUTES = {
|
|
"domain": "domain-intel",
|
|
"osint": "osint-investigation",
|
|
"public-records": "osint-investigation",
|
|
"username": "sherlock",
|
|
"identity": "sherlock",
|
|
"repository": "oss-forensics",
|
|
"supply-chain": "oss-forensics",
|
|
"codebase": "audit-context-building",
|
|
"source": "audit-context-building",
|
|
"semgrep": "semgrep",
|
|
"codeql": "codeql",
|
|
"sarif": "sarif-parsing",
|
|
}
|
|
_PHASE_DEFAULTS = {
|
|
Phase.SCOPING: "pentest",
|
|
Phase.RECON: "pentest",
|
|
Phase.VULN_RESEARCH: "pentest",
|
|
Phase.EXPLOITATION: "pentest",
|
|
Phase.POST_EXPLOITATION: "pentest",
|
|
Phase.PRIVESC: "pentest",
|
|
Phase.FLAGS: "pentest",
|
|
Phase.REPORTING: "pentest",
|
|
Phase.RETROSPECTIVE: "pentest",
|
|
}
|
|
|
|
|
|
def _candidate_source_guidance() -> str:
|
|
"""Render the public, normalized candidate-source vocabulary and its routes."""
|
|
|
|
return ", ".join(f"{source} -> {skill}" for source, skill in sorted(_SOURCE_ROUTES.items()))
|
|
|
|
|
|
def _route_basis(decision: RouteDecision) -> str:
|
|
"""Explain the highest-priority input that selected one mandatory skill."""
|
|
|
|
if decision.vulnerability_class in _VULNERABILITY_ROUTES:
|
|
return (
|
|
f"vulnerability_class {decision.vulnerability_class!r} "
|
|
"(vulnerability routes take precedence over candidate_source)"
|
|
)
|
|
if decision.candidate_source in _SOURCE_ROUTES:
|
|
return f"candidate_source {decision.candidate_source!r}"
|
|
return f"phase {decision.phase!r} default"
|
|
|
|
|
|
def _normalise(value: str | None) -> str:
|
|
return "-".join((value or "").strip().lower().replace("_", "-").split())
|
|
|
|
|
|
def skill_spec(name: str) -> SkillSpec | None:
|
|
"""Return catalog provenance and installation guidance for a skill."""
|
|
return _CATALOG_BY_NAME.get(_normalise(name))
|
|
|
|
|
|
def validate_catalog(catalog: Iterable[SkillSpec] = CATALOG) -> tuple[str, ...]:
|
|
"""Return integrity failures; callers must refuse policy on any failure."""
|
|
|
|
errors: list[str] = []
|
|
names: set[str] = set()
|
|
locals_: set[str] = set()
|
|
sources: set[str] = set()
|
|
for spec in catalog:
|
|
name = _normalise(spec.name)
|
|
if not name:
|
|
errors.append("skill catalog contains an empty name")
|
|
elif name in names:
|
|
errors.append(f"duplicate skill name: {spec.name}")
|
|
names.add(name)
|
|
local_name = _normalise(spec.local_name)
|
|
if not local_name:
|
|
errors.append(f"skill {spec.name} has no local name")
|
|
elif local_name in locals_:
|
|
errors.append(f"local-name collision: {spec.local_name}")
|
|
locals_.add(local_name)
|
|
if not spec.source.strip():
|
|
errors.append(f"skill {spec.name} has no source")
|
|
elif spec.source in _EXCLUDED_SOURCES:
|
|
errors.append(f"skill {spec.name} uses excluded source: {spec.source}")
|
|
elif spec.source in sources:
|
|
errors.append(f"duplicate skill source: {spec.source}")
|
|
sources.add(spec.source)
|
|
if spec.trust not in {"bundled", "official", "reviewed-third-party"}:
|
|
errors.append(f"skill {spec.name} has unknown trust level: {spec.trust}")
|
|
if not spec.install_hint.strip():
|
|
errors.append(f"skill {spec.name} has no install hint")
|
|
if spec.approved_bundle_digest and not spec.approved_bundle_digest.startswith("sha256:"):
|
|
errors.append(f"skill {spec.name} has invalid approved bundle digest")
|
|
if spec.trust == "bundled" and spec.digest_required_on_install:
|
|
errors.append(f"bundled skill {spec.name} cannot require a remote install digest")
|
|
if spec.trust != "bundled" and not spec.digest_required_on_install:
|
|
errors.append(f"external skill {spec.name} must require an approved install digest")
|
|
return tuple(errors)
|
|
|
|
|
|
def resolve_skill_route(
|
|
phase: str,
|
|
vulnerability_class: str | None = None,
|
|
candidate_source: str | None = None,
|
|
) -> RouteDecision:
|
|
"""Resolve one policy route without consulting state or installed skills."""
|
|
|
|
catalog_errors = validate_catalog()
|
|
raw_vulnerability = _normalise(vulnerability_class)
|
|
raw_source = _normalise(candidate_source)
|
|
try:
|
|
canonical_phase = normalize_phase(phase)
|
|
except (AttributeError, ValueError):
|
|
return RouteDecision(
|
|
str(phase),
|
|
raw_vulnerability,
|
|
raw_source,
|
|
None,
|
|
(),
|
|
(f"unknown phase: {phase}", *catalog_errors),
|
|
)
|
|
selected = _VULNERABILITY_ROUTES.get(raw_vulnerability)
|
|
if selected is None:
|
|
selected = _SOURCE_ROUTES.get(raw_source)
|
|
if selected is None:
|
|
selected = _PHASE_DEFAULTS[canonical_phase]
|
|
mismatch: list[str] = list(catalog_errors)
|
|
if raw_vulnerability and raw_vulnerability not in _VULNERABILITY_ROUTES:
|
|
valid_classes = ", ".join(sorted(_VULNERABILITY_ROUTES.keys()))
|
|
mismatch.append(
|
|
f"unknown vulnerability class: {vulnerability_class}; valid classes are: {valid_classes}"
|
|
)
|
|
if raw_source and raw_source not in _SOURCE_ROUTES:
|
|
mismatch.append(
|
|
f"unknown candidate source: {candidate_source!r}; accepted normalized values and routes: "
|
|
+ _candidate_source_guidance()
|
|
+ ". If this value names a vulnerability type (for example jwt, idor, xss, or ssrf), "
|
|
"pass it as vuln_class instead of candidate_source."
|
|
)
|
|
allowed = () if mismatch else (selected,)
|
|
return RouteDecision(
|
|
canonical_phase.value, raw_vulnerability, raw_source, selected, allowed, tuple(mismatch)
|
|
)
|
|
|
|
|
|
def validate_skill_selection(
|
|
selected_skill: str,
|
|
phase: str,
|
|
vulnerability_class: str | None = None,
|
|
candidate_source: str | None = None,
|
|
) -> RouteDecision:
|
|
"""Resolve and add a fail-closed explanation for an LLM skill mismatch."""
|
|
|
|
decision = resolve_skill_route(phase, vulnerability_class, candidate_source)
|
|
selection = _normalise(selected_skill)
|
|
reasons = list(decision.mismatch_reasons)
|
|
if selection == "fp-check" and decision.phase == Phase.RETROSPECTIVE.value and not reasons:
|
|
return RouteDecision(
|
|
decision.phase,
|
|
decision.vulnerability_class,
|
|
decision.candidate_source,
|
|
selection,
|
|
(selection,),
|
|
(),
|
|
)
|
|
if selection not in _CATALOG_BY_NAME:
|
|
reasons.append(f"unknown or unapproved skill: {selected_skill}")
|
|
elif decision.allowed and selection not in decision.allowed:
|
|
reasons.append(
|
|
f"skill {selected_skill!r} is not permitted; expected {decision.selected!r} "
|
|
f"because {_route_basis(decision)}. Set skill={decision.selected!r}."
|
|
)
|
|
return RouteDecision(
|
|
decision.phase,
|
|
decision.vulnerability_class,
|
|
decision.candidate_source,
|
|
decision.selected,
|
|
decision.allowed if not reasons else (),
|
|
tuple(reasons),
|
|
)
|
|
|
|
|
|
def catalog_snapshot(repo_root: Path) -> dict[str, object]:
|
|
"""Build the Hermes-compatible dependency snapshot payload.
|
|
|
|
Hermes ignores the Violin-specific audit metadata, while the later receipt
|
|
layer uses it to ensure an installed external bundle has a recorded digest.
|
|
"""
|
|
|
|
skills = []
|
|
for spec in CATALOG:
|
|
entry = {
|
|
"identifier": spec.source,
|
|
"category": "security",
|
|
"name": spec.name,
|
|
"local_name": spec.local_name,
|
|
"trust": spec.trust,
|
|
"install_hint": spec.install_hint,
|
|
"approved_bundle_digest": spec.approved_bundle_digest,
|
|
"digest_required_on_install": spec.digest_required_on_install,
|
|
}
|
|
if spec.trust == "bundled":
|
|
entry["path"] = str(repo_root / spec.source.removeprefix("bundled:"))
|
|
skills.append(entry)
|
|
return {"hermes_version": "0.18.0", "skills": skills, "taps": []}
|