fix(cron): normalize absolute skill paths before skill_view (#59824)
Cron jobs may store absolute paths to skills under HERMES_HOME/skills or external_dirs, but skill_view rejects absolute names for security. Extract the slash-command normalization into agent.skill_utils and reuse it when cron loads job skills.
This commit is contained in:
parent
07d93413e5
commit
62972060ca
|
|
@ -143,37 +143,9 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from tools.skills_tool import SKILLS_DIR, skill_view
|
from tools.skills_tool import SKILLS_DIR, skill_view
|
||||||
from agent.skill_utils import get_external_skills_dirs
|
from agent.skill_utils import normalize_skill_lookup_name
|
||||||
|
|
||||||
identifier_path = Path(raw_identifier).expanduser()
|
normalized = normalize_skill_lookup_name(raw_identifier)
|
||||||
if identifier_path.is_absolute():
|
|
||||||
normalized = None
|
|
||||||
trusted_roots = [SKILLS_DIR]
|
|
||||||
try:
|
|
||||||
trusted_roots.extend(get_external_skills_dirs())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Prefer the lexical path under a trusted skill root before
|
|
||||||
# resolving symlinks. Slash-command discovery can legitimately
|
|
||||||
# find a skill via ~/.hermes/skills/<name> where <name> is a
|
|
||||||
# symlink to a checked-out skill elsewhere. Resolving first turns
|
|
||||||
# that trusted visible path into an arbitrary absolute path that
|
|
||||||
# skill_view() refuses to load.
|
|
||||||
for root in trusted_roots:
|
|
||||||
try:
|
|
||||||
normalized = str(identifier_path.relative_to(root))
|
|
||||||
break
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if normalized is None:
|
|
||||||
try:
|
|
||||||
normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))
|
|
||||||
except Exception:
|
|
||||||
normalized = raw_identifier
|
|
||||||
else:
|
|
||||||
normalized = raw_identifier.lstrip("/")
|
|
||||||
|
|
||||||
loaded_skill = json.loads(
|
loaded_skill = json.loads(
|
||||||
skill_view(normalized, task_id=task_id, preprocess=False)
|
skill_view(normalized, task_id=task_id, preprocess=False)
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,46 @@ def get_all_skills_dirs() -> List[Path]:
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_skill_lookup_name(identifier: str) -> str:
|
||||||
|
"""Normalize a skill identifier to a ``skill_view()``-safe relative path.
|
||||||
|
|
||||||
|
Slash commands and cron jobs may store absolute paths to skills that live
|
||||||
|
under ``~/.hermes/skills/`` (including via symlinks) or configured
|
||||||
|
``skills.external_dirs``. ``skill_view()`` rejects absolute names for
|
||||||
|
security, so callers must translate trusted absolute paths to their
|
||||||
|
relative form first.
|
||||||
|
"""
|
||||||
|
raw_identifier = (identifier or "").strip()
|
||||||
|
if not raw_identifier:
|
||||||
|
return raw_identifier
|
||||||
|
|
||||||
|
identifier_path = Path(raw_identifier).expanduser()
|
||||||
|
if not identifier_path.is_absolute():
|
||||||
|
return raw_identifier.lstrip("/")
|
||||||
|
|
||||||
|
trusted_roots = [get_skills_dir()]
|
||||||
|
try:
|
||||||
|
trusted_roots.extend(get_external_skills_dirs())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Prefer the lexical path under a trusted skill root before resolving
|
||||||
|
# symlinks. Slash-command discovery can legitimately find a skill via
|
||||||
|
# ~/.hermes/skills/<name> where <name> is a symlink to a checked-out
|
||||||
|
# skill elsewhere. Resolving first turns that trusted visible path into
|
||||||
|
# an arbitrary absolute path that skill_view() refuses to load.
|
||||||
|
for root in trusted_roots:
|
||||||
|
try:
|
||||||
|
return str(identifier_path.relative_to(root))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
return str(identifier_path.resolve().relative_to(get_skills_dir().resolve()))
|
||||||
|
except Exception:
|
||||||
|
return raw_identifier
|
||||||
|
|
||||||
|
|
||||||
def _resolve_for_skill_ownership(path) -> Path:
|
def _resolve_for_skill_ownership(path) -> Path:
|
||||||
path_obj = path if isinstance(path, Path) else Path(str(path))
|
path_obj = path if isinstance(path, Path) else Path(str(path))
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2195,6 +2195,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||||
from tools.skills_tool import skill_view
|
from tools.skills_tool import skill_view
|
||||||
from tools.skill_usage import bump_use
|
from tools.skill_usage import bump_use
|
||||||
from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key
|
from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key
|
||||||
|
from agent.skill_utils import normalize_skill_lookup_name
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
skipped: list[str] = []
|
skipped: list[str] = []
|
||||||
|
|
@ -2225,7 +2226,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loaded = json.loads(skill_view(skill_name))
|
loaded = json.loads(skill_view(normalize_skill_lookup_name(skill_name)))
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
logger.warning("Cron job '%s': skill '%s' returned invalid JSON, skipping", job.get("name", job.get("id")), skill_name)
|
logger.warning("Cron job '%s': skill '%s' returned invalid JSON, skipping", job.get("name", job.get("id")), skill_name)
|
||||||
skipped.append(skill_name)
|
skipped.append(skill_name)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue