feat: secure skill env setup on load (core #688)
When a skill declares required_environment_variables in its YAML frontmatter, missing env vars trigger a secure TUI prompt (identical to the sudo password widget) when the skill is loaded. Secrets flow directly to ~/.hermes/.env, never entering LLM context. Key changes: - New required_environment_variables frontmatter field for skills - Secure TUI widget (masked input, 120s timeout) - Gateway safety: messaging platforms show local setup guidance - Legacy prerequisites.env_vars normalized into new format - Remote backend handling: conservative setup_needed=True - Env var name validation, file permissions hardened to 0o600 - Redact patterns extended for secret-related JSON fields - 12 existing skills updated with prerequisites declarations - ~48 new tests covering skip, timeout, gateway, remote backends - Dynamic panel widget sizing (fixes hardcoded width from original PR) Cherry-picked from PR #723 by kshitijk4poor, rebased onto current main with conflict resolution. Fixes #688 Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
This commit is contained in:
parent
c097e56142
commit
ccfbf42844
34 changed files with 2419 additions and 403 deletions
|
|
@ -154,37 +154,31 @@ CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
|
|||
# Skills index
|
||||
# =========================================================================
|
||||
|
||||
def _read_skill_description(skill_file: Path, max_chars: int = 60) -> str:
|
||||
"""Read the description from a SKILL.md frontmatter, capped at max_chars."""
|
||||
try:
|
||||
raw = skill_file.read_text(encoding="utf-8")[:2000]
|
||||
match = re.search(
|
||||
r"^---\s*\n.*?description:\s*(.+?)\s*\n.*?^---",
|
||||
raw, re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if match:
|
||||
desc = match.group(1).strip().strip("'\"")
|
||||
if len(desc) > max_chars:
|
||||
desc = desc[:max_chars - 3] + "..."
|
||||
return desc
|
||||
except Exception as e:
|
||||
logger.debug("Failed to read skill description from %s: %s", skill_file, e)
|
||||
return ""
|
||||
def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
|
||||
"""Read a SKILL.md once and return platform compatibility, frontmatter, and description.
|
||||
|
||||
|
||||
def _skill_is_platform_compatible(skill_file: Path) -> bool:
|
||||
"""Quick check if a SKILL.md is compatible with the current OS platform.
|
||||
|
||||
Reads just enough to parse the ``platforms`` frontmatter field.
|
||||
Skills without the field (the vast majority) are always compatible.
|
||||
Returns (is_compatible, frontmatter, description). On any error, returns
|
||||
(True, {}, "") to err on the side of showing the skill.
|
||||
"""
|
||||
try:
|
||||
from tools.skills_tool import _parse_frontmatter, skill_matches_platform
|
||||
|
||||
raw = skill_file.read_text(encoding="utf-8")[:2000]
|
||||
frontmatter, _ = _parse_frontmatter(raw)
|
||||
return skill_matches_platform(frontmatter)
|
||||
|
||||
if not skill_matches_platform(frontmatter):
|
||||
return False, {}, ""
|
||||
|
||||
desc = ""
|
||||
raw_desc = frontmatter.get("description", "")
|
||||
if raw_desc:
|
||||
desc = str(raw_desc).strip().strip("'\"")
|
||||
if len(desc) > 60:
|
||||
desc = desc[:57] + "..."
|
||||
|
||||
return True, frontmatter, desc
|
||||
except Exception:
|
||||
return True # Err on the side of showing the skill
|
||||
return True, {}, ""
|
||||
|
||||
|
||||
def _read_skill_conditions(skill_file: Path) -> dict:
|
||||
|
|
@ -252,14 +246,14 @@ def build_skills_system_prompt(
|
|||
if not skills_dir.exists():
|
||||
return ""
|
||||
|
||||
# Collect skills with descriptions, grouped by category
|
||||
# Collect skills with descriptions, grouped by category.
|
||||
# Each entry: (skill_name, description)
|
||||
# Supports sub-categories: skills/mlops/training/axolotl/SKILL.md
|
||||
# → category "mlops/training", skill "axolotl"
|
||||
# -> category "mlops/training", skill "axolotl"
|
||||
skills_by_category: dict[str, list[tuple[str, str]]] = {}
|
||||
for skill_file in skills_dir.rglob("SKILL.md"):
|
||||
# Skip skills incompatible with the current OS platform
|
||||
if not _skill_is_platform_compatible(skill_file):
|
||||
is_compatible, _, desc = _parse_skill_file(skill_file)
|
||||
if not is_compatible:
|
||||
continue
|
||||
# Skip skills whose conditional activation rules exclude them
|
||||
conditions = _read_skill_conditions(skill_file)
|
||||
|
|
@ -278,7 +272,6 @@ def build_skills_system_prompt(
|
|||
else:
|
||||
category = "general"
|
||||
skill_name = skill_file.parent.name
|
||||
desc = _read_skill_description(skill_file)
|
||||
skills_by_category.setdefault(category, []).append((skill_name, desc))
|
||||
|
||||
if not skills_by_category:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ _ENV_ASSIGN_RE = re.compile(
|
|||
)
|
||||
|
||||
# JSON field patterns: "apiKey": "value", "token": "value", etc.
|
||||
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer)"
|
||||
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
|
||||
_JSON_FIELD_RE = re.compile(
|
||||
rf'("{_JSON_KEY_NAMES}")\s*:\s*"([^"]+)"',
|
||||
re.IGNORECASE,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces
|
|||
can invoke skills via /skill-name commands.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
|
@ -63,7 +64,11 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
|
|||
return _skill_commands
|
||||
|
||||
|
||||
def build_skill_invocation_message(cmd_key: str, user_instruction: str = "") -> Optional[str]:
|
||||
def build_skill_invocation_message(
|
||||
cmd_key: str,
|
||||
user_instruction: str = "",
|
||||
task_id: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""Build the user message content for a skill slash command invocation.
|
||||
|
||||
Args:
|
||||
|
|
@ -78,36 +83,74 @@ def build_skill_invocation_message(cmd_key: str, user_instruction: str = "") ->
|
|||
if not skill_info:
|
||||
return None
|
||||
|
||||
skill_md_path = Path(skill_info["skill_md_path"])
|
||||
skill_dir = Path(skill_info["skill_dir"])
|
||||
skill_name = skill_info["name"]
|
||||
skill_path = skill_info["skill_dir"]
|
||||
|
||||
try:
|
||||
content = skill_md_path.read_text(encoding='utf-8')
|
||||
from tools.skills_tool import SKILLS_DIR, skill_view
|
||||
|
||||
loaded_skill = json.loads(skill_view(skill_path, task_id=task_id))
|
||||
except Exception:
|
||||
return f"[Failed to load skill: {skill_name}]"
|
||||
|
||||
if not loaded_skill.get("success"):
|
||||
return f"[Failed to load skill: {skill_name}]"
|
||||
|
||||
content = str(loaded_skill.get("content") or "")
|
||||
skill_dir = Path(skill_info["skill_dir"])
|
||||
|
||||
parts = [
|
||||
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
|
||||
"",
|
||||
content.strip(),
|
||||
]
|
||||
|
||||
if loaded_skill.get("setup_skipped"):
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
"[Skill setup note: Required environment setup was skipped. Continue loading the skill and explain any reduced functionality if it matters.]",
|
||||
]
|
||||
)
|
||||
elif loaded_skill.get("gateway_setup_hint"):
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
f"[Skill setup note: {loaded_skill['gateway_setup_hint']}]",
|
||||
]
|
||||
)
|
||||
elif loaded_skill.get("setup_needed") and loaded_skill.get("setup_note"):
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
f"[Skill setup note: {loaded_skill['setup_note']}]",
|
||||
]
|
||||
)
|
||||
|
||||
supporting = []
|
||||
for subdir in ("references", "templates", "scripts", "assets"):
|
||||
subdir_path = skill_dir / subdir
|
||||
if subdir_path.exists():
|
||||
for f in sorted(subdir_path.rglob("*")):
|
||||
if f.is_file():
|
||||
rel = str(f.relative_to(skill_dir))
|
||||
supporting.append(rel)
|
||||
linked_files = loaded_skill.get("linked_files") or {}
|
||||
for entries in linked_files.values():
|
||||
if isinstance(entries, list):
|
||||
supporting.extend(entries)
|
||||
|
||||
if not supporting:
|
||||
for subdir in ("references", "templates", "scripts", "assets"):
|
||||
subdir_path = skill_dir / subdir
|
||||
if subdir_path.exists():
|
||||
for f in sorted(subdir_path.rglob("*")):
|
||||
if f.is_file():
|
||||
rel = str(f.relative_to(skill_dir))
|
||||
supporting.append(rel)
|
||||
|
||||
if supporting:
|
||||
skill_view_target = str(Path(skill_path).relative_to(SKILLS_DIR))
|
||||
parts.append("")
|
||||
parts.append("[This skill has supporting files you can load with the skill_view tool:]")
|
||||
for sf in supporting:
|
||||
parts.append(f"- {sf}")
|
||||
parts.append(f'\nTo view any of these, use: skill_view(name="{skill_name}", file="<path>")')
|
||||
parts.append(
|
||||
f'\nTo view any of these, use: skill_view(name="{skill_view_target}", file_path="<path>")'
|
||||
)
|
||||
|
||||
if user_instruction:
|
||||
parts.append("")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue