From f891d702dfeb5351f8020e621ee257c40bffa0a8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:27:36 -0700 Subject: [PATCH] feat: project-local skill discovery with per-repo trust gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions started inside a git checkout now source skills from /.hermes/skills/ and /.agents/skills/ (the cross-tool convention shared with other agent harnesses) as the highest-precedence skill tier: project > local > external_dirs. Loading is trust-gated per repo (skills.trusted_project_dirs, managed by 'hermes skills trust'/'untrust') because skills are executable procedure documents — auto-sourcing them from any cloned repo is a prompt-injection vector. Untrusted repos with skills get a one-line banner notice instead. - agent/skill_utils.py: find_project_root, get_project_skills_dirs, get_untrusted_project_skills_root, get_scan_ordered_skills_dirs; project dirs join the curator read-only ownership boundary - agent/prompt_builder.py: project tier scanned first, entries tagged [project], same-named local entries shadowed; cache key extended - tools/skills_tool.py: skills_list scans project dirs first (first-wins); skill_view resolves cross-tier collisions in favor of the project tier (same-tier ambiguity still refuses); security warning recognizes the tier - agent/skill_commands.py + hermes_cli/commands.py: /skill-name slash commands and gateway slash menus include project skills - tools/credential_files.py: project dirs mounted into remote backends - cli.py: banner notice (loaded count / trust hint) - hermes_cli/main.py + subcommands/skills.py: hermes skills trust/untrust - config: skills.project_discovery (default on), skills.trusted_project_dirs - docs: Project-Local Skills section in skills.md - tests: tests/agent/test_project_skills.py (18 cases) Session cwd is fixed at agent build time, so the resolved tier is stable for the conversation and the system prompt stays byte-stable (cache-safe). --- agent/prompt_builder.py | 56 +++++- agent/skill_commands.py | 6 +- agent/skill_utils.py | 193 ++++++++++++++++++++- cli.py | 29 ++++ hermes_cli/commands.py | 18 +- hermes_cli/config_defaults.py | 10 ++ hermes_cli/main.py | 77 ++++++++ hermes_cli/subcommands/skills.py | 21 +++ tests/agent/test_project_skills.py | 135 ++++++++++++++ tools/credential_files.py | 25 ++- tools/skills_tool.py | 50 +++++- website/docs/user-guide/features/skills.md | 35 ++++ 12 files changed, 637 insertions(+), 18 deletions(-) create mode 100644 tests/agent/test_project_skills.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 475cfeb213..829b230a5b 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1783,8 +1783,14 @@ def build_skills_system_prompt( _home_token = None try: external_dirs = get_all_skills_dirs()[1:] # skip local (index 0) + # Trusted project-local dirs (./.hermes/skills, ./.agents/skills at + # the git root) — highest-precedence tier, scanned before local. + # Resolved once here; cwd and trust are stable for the session, so + # the index (and the system prompt) stays byte-stable. + from agent.skill_utils import get_project_skills_dirs + project_dirs = get_project_skills_dirs() - if not skills_dir.exists() and not external_dirs: + if not skills_dir.exists() and not external_dirs and not project_dirs: return "" return _build_skills_system_prompt_inner( @@ -1793,6 +1799,7 @@ def build_skills_system_prompt( available_tools, available_toolsets, compact_categories, + project_dirs=project_dirs, ) finally: if _home_token is not None: @@ -1805,14 +1812,17 @@ def _build_skills_system_prompt_inner( available_tools: "set[str] | None", available_toolsets: "set[str] | None", compact_categories: "frozenset[str] | None", + project_dirs: "list[Path] | None" = None, ) -> str: # Include the resolved platform so per-platform disabled-skill lists # produce distinct cache entries (gateway serves multiple platforms). _platform_hint = _current_session_platform_hint() disabled = get_disabled_skill_names(_platform_hint or None) + project_dirs = project_dirs or [] cache_key = ( str(skills_dir), tuple(str(d) for d in external_dirs), + tuple(str(d) for d in project_dirs), tuple(sorted(str(t) for t in (available_tools or set()))), tuple(sorted(str(ts) for ts in (available_toolsets or set()))), _platform_hint, @@ -1877,6 +1887,50 @@ def _build_skills_system_prompt_inner( continue visible_entries.append(entry) + # ── Project-local skills (highest precedence) ────────────────────── + # Scanned before the local/org pass; names claimed here shadow same-named + # profile-local skills below (that's the feature — vendored repo skills + # win inside their repo). Each entry is tagged so the model and the user + # can see where it came from. + project_names: set[str] = set() + for proj_dir in project_dirs: + if not proj_dir.exists(): + continue + for skill_file in iter_skill_index_files(proj_dir, "SKILL.md"): + try: + is_compatible, frontmatter, desc = _parse_skill_file(skill_file) + if not is_compatible: + continue + entry = _build_snapshot_entry(skill_file, proj_dir, frontmatter, desc) + fm_name = entry["frontmatter_name"] + if fm_name in project_names: + continue + if fm_name in disabled or entry["skill_name"] in disabled: + continue + if not _skill_should_show( + extract_skill_conditions(frontmatter), + available_tools, + available_toolsets, + ): + continue + project_names.add(fm_name) + skills_by_category.setdefault(entry["category"], []).append( + (fm_name, f"[project] {entry['description']}".strip()) + ) + except Exception as e: + logger.debug("Error reading project skill %s: %s", skill_file, e) + + if project_names: + # Drop profile-local entries shadowed by a project skill BEFORE the + # org-labeling pass so collision flags don't fire on intentional + # project-over-local overrides. + visible_entries = [ + e + for e in visible_entries + if (e.get("frontmatter_name") or e.get("skill_name") or "") + not in project_names + ] + # ── M2 org labeling + FAIL-LOUD collisions ───────────────────────── # An org skill lists with an explicit provenance tag. When a personal and # an org skill share a name, NEITHER silently wins: both list qualified diff --git a/agent/skill_commands.py b/agent/skill_commands.py index e524d652d1..0a021f60ff 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -428,13 +428,13 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: _skill_commands = {} try: from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names - from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs, iter_skill_index_files from hermes_cli.commands import resolve_command disabled = _get_disabled_skill_names() seen_names: set = set() - # Scan local dir first, then external dirs - dirs_to_scan = [] + # Scan project dirs first (highest precedence), then local, then external + dirs_to_scan = list(get_project_skills_dirs()) if SKILLS_DIR.exists(): dirs_to_scan.append(SKILLS_DIR) dirs_to_scan.extend(get_external_skills_dirs()) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index a07615077b..c29b27b11a 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -607,12 +607,192 @@ def get_all_skills_dirs() -> List[Path]: The local dir is always first (and always included even if it doesn't exist yet — callers handle that). External dirs follow in config order. + + NOTE: trusted project-local dirs (``./.hermes/skills`` at the git root) are + NOT part of this list — they have *higher* precedence than the local dir, + so callers that need them use :func:`get_project_skills_dirs` and scan + those roots first. See ``get_scan_ordered_skills_dirs`` for the full + precedence-ordered list. """ dirs = [get_skills_dir()] dirs.extend(get_external_skills_dirs()) return dirs +# ── Project-local skills directories ────────────────────────────────────── +# +# Repo-local skills, mirroring what OpenCode (.opencode/skill/, .agents/skills/) +# and Codex (.codex/skills/, .agents/skills/) do: a project checkout can carry +# its own skills, active only for sessions started inside that project. +# +# Two candidate roots at the project root (found by walking up from cwd to the +# first directory containing ``.git``): +# /.hermes/skills/ — Hermes-native location +# /.agents/skills/ — cross-tool convention shared with other harnesses +# +# TRUST GATE: unlike AGENTS.md (plain instruction text), skills are load-on- +# demand procedure documents an agent will follow — auto-sourcing them from any +# cloned repo is a prompt-injection vector. Project skills therefore only load +# when the project root is listed in ``skills.trusted_project_dirs`` in +# config.yaml (Codex-style per-path trust). Untrusted dirs are still +# *discoverable* via get_untrusted_project_skills_root() so the CLI can print +# a one-line "run `hermes skills trust`" notice. +# +# PRECEDENCE: trusted project skills override same-named profile/bundled +# skills (index scans project dirs first; skill_view resolves cross-tier +# collisions in favor of the project tier). This matches both competitor +# harnesses and is the point of the feature: vendored repo skills win inside +# their repo. +# +# CACHE SAFETY: cwd is fixed for the life of a session, and the trust list is +# read from config at agent build time — the resolved dirs are stable for the +# conversation, so the skills index (and with it the system prompt) stays +# byte-stable. Same contract as AGENTS.md injection and project plugins. + +PROJECT_SKILLS_SUBDIRS = ( + os.path.join(".hermes", "skills"), + os.path.join(".agents", "skills"), +) + +# Walk-up bound: don't scan the whole filesystem on pathological cwds. +_PROJECT_ROOT_MAX_DEPTH = 64 + + +def find_project_root(start: Optional[Path] = None) -> Optional[Path]: + """Locate the enclosing project root: nearest ancestor containing ``.git``. + + Returns None when cwd is not inside a git checkout. ``.git`` may be a dir + (normal clone) or a file (worktree/submodule) — both count. + """ + try: + cur = (start or Path.cwd()).resolve() + except OSError: + return None + home = Path.home().resolve() + for _ in range(_PROJECT_ROOT_MAX_DEPTH): + try: + if (cur / ".git").exists(): + # A git checkout AT the home dir (dotfiles-style) would make + # every session project-scoped; treat home itself as non-project. + if cur == home: + return None + return cur + except OSError: + return None + if cur.parent == cur: + return None + cur = cur.parent + return None + + +def _project_trusted_dirs_from_config() -> Set[Path]: + """Resolved set of trusted project roots from ``skills.trusted_project_dirs``.""" + parsed = _load_raw_config() + if not parsed: + return set() + skills_cfg = parsed.get("skills") + if not isinstance(skills_cfg, dict): + return set() + raw = skills_cfg.get("trusted_project_dirs") + if isinstance(raw, str): + raw = [raw] + if not isinstance(raw, list): + return set() + result: Set[Path] = set() + for entry in raw: + entry = str(entry).strip() + if not entry: + continue + try: + result.add(Path(os.path.expanduser(os.path.expandvars(entry))).resolve()) + except OSError: + continue + return result + + +def is_project_root_trusted(root: Path) -> bool: + """True when *root* is listed in ``skills.trusted_project_dirs``.""" + try: + return Path(root).resolve() in _project_trusted_dirs_from_config() + except OSError: + return False + + +def _candidate_project_skills_dirs(root: Path) -> List[Path]: + """Existing skill dirs under *root*, excluding the profile's own skills dir. + + The exclusion matters when HERMES_HOME itself lives inside a git checkout: + ``/.hermes/skills`` would otherwise double as both the profile-local + and the project tier. + """ + local_skills = get_skills_dir().resolve() + dirs: List[Path] = [] + for sub in PROJECT_SKILLS_SUBDIRS: + cand = root / sub + try: + if cand.is_dir() and cand.resolve() != local_skills: + dirs.append(cand.resolve()) + except OSError: + continue + return dirs + + +def get_project_skills_dirs() -> List[Path]: + """Trusted project-local skill dirs for the current cwd (may be empty). + + Empty when: not in a git checkout, no project skills dirs exist, project + discovery is disabled (``skills.project_discovery: false``), or the + project root is not trusted. + """ + parsed = _load_raw_config() + skills_cfg = parsed.get("skills") if isinstance(parsed, dict) else None + if isinstance(skills_cfg, dict) and skills_cfg.get("project_discovery") is False: + return [] + root = find_project_root() + if root is None: + return [] + if not is_project_root_trusted(root): + return [] + return _candidate_project_skills_dirs(root) + + +def get_untrusted_project_skills_root() -> Optional[Tuple[Path, int]]: + """When cwd's project has skills but is NOT trusted: (root, skill_count). + + Used by the CLI to print a one-line notice pointing at + ``hermes skills trust``. Returns None when there is nothing to notify + about (no project, no skills, already trusted, or discovery disabled). + """ + parsed = _load_raw_config() + skills_cfg = parsed.get("skills") if isinstance(parsed, dict) else None + if isinstance(skills_cfg, dict) and skills_cfg.get("project_discovery") is False: + return None + root = find_project_root() + if root is None or is_project_root_trusted(root): + return None + count = 0 + for d in _candidate_project_skills_dirs(root): + try: + count += sum(1 for _ in iter_skill_index_files(d, "SKILL.md")) + except OSError: + continue + if count == 0: + return None + return root, count + + +def get_scan_ordered_skills_dirs() -> List[Path]: + """All skill dirs in precedence order: project → local → external. + + First-wins name deduplication over this order gives project skills + priority over profile-local and external ones. + """ + dirs = list(get_project_skills_dirs()) + dirs.append(get_skills_dir()) + dirs.extend(get_external_skills_dirs()) + return dirs + + def normalize_skill_lookup_name(identifier: str) -> str: """Normalize a skill identifier to a ``skill_view()``-safe relative path. @@ -643,6 +823,10 @@ def normalize_skill_lookup_name(identifier: str) -> str: primary_root = get_skills_dir() trusted_roots = [primary_root] + try: + trusted_roots.extend(get_project_skills_dirs()) + except Exception: + pass try: trusted_roots.extend(get_external_skills_dirs()) except Exception: @@ -688,7 +872,14 @@ def is_external_skill_path(path) -> bool: not each need to re-interpret the config. """ candidate = _resolve_for_skill_ownership(path) - for root in get_external_skills_dirs(): + roots: List[Path] = list(get_external_skills_dirs()) + # Trusted project-local dirs are repo-owned — same read-only boundary + # for autonomous lifecycle maintenance as configured external dirs. + try: + roots.extend(get_project_skills_dirs()) + except Exception: + pass + for root in roots: resolved_root = _resolve_for_skill_ownership(root) try: candidate.relative_to(resolved_root) diff --git a/cli.py b/cli.py index 825dc067cc..1199f34741 100644 --- a/cli.py +++ b/cli.py @@ -8328,6 +8328,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): "[dim] Switch with: /model sonnet or /model gpt5[/]" ) + # Project-local skills: one-line status. Trusted → show count; + # untrusted-with-skills → point at `hermes skills trust`. Never raises. + try: + from agent.skill_utils import ( + get_project_skills_dirs, + get_untrusted_project_skills_root, + iter_skill_index_files, + ) + _proj_dirs = get_project_skills_dirs() + if _proj_dirs: + _n = sum( + sum(1 for _ in iter_skill_index_files(d, "SKILL.md")) + for d in _proj_dirs + ) + if _n: + self._console_print( + f"[dim]◆ {_n} project skill(s) loaded from this repo[/]" + ) + else: + _untrusted = get_untrusted_project_skills_root() + if _untrusted is not None: + _root, _n = _untrusted + self._console_print( + f"[yellow]◆ {_n} project skill(s) found in {_root} but not " + f"loaded — run `hermes skills trust` to enable them.[/]" + ) + except Exception: + logger.debug("project skills banner notice failed", exc_info=True) + self._console_print() def _restore_session_cwd(self, session_meta: dict, *, quiet: bool = False) -> None: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index e515b4c213..350ee7411c 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -976,11 +976,12 @@ def _collect_gateway_skill_entries( try: from agent.skill_commands import get_skill_commands from tools.skills_tool import SKILLS_DIR - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs _skills_dir = str(SKILLS_DIR.resolve()) _hub_dir = str((SKILLS_DIR / ".hub").resolve()).rstrip("/") + "/" # Build set of allowed directory prefixes: local skills dir + any - # user-configured ``skills.external_dirs``. Ensure each prefix ends + # user-configured ``skills.external_dirs`` + trusted project dirs. + # Ensure each prefix ends # with ``/`` so ``/my-skills`` does not also match ``/my-skills-extra``. # Without this widening, external skills are visible in # ``hermes skills list`` and the agent's ``/skill-name`` dispatch but @@ -989,6 +990,9 @@ def _collect_gateway_skill_entries( _allowed_prefixes.extend( str(d).rstrip("/") + "/" for d in get_external_skills_dirs() ) + _allowed_prefixes.extend( + str(d).rstrip("/") + "/" for d in get_project_skills_dirs() + ) skill_cmds = get_skill_commands() for cmd_key in sorted(skill_cmds): info = skill_cmds[cmd_key] @@ -1156,7 +1160,7 @@ def discord_skill_commands_by_category( try: from agent.skill_commands import get_skill_commands - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs from tools.skills_tool import SKILLS_DIR _skills_dir = SKILLS_DIR.resolve() @@ -1173,6 +1177,14 @@ def discord_skill_commands_by_category( continue except Exception: pass + try: + for proj in get_project_skills_dirs(): + try: + _scan_roots.append(_P(proj).resolve()) + except Exception: + continue + except Exception: + pass skill_cmds = get_skill_commands() for cmd_key in sorted(skill_cmds): diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 2e9ee39858..cbff4bc7af 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1964,6 +1964,16 @@ DEFAULT_CONFIG = { # always goes to ~/.hermes/skills/. "skills": { "external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"] + # Project-local skill discovery: when a session starts inside a git + # checkout, ``/.hermes/skills/`` and ``/.agents/skills/`` + # are sourced as the highest-precedence skill tier — but ONLY when the + # project root is listed in trusted_project_dirs below. Trust a repo + # with ``hermes skills trust`` (run from inside it). Set to false to + # disable discovery entirely (no scan, no untrusted-skills notice). + "project_discovery": True, + # Absolute paths of project roots whose repo-local skills may load. + # Managed by ``hermes skills trust`` / ``hermes skills untrust``. + "trusted_project_dirs": [], # Substitute ${HERMES_SKILL_DIR} and ${HERMES_SESSION_ID} in SKILL.md # content with the absolute skill directory and the active session id # before the agent sees it. Lets skill authors reference bundled diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 575c87ff48..299c82a5bd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12048,12 +12048,89 @@ def cmd_skills(args): from hermes_cli.skills_config import skills_command as skills_config_command skills_config_command(args) + elif getattr(args, "skills_action", None) in ("trust", "untrust"): + _cmd_skills_trust(args) else: from hermes_cli.skills_hub import skills_command skills_command(args) +def _cmd_skills_trust(args): + """``hermes skills trust [path]`` / ``hermes skills untrust [path]``. + + Manages ``skills.trusted_project_dirs`` in config.yaml. With no path, + operates on the project root enclosing the current directory (nearest + ancestor with ``.git``). + """ + from pathlib import Path + + from agent.skill_utils import ( + PROJECT_SKILLS_SUBDIRS, + _candidate_project_skills_dirs, + find_project_root, + iter_skill_index_files, + ) + from hermes_cli.config import load_config, save_config + + action = args.skills_action + raw_path = getattr(args, "path", None) + if raw_path: + root = Path(raw_path).expanduser().resolve() + if not root.is_dir(): + print(f"Not a directory: {root}") + return + else: + root = find_project_root() + if root is None: + print( + "Not inside a git checkout. Run from a project directory or " + "pass the project root path explicitly." + ) + return + + config = load_config() + skills_cfg = config.setdefault("skills", {}) + trusted = skills_cfg.get("trusted_project_dirs") or [] + if not isinstance(trusted, list): + trusted = [trusted] + trusted = [str(t) for t in trusted] + root_str = str(root) + + if action == "untrust": + kept = [t for t in trusted if str(Path(t).expanduser().resolve()) != root_str] + if len(kept) == len(trusted): + print(f"{root} was not trusted.") + return + skills_cfg["trusted_project_dirs"] = kept + save_config(config) + print(f"Untrusted: {root}") + print("Project skills from this repo will no longer load.") + return + + # trust + if any(str(Path(t).expanduser().resolve()) == root_str for t in trusted): + print(f"Already trusted: {root}") + else: + trusted.append(root_str) + skills_cfg["trusted_project_dirs"] = trusted + save_config(config) + print(f"Trusted: {root}") + + # Show what this unlocks + count = 0 + for d in _candidate_project_skills_dirs(root): + count += sum(1 for _ in iter_skill_index_files(d, "SKILL.md")) + if count: + print( + f"{count} project skill(s) will load in sessions started inside " + "this repo (they take precedence over same-named profile skills)." + ) + else: + subdirs = " or ".join(PROJECT_SKILLS_SUBDIRS) + print(f"No project skills found yet — add them under {subdirs}.") + + def cmd_pairing(args): from hermes_cli.pairing import pairing_command diff --git a/hermes_cli/subcommands/skills.py b/hermes_cli/subcommands/skills.py index 1f016b2f5b..6c2aa85d22 100644 --- a/hermes_cli/subcommands/skills.py +++ b/hermes_cli/subcommands/skills.py @@ -18,6 +18,27 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None: ) skills_subparsers = skills_parser.add_subparsers(dest="skills_action") + skills_trust = skills_subparsers.add_parser( + "trust", + help="Trust a project so its repo-local skills (./.hermes/skills, ./.agents/skills) load", + ) + skills_trust.add_argument( + "path", + nargs="?", + default=None, + help="Project root to trust (default: enclosing git checkout of cwd)", + ) + + skills_untrust = skills_subparsers.add_parser( + "untrust", help="Revoke project-skill trust for a repo" + ) + skills_untrust.add_argument( + "path", + nargs="?", + default=None, + help="Project root to untrust (default: enclosing git checkout of cwd)", + ) + skills_browse = skills_subparsers.add_parser( "browse", help="Browse all available skills (paginated)" ) diff --git a/tests/agent/test_project_skills.py b/tests/agent/test_project_skills.py new file mode 100644 index 0000000000..c2461ba00c --- /dev/null +++ b/tests/agent/test_project_skills.py @@ -0,0 +1,135 @@ +"""Tests for project-local skill discovery (skills.trusted_project_dirs).""" + +import os +from pathlib import Path + +import pytest + +import agent.skill_utils as su + + +@pytest.fixture +def project_env(tmp_path, monkeypatch): + """A temp HERMES_HOME + a git-marked project with skills in both subdirs.""" + home = tmp_path / ".hermes" + (home / "skills").mkdir(parents=True) + config = home / "config.yaml" + config.write_text("skills:\n external_dirs: []\n") + + repo = tmp_path / "proj" + (repo / ".git").mkdir(parents=True) + hs = repo / ".hermes" / "skills" / "repo-skill" + hs.mkdir(parents=True) + (hs / "SKILL.md").write_text( + "---\nname: repo-skill\ndescription: from repo\n---\nbody\n" + ) + ag = repo / ".agents" / "skills" / "conv-skill" + ag.mkdir(parents=True) + (ag / "SKILL.md").write_text( + "---\nname: conv-skill\ndescription: convention\n---\nbody\n" + ) + + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.chdir(repo) + su._external_dirs_cache_clear() + yield {"home": home, "repo": repo, "config": config} + su._external_dirs_cache_clear() + + +def _trust(config: Path, repo: Path) -> None: + config.write_text( + f"skills:\n external_dirs: []\n trusted_project_dirs: ['{repo}']\n" + ) + su._external_dirs_cache_clear() + + +class TestFindProjectRoot: + def test_finds_git_dir_root(self, project_env): + assert su.find_project_root() == project_env["repo"].resolve() + + def test_git_file_counts_as_marker(self, tmp_path, monkeypatch): + # Worktrees/submodules have a .git FILE, not a dir + repo = tmp_path / "wt" + repo.mkdir() + (repo / ".git").write_text("gitdir: /elsewhere\n") + monkeypatch.chdir(repo) + assert su.find_project_root() == repo.resolve() + + def test_no_git_returns_none(self, tmp_path, monkeypatch): + d = tmp_path / "plain" + d.mkdir() + monkeypatch.chdir(d) + assert su.find_project_root(start=d) is None + + def test_walks_up_from_subdir(self, project_env): + sub = project_env["repo"] / "a" / "b" + sub.mkdir(parents=True) + os.chdir(sub) + assert su.find_project_root() == project_env["repo"].resolve() + + +class TestTrustGate: + def test_untrusted_loads_nothing(self, project_env): + assert su.get_project_skills_dirs() == [] + + def test_untrusted_notice_with_count(self, project_env): + notice = su.get_untrusted_project_skills_root() + assert notice is not None + root, count = notice + assert root == project_env["repo"].resolve() + assert count == 2 + + def test_trusted_returns_both_subdirs(self, project_env): + _trust(project_env["config"], project_env["repo"]) + dirs = su.get_project_skills_dirs() + assert (project_env["repo"] / ".hermes" / "skills").resolve() in dirs + assert (project_env["repo"] / ".agents" / "skills").resolve() in dirs + + def test_trusted_no_notice(self, project_env): + _trust(project_env["config"], project_env["repo"]) + assert su.get_untrusted_project_skills_root() is None + + def test_discovery_disabled_kills_both(self, project_env): + project_env["config"].write_text( + "skills:\n project_discovery: false\n" + f" trusted_project_dirs: ['{project_env['repo']}']\n" + ) + su._external_dirs_cache_clear() + assert su.get_project_skills_dirs() == [] + assert su.get_untrusted_project_skills_root() is None + + def test_no_skills_no_notice(self, tmp_path, monkeypatch): + home = tmp_path / ".hermes" + (home / "skills").mkdir(parents=True) + (home / "config.yaml").write_text("skills: {}\n") + repo = tmp_path / "empty-proj" + (repo / ".git").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.chdir(repo) + su._external_dirs_cache_clear() + assert su.get_untrusted_project_skills_root() is None + + +class TestPrecedence: + def test_scan_order_project_first(self, project_env): + _trust(project_env["config"], project_env["repo"]) + order = su.get_scan_ordered_skills_dirs() + proj_dirs = { + (project_env["repo"] / ".hermes" / "skills").resolve(), + (project_env["repo"] / ".agents" / "skills").resolve(), + } + assert set(order[:2]) == proj_dirs + assert order[2] == su.get_skills_dir() + + def test_project_paths_are_readonly_owned(self, project_env): + _trust(project_env["config"], project_env["repo"]) + p = project_env["repo"] / ".hermes" / "skills" / "repo-skill" / "SKILL.md" + assert su.is_external_skill_path(p) is True + + def test_get_all_skills_dirs_unchanged(self, project_env): + # Backward-compat contract: local first, no project tier here. + _trust(project_env["config"], project_env["repo"]) + dirs = su.get_all_skills_dirs() + assert dirs[0] == su.get_skills_dir() + for d in dirs: + assert ".agents" not in str(d) diff --git a/tools/credential_files.py b/tools/credential_files.py index cd1234a92f..92a1e9e346 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -275,7 +275,7 @@ def get_skills_directory_mount( # Mount external skill dirs try: - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs for idx, ext_dir in enumerate(get_external_skills_dirs()): if ext_dir.is_dir(): host_path = _safe_skills_path(ext_dir) @@ -283,6 +283,15 @@ def get_skills_directory_mount( "host_path": host_path, "container_path": f"{container_base.rstrip('/')}/external_skills/{idx}", }) + # Trusted project-local skill dirs (repo checkouts). Separate + # namespace so container paths stay stable if external_dirs change. + for idx, proj_dir in enumerate(get_project_skills_dirs()): + if proj_dir.is_dir(): + host_path = _safe_skills_path(proj_dir) + mounts.append({ + "host_path": host_path, + "container_path": f"{container_base.rstrip('/')}/project_skills/{idx}", + }) except ImportError: pass @@ -362,7 +371,7 @@ def iter_skills_files( # Include external skill dirs try: - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs for idx, ext_dir in enumerate(get_external_skills_dirs()): if not ext_dir.is_dir(): continue @@ -375,6 +384,18 @@ def iter_skills_files( "host_path": str(item), "container_path": f"{container_root}/{rel}", }) + for idx, proj_dir in enumerate(get_project_skills_dirs()): + if not proj_dir.is_dir(): + continue + container_root = f"{container_base.rstrip('/')}/project_skills/{idx}" + for item in proj_dir.rglob("*"): + if item.is_symlink() or not item.is_file(): + continue + rel = item.relative_to(proj_dir) + result.append({ + "host_path": str(item), + "container_path": f"{container_root}/{rel}", + }) except ImportError: pass diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 44157ce136..57027a9fd9 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -685,7 +685,11 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: signature changes (dir/category mtimes or the disabled-set) and expires after a short TTL to bound staleness from in-place SKILL.md edits. """ - from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files + from agent.skill_utils import ( + get_external_skills_dirs, + get_project_skills_dirs, + iter_skill_index_files, + ) cache_key = _SKILLS_CACHE_KEY_DISABLED if skip_disabled else _SKILLS_CACHE_KEY_FILTERED @@ -695,8 +699,10 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: # Collect directories to scan — same resolution as the scan loop below # (_skills_dir() resolves the LIVE profile HERMES_HOME; the module-level - # SKILLS_DIR can be stale in long-lived runtimes). - dirs_to_scan: list = [] + # SKILLS_DIR can be stale in long-lived runtimes). Trusted project-local + # dirs come FIRST: first-wins dedup below gives them precedence over + # same-named local/external skills. + dirs_to_scan: list = list(get_project_skills_dirs()) active_skills_dir = _skills_dir() if active_skills_dir.exists(): dirs_to_scan.append(active_skills_dir) @@ -1194,7 +1200,7 @@ def skill_view( if bare: local_category_name = f"{namespace}/{bare}" - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs # The categorized fall-through form (namespace/bare) joins onto each # search dir too; re-validate it since `bare` is not namespace-checked. @@ -1210,8 +1216,11 @@ def skill_view( ensure_ascii=False, ) - # Build list of all skill directories to search - all_dirs = [] + # Build list of all skill directories to search. Project dirs first — + # they're the highest-precedence tier and the collision resolver + # below uses this ordering. + project_dirs = get_project_skills_dirs() + all_dirs = list(project_dirs) active_skills_dir = _skills_dir() if active_skills_dir.exists(): all_dirs.append(active_skills_dir) @@ -1310,6 +1319,30 @@ def skill_view( ): _record(None, found_md) + if len(candidates) > 1 and project_dirs: + # Cross-tier collision resolution: a project skill intentionally + # overrides a same-named local/external skill, so when at least + # one candidate lives under a trusted project dir, narrow to + # those. Ambiguity WITHIN the project tier still refuses below. + def _in_project(smd: Path) -> bool: + try: + resolved = smd.resolve() + except Exception: + resolved = smd + for pd in project_dirs: + try: + resolved.relative_to(pd) + return True + except ValueError: + continue + return False + + project_candidates = [ + (sd, smd) for sd, smd in candidates if _in_project(smd) + ] + if project_candidates: + candidates = project_candidates + if len(candidates) > 1: paths = [str(smd) for _, smd in candidates] logging.getLogger(__name__).warning( @@ -1362,11 +1395,12 @@ def skill_view( ) # Security: warn if skill is loaded from outside trusted directories - # (local skills dir + configured external_dirs are all trusted) + # (project dirs + local skills dir + configured external_dirs — i.e. + # everything in all_dirs — are trusted) _outside_skills_dir = True _trusted_dirs = [active_skills_dir.resolve()] try: - _trusted_dirs.extend(d.resolve() for d in all_dirs[1:]) + _trusted_dirs.extend(d.resolve() for d in all_dirs) except Exception: pass for _td in _trusted_dirs: diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 2c8a1663c6..dfdd1d563e 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -373,6 +373,41 @@ Paths support `~` expansion and `${VAR}` environment variable substitution. All four skills appear in your skill index. If you create a new skill called `my-custom-workflow` locally, it shadows the external version. +## Project-Local Skills + +Repos can carry their own skills, active only for sessions started inside that project — the same pattern other agent harnesses use for repo-local configuration. When you launch Hermes inside a git checkout, it looks for skills in: + +```text +/.hermes/skills/ # Hermes-native location +/.agents/skills/ # cross-tool convention (shared with other agent CLIs) +``` + +The project root is the nearest ancestor directory containing `.git` (worktrees and submodules count). + +### Trusting a project + +Skills are procedure documents the agent follows, so Hermes does **not** auto-load them from arbitrary cloned repos. The first time you run Hermes in a repo with project skills, the banner shows a notice: + +```text +◆ 3 project skill(s) found in /home/you/myproject but not loaded — run `hermes skills trust` to enable them. +``` + +Trust the repo once (from inside it, or by passing the path): + +```bash +hermes skills trust # trust the current repo +hermes skills trust ~/myproject # or explicitly +hermes skills untrust # revoke +``` + +Trusted roots are stored in `skills.trusted_project_dirs` in `~/.hermes/config.yaml`. Set `skills.project_discovery: false` to turn the feature off entirely (no scanning, no notices). + +### Precedence + +Project skills are the **highest-precedence tier**: `project → local (~/.hermes/skills/) → external_dirs`. A project skill named `deploy` overrides a same-named profile or bundled skill for sessions inside that repo — that's the point: vendored repo skills win on their home turf, without touching your global profile. Project skills are tagged `[project]` in the agent's skill index so provenance stays visible. + +Like external dirs, project skill directories are treated as repo-owned: autonomous skill maintenance (the curator) never modifies them, and new agent-created skills always go to `~/.hermes/skills/`. + ## Skill Bundles Skill bundles are tiny YAML files that group several skills under a single slash command. When you run `/`, every skill listed in the bundle loads at once — useful when a particular task always benefits from the same set of skills together.