Files

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": []}