Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,9 @@ reader = plugin_reference_tool(skill, toolset="temporal-awareness")

Called with no `file_path`, it returns every file under `references_dir`
(recursively, as relative POSIX paths). Called with `file_path` set to a
path relative to `references_dir`, it returns that file's content. A
path relative to `references_dir`, it returns that file's content. It also
accepts the equivalent skill-relative `references/<path>` form commonly
authored in `SKILL.md` files. A
`file_path` that resolves outside `references_dir` — including through a
symlink, an absolute path, or a `../` chain — is rejected rather than
followed; a non-string `file_path` is rejected with a clean error instead of
Expand Down
14 changes: 10 additions & 4 deletions hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1381,8 +1381,9 @@ def plugin_reference_tool(

Call with no arguments (or ``file_path=None``) to list every file under ``references_dir``.
Call with ``file_path`` set to a path relative to ``references_dir`` to read that file's
content; a path that resolves outside ``references_dir`` (including via a symlink) is
rejected.
content. Skill-relative paths beginning with ``references/`` are accepted too, matching the
conventional paths authored in ``SKILL.md`` files. A path that resolves outside
``references_dir`` (including via a symlink) is rejected.

Returns a ready-to-register tool function -- include it in the plugin's own declarations
passed to ``register_plugin``. Raises :class:`ValueError` immediately if ``skill`` has no
Expand All @@ -1405,7 +1406,8 @@ def plugin_reference_tool(
tool_name = name or _default_reference_tool_name(skill.name)
tool_description = description or (
f"List or read companion reference files for the {skill.name!r} skill. Omit file_path "
"to list every available file; pass file_path to read one file's content."
"to list every available file; pass either a listed path or its skill-relative "
"references/<path> form to read one file's content."
)

def _read_plugin_reference(args: dict, **_: Any) -> dict:
Expand All @@ -1422,7 +1424,11 @@ def _read_plugin_reference(args: dict, **_: Any) -> dict:
if not isinstance(file_path, str):
raise TypeError(f"file_path must be a string, got {type(file_path).__name__}")

candidate = (references_dir / file_path).resolve()
path = Path(file_path)
if path.parts[:1] == ("references",):
path = Path(*path.parts[1:])

candidate = (references_dir / path).resolve()
try:
candidate.relative_to(resolved_root)
except ValueError:
Expand Down
2 changes: 1 addition & 1 deletion skills/hermes-plugins/references/plugin-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ guidance, not a second implementation specification.
| Request or execution middleware | `@middleware`, `MiddlewareKind` | `register_plugin` | Callback is synchronous; request phases replace payloads, execution phases call single-use `next_call`. |
| Lifecycle hook | `@hook` | `register_plugin` | Hermes kwargs and return values pass through; exceptions are re-raised for Hermes isolation. |
| Plugin-owned skill | `plugin_skill` | `register_plugin(..., skills=...)` | Hermes adds the plugin namespace; missing required skills fail, optional skills warn and skip. Optional `references_dir` (a companion reference-files directory) is forward-compatible groundwork only — no released host surfaces it yet; see [`README.md`](../../../README.md#commands-middleware-hooks-and-plugin-skills). |
| Agent-visible skill reference files (today) | `plugin_reference_tool` | `register_plugin` (its return value is an ordinary `@tool` function — add it to the plugin's declarations) | Sidesteps `register_skill` entirely by exposing `references_dir` as a normal tool call; rejects any `file_path` that resolves outside `references_dir` (symlink, absolute path, or `../`) and any non-string `file_path`. `references_dir` itself must be inside the skill's own directory — enforced by `plugin_skill` and re-checked here. |
| Agent-visible skill reference files (today) | `plugin_reference_tool` | `register_plugin` (its return value is an ordinary `@tool` function — add it to the plugin's declarations) | Sidesteps `register_skill` entirely by exposing `references_dir` as a normal tool call; accepts both paths listed by the tool and skill-relative `references/<path>` forms, while rejecting any path that resolves outside `references_dir` (symlink, absolute path, or `../`) and any non-string `file_path`. `references_dir` itself must be inside the skill's own directory — enforced by `plugin_skill` and re-checked here. |
| Context engine | Hermes `ContextEngine` instance | `register_plugin(..., context_engine=...)` | Singular native engine registration; schemas and recovery dispatch stay in `get_tool_schemas()` / `handle_tool_call()`, never duplicated with `@tool`. |
| Host-managed call | `invoke_host_tool` | None | Use for supported non-registry capabilities such as `send_message`; pre/post-tool hooks remain active. |
| Local media delivery | `MediaPayload`, `MediaType`, `deliver_media` | Consumer registers suppression hooks | File must be absolute, present, and non-empty; `origin` resolves from task-local Hermes context. |
Expand Down
19 changes: 19 additions & 0 deletions tests/test_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2318,6 +2318,25 @@ def test_reads_a_specific_file(self) -> None:
self.assertTrue(payload["success"])
self.assertEqual("hello world", payload["data"]["content"])

def test_reads_skill_relative_paths_with_references_prefix(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill = self._skill_with_references(
tmp,
**{
"a.md": "top-level",
"nested/b.md": "nested",
},
)
reader = hpk.plugin_reference_tool(skill, toolset="sample")

top_level = json.loads(reader({"file_path": "references/a.md"}))
nested = json.loads(reader({"file_path": "references/nested/b.md"}))

self.assertTrue(top_level["success"])
self.assertEqual("top-level", top_level["data"]["content"])
self.assertTrue(nested["success"])
self.assertEqual("nested", nested["data"]["content"])

def test_accepts_custom_name_and_description(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill = self._skill_with_references(tmp, **{"a.md": "hi"})
Expand Down