refactor(tools): compact credential_files, blueprints, arg_coercion

- credential_files: _walk_skill_tree unifies the os.walk/EXCLUDED_SKILL_DIRS/symlink-skip
  loop shared by _safe_skills_path and iter_skills_files (drops _iter_syncable_files);
  _remap_cache_path unifies map_cache_path_to_container/from_agent_visible_cache_path;
  _iter_regular_files inlined into its only caller iter_cache_files.
- arg_coercion: _SCALAR_COERCERS dispatch table replaces the if/elif type ladder in
  _coerce_value; identity-preserving normalize helpers compacted. Logger name and
  every message text unchanged.
- blueprints: frontmatter split, job-spec and export compacted; __all__ unchanged.
This commit is contained in:
Teknium
2026-09-02 22:36:15 -07:00
parent b428887b98
commit ffbd5ef633
3 changed files with 194 additions and 445 deletions

View File

@@ -2,8 +2,8 @@
Models emit "42" for integers, "true" for booleans, JSON-encoded strings for
arrays/objects (also nested inside containers), and bare scalars where an array
is expected. Coercion is schema-guided and conservative: originals are kept
whenever a repair is not unambiguous.
is expected (wrapped in a one-element list). Coercion is schema-guided and
conservative: originals are kept whenever a repair is not unambiguous.
"""
import json
@@ -18,20 +18,12 @@ logger = logging.getLogger("model_tools")
def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Coerce string-typed args to their JSON-Schema types; originals kept on failure.
Models emit "42" for integers, "true" for booleans, JSON-encoded strings for
arrays/objects (also nested inside containers), and bare scalars where an
array is expected (wrapped in a one-element list).
"""
"""Coerce string-typed args to their JSON-Schema types; originals kept on failure."""
if not args or not isinstance(args, dict):
return args
schema = registry.get_schema(tool_name)
if not schema:
return args
properties = (schema.get("parameters") or {}).get("properties")
properties = ((schema or {}).get("parameters") or {}).get("properties")
if not properties:
return args
@@ -48,43 +40,33 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
if not prop_schema:
continue
expected = prop_schema.get("type")
is_container = isinstance(value, (list, tuple))
# Bare non-list value for an array schema. Strings go through
# _coerce_value first so a JSON-encoded array is parsed and a nullable
# "null" becomes None (not ["null"]). None itself is preserved: the tool's
# own default handling decides between "omit" and "empty list".
if expected == "array" and value is not None and not isinstance(value, (list, tuple)):
if expected == "array" and value is not None and not is_container:
if isinstance(value, str):
coerced = _coerce_value(value, expected, schema=prop_schema)
if coerced is not value:
args[key] = coerced
continue
if value.strip().startswith("["):
logger.warning(
"coerce_tool_args: %s.%s looks like a JSON array string "
"but could not be parsed — model may have emitted a "
"JSON-encoded string instead of a native array. "
"Falling back to single-element list.",
tool_name, key,
)
logger.warning("coerce_tool_args: %s.%s looks like a JSON array string "
"but could not be parsed — model may have emitted a "
"JSON-encoded string instead of a native array. "
"Falling back to single-element list.", tool_name, key)
args[key] = [value]
logger.info(
"coerce_tool_args: wrapped bare string in list for %s.%s",
tool_name, key,
)
logger.info("coerce_tool_args: wrapped bare string in list for %s.%s", tool_name, key)
continue
args[key] = [value]
logger.info(
"coerce_tool_args: wrapped bare %s in list for %s.%s",
type(value).__name__, tool_name, key,
)
logger.info("coerce_tool_args: wrapped bare %s in list for %s.%s", type(value).__name__, tool_name, key)
continue
if not isinstance(value, str):
# Native container: still normalize JSON-encoded elements/sub-fields.
if (expected == "array" and isinstance(value, (list, tuple))) or (
expected == "object" and isinstance(value, dict)
):
if (expected == "array" and is_container) or (expected == "object" and isinstance(value, dict)):
args[key] = _normalize_json_strings_for_schema(value, prop_schema)
continue
if not expected and not _schema_allows_null(prop_schema):
@@ -105,13 +87,8 @@ def _schema_accepts_kind(schema: Any, kind: str) -> bool:
t = schema.get("type")
if t == kind or (isinstance(t, list) and kind in t):
return True
for union_key in ("anyOf", "oneOf", "allOf"):
branches = schema.get(union_key)
if isinstance(branches, list) and any(
_schema_accepts_kind(b, kind) for b in branches
):
return True
return False
return any(isinstance(branches := schema.get(union_key), list) and any(_schema_accepts_kind(b, kind) for b in branches)
for union_key in ("anyOf", "oneOf", "allOf"))
def _normalize_json_strings_for_schema(value: Any, schema: Any) -> Any:
@@ -128,46 +105,32 @@ def _normalize_json_strings_for_schema(value: Any, schema: Any) -> Any:
trimmed = value.strip()
expects_array = _schema_accepts_kind(schema, "array")
expects_object = _schema_accepts_kind(schema, "object")
if (expects_array and trimmed.startswith("[")) or (
expects_object and trimmed.startswith("{")
):
try:
parsed = json.loads(trimmed)
except (ValueError, TypeError):
return value
if (isinstance(parsed, list) and expects_array) or (isinstance(parsed, dict) and expects_object):
value = parsed
else:
return value
else:
if not ((expects_array and trimmed.startswith("[")) or (expects_object and trimmed.startswith("{"))):
return value
try:
parsed = json.loads(trimmed)
except (ValueError, TypeError):
return value
if not ((isinstance(parsed, list) and expects_array) or (isinstance(parsed, dict) and expects_object)):
return value
value = parsed
if isinstance(value, list):
items_schema = schema.get("items")
if not isinstance(items_schema, dict):
return value
changed = False
out = []
for item in value:
nxt = _normalize_json_strings_for_schema(item, items_schema)
changed = changed or (nxt is not item)
out.append(nxt)
return out if changed else value
out = [_normalize_json_strings_for_schema(item, items_schema) for item in value]
return out if any(n is not o for n, o in zip(out, value)) else value
if isinstance(value, dict):
props = schema.get("properties")
if not isinstance(props, dict):
return value
changed = False
out = dict(value)
for k, prop_schema in props.items():
if k not in value or not isinstance(prop_schema, dict):
continue
nxt = _normalize_json_strings_for_schema(value[k], prop_schema)
if nxt is not value[k]:
out[k] = nxt
changed = True
return out if changed else value
if k in value and isinstance(prop_schema, dict):
out[k] = _normalize_json_strings_for_schema(value[k], prop_schema)
return out if any(out[k] is not v for k, v in value.items()) else value
return value
@@ -178,23 +141,12 @@ def _coerce_value(value: str, expected_type, schema: dict | None = None):
return None
if isinstance(expected_type, list):
for t in expected_type:
result = _coerce_value(value, t, schema=schema)
if result is not value:
return result
return value
return next((r for t in expected_type if (r := _coerce_value(value, t, schema=schema)) is not value), value)
if expected_type in {"integer", "number"}:
return _coerce_number(value, integer_only=(expected_type == "integer"))
if expected_type == "boolean":
return _coerce_boolean(value)
if expected_type == "array":
return _coerce_json(value, list)
if expected_type == "object":
return _coerce_json(value, dict)
if expected_type == "null" and value.strip().lower() == "null":
return None
return value
coercer = _SCALAR_COERCERS.get(expected_type)
if coercer is not None:
return coercer(value)
return None if expected_type == "null" and value.strip().lower() == "null" else value
def _schema_allows_null(schema: dict | None) -> bool:
@@ -206,37 +158,24 @@ def _schema_allows_null(schema: dict | None) -> bool:
return True
if schema.get("nullable") is True:
return True
for union_key in ("anyOf", "oneOf"):
variants = schema.get(union_key)
if isinstance(variants, list) and any(
isinstance(v, dict) and v.get("type") == "null" for v in variants
):
return True
return False
return any(isinstance(variants := schema.get(union_key), list)
and any(isinstance(v, dict) and v.get("type") == "null" for v in variants)
for union_key in ("anyOf", "oneOf"))
def _coerce_json(value: str, expected_python_type: type):
"""json.loads *value* when the schema expects array/object; original string on mismatch."""
name = expected_python_type.__name__
try:
parsed = json.loads(value)
except (ValueError, TypeError) as exc:
logger.warning(
"coerce_tool_args: failed to parse string as JSON for expected type %s: %s",
expected_python_type.__name__,
exc,
)
logger.warning("coerce_tool_args: failed to parse string as JSON for expected type %s: %s", name, exc)
return value
if isinstance(parsed, expected_python_type):
logger.debug(
"coerce_tool_args: coerced string to %s via json.loads",
expected_python_type.__name__,
)
logger.debug("coerce_tool_args: coerced string to %s via json.loads", name)
return parsed
logger.warning(
"coerce_tool_args: JSON-parsed value is %s, expected %s — skipping coercion",
type(parsed).__name__,
expected_python_type.__name__,
)
logger.warning("coerce_tool_args: JSON-parsed value is %s, expected %s — skipping coercion",
type(parsed).__name__, name)
return value
@@ -246,20 +185,21 @@ def _coerce_number(value: str, integer_only: bool = False):
f = float(value)
except (ValueError, OverflowError):
return value
if f != f or f == float("inf") or f == float("-inf"):
if f != f or f in (float("inf"), float("-inf")):
return value # not JSON-serializable
if f == int(f):
return int(f)
if integer_only:
return value
return f
return int(f) if f == int(f) else value if integer_only else f
def _coerce_boolean(value: str):
"""Parse "true"/"false" (case-insensitive); original string otherwise."""
low = value.strip().lower()
if low == "true":
return True
if low == "false":
return False
return value
return {"true": True, "false": False}.get(value.strip().lower(), value)
# JSON-Schema scalar/container type -> coercer; "null" and unions are handled in _coerce_value.
_SCALAR_COERCERS = {
"integer": lambda v: _coerce_number(v, integer_only=True),
"number": _coerce_number,
"boolean": _coerce_boolean,
"array": lambda v: _coerce_json(v, list),
"object": lambda v: _coerce_json(v, dict),
}

View File

@@ -1,21 +1,10 @@
"""Blueprints: shareable plain-language automations layered on skills + cron.
A "blueprint" is NOT a new object type. It is an ordinary skill (a SKILL.md the
agent loads) that additionally declares an automation schedule in its
frontmatter:
metadata:
hermes:
blueprint:
schedule: "0 9 * * *" # presence of `blueprint:` marks it runnable
deliver: origin # optional (default "origin")
prompt: "..." # optional task instruction for the run
no_agent: false # optional
Because a blueprint is just a skill it rides the whole skills-hub pipeline
(search, scan, install, provenance, publish) for free; this module is only the
bridge from that frontmatter to the cron ``create_job()`` API, plus the inverse
(``export_blueprint``) that renders a cron job back into a shareable SKILL.md.
A blueprint is NOT a new object type: it is an ordinary skill whose frontmatter declares
``metadata.hermes.blueprint`` (``schedule`` required; optional ``deliver`` [default "origin"],
``prompt``, ``no_agent``, ``model``, ``provider``, ``enabled_toolsets``), so it rides the whole
skills-hub pipeline for free. This module only bridges that block to cron ``create_job()``,
plus the inverse (``export_blueprint``) back to a SKILL.md.
"""
from __future__ import annotations
@@ -27,16 +16,8 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
__all__ = [
"BlueprintSpec",
"parse_blueprint",
"blueprint_spec_for_installed",
"blueprint_to_job_spec",
"create_blueprint_job",
"register_blueprint_suggestion",
"export_blueprint",
"BlueprintError",
]
__all__ = ["BlueprintSpec", "parse_blueprint", "blueprint_spec_for_installed", "blueprint_to_job_spec",
"create_blueprint_job", "register_blueprint_suggestion", "export_blueprint", "BlueprintError"]
class BlueprintError(ValueError):
@@ -63,18 +44,12 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
if not isinstance(text, str):
return None
stripped = text.lstrip("\ufeff").lstrip() # BOM is not whitespace; strip explicitly
if not stripped.startswith("---"):
if not stripped.startswith("---") or (end := stripped.find("\n---", 3)) == -1:
return None
# Find the closing fence after the opening one.
after_open = stripped[3:]
end = after_open.find("\n---")
if end == -1:
return None
fm_text = after_open[:end]
try:
import yaml
data = yaml.safe_load(fm_text)
data = yaml.safe_load(stripped[3:end])
except Exception as e: # pragma: no cover - malformed YAML
logger.debug("blueprint: frontmatter YAML parse failed: %s", e)
return None
@@ -84,16 +59,14 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]:
"""Extract a BlueprintSpec from a SKILL.md string, or None if not a blueprint.
A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping containing
a non-empty ``schedule``. Raises BlueprintError if the block exists but is
A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping with a
non-empty ``schedule``. Raises BlueprintError if the block exists but is
structurally invalid (so a typo surfaces instead of silently no-op'ing).
"""
fm = _split_frontmatter(skill_md_text)
if not fm:
return None
name = str(fm.get("name", "")).strip()
meta = fm.get("metadata")
hermes = meta.get("hermes") if isinstance(meta, dict) else None
blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None
@@ -106,16 +79,13 @@ def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]:
if not schedule:
raise BlueprintError("blueprint.schedule is required and must be non-empty")
prompt = blueprint.get("prompt")
model = blueprint.get("model")
provider = blueprint.get("provider")
prompt, model, provider = blueprint.get("prompt"), blueprint.get("model"), blueprint.get("provider")
toolsets = blueprint.get("enabled_toolsets")
if toolsets is not None and not isinstance(toolsets, list):
raise BlueprintError("blueprint.enabled_toolsets must be a list when present")
return BlueprintSpec(
skill_name=name,
schedule=schedule,
skill_name=str(fm.get("name", "")).strip(), schedule=schedule,
deliver=str(blueprint.get("deliver", "origin")).strip() or "origin",
prompt=str(prompt) if prompt is not None else None,
no_agent=bool(blueprint.get("no_agent", False)),
@@ -133,48 +103,31 @@ def blueprint_spec_for_installed(skill_name: str) -> Optional[BlueprintSpec]:
from tools.skills_hub import SKILLS_DIR
except Exception: # pragma: no cover - import guard
return None
# Skills live at skills/<category>/<name>/SKILL.md or skills/<name>/SKILL.md.
for path in Path(SKILLS_DIR).glob(f"**/{skill_name}/SKILL.md"):
try:
text = path.read_text(encoding="utf-8")
spec = parse_blueprint(path.read_text(encoding="utf-8"))
except OSError:
continue
spec = parse_blueprint(text)
if spec is not None:
# Prefer the frontmatter name, fall back to the directory name.
if not spec.skill_name:
spec.skill_name = skill_name
spec.skill_name = spec.skill_name or skill_name # frontmatter name wins over dir name
return spec
return None
def blueprint_to_job_spec(
spec: BlueprintSpec,
*,
name: Optional[str] = None,
) -> Dict[str, Any]:
def blueprint_to_job_spec(spec: BlueprintSpec, *, name: Optional[str] = None) -> Dict[str, Any]:
"""``cron.jobs.create_job`` kwargs for a spec — the single translation used by
both ``create_blueprint_job`` and the suggestion path so they never drift."""
return {
"prompt": spec.prompt,
"schedule": spec.schedule,
"name": name or f"blueprint:{spec.skill_name}",
"deliver": spec.deliver,
"skills": [spec.skill_name] if spec.skill_name else None,
"model": spec.model,
"provider": spec.provider,
"enabled_toolsets": spec.enabled_toolsets,
"prompt": spec.prompt, "schedule": spec.schedule, "name": name or f"blueprint:{spec.skill_name}",
"deliver": spec.deliver, "skills": [spec.skill_name] if spec.skill_name else None,
"model": spec.model, "provider": spec.provider, "enabled_toolsets": spec.enabled_toolsets,
"no_agent": spec.no_agent,
}
def create_blueprint_job(
spec: BlueprintSpec,
*,
origin: Optional[Dict[str, Any]] = None,
name: Optional[str] = None,
) -> Dict[str, Any]:
def create_blueprint_job(spec: BlueprintSpec, *, origin: Optional[Dict[str, Any]] = None,
name: Optional[str] = None) -> Dict[str, Any]:
"""Create the cron job for a spec (skill preloaded via ``skills=[name]``); returns the job dict."""
from cron.scheduler import create_job_with_scheduler_registration
@@ -194,13 +147,10 @@ def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any
except Exception: # pragma: no cover - import guard
return None
deliver = f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else ""
return add_suggestion(
title=f"Schedule '{spec.skill_name}'",
description=(
f"The '{spec.skill_name}' blueprint runs on schedule {spec.schedule}"
+ (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "")
+ "."
),
description=f"The '{spec.skill_name}' blueprint runs on schedule {spec.schedule}{deliver}.",
source="blueprint",
job_spec=blueprint_to_job_spec(spec),
dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}",
@@ -213,38 +163,24 @@ def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional
``body`` becomes the SKILL.md body; its first line is the description."""
import yaml
name = blueprint_name or job.get("name") or "shared-blueprint"
# Sanitize to a valid skill identifier.
name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower())
name = name.strip("-_") or "shared-blueprint"
name = str(blueprint_name or job.get("name") or "shared-blueprint").lower()
name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in name).strip("-_") or "shared-blueprint"
blueprint_block: Dict[str, Any] = {
"schedule": job.get("schedule_display") or _schedule_to_string(job.get("schedule")),
}
block: Dict[str, Any] = {"schedule": job.get("schedule_display") or _schedule_to_string(job.get("schedule"))}
if job.get("deliver") and job["deliver"] != "origin":
blueprint_block["deliver"] = job["deliver"]
block["deliver"] = job["deliver"]
if job.get("prompt"):
blueprint_block["prompt"] = job["prompt"]
block["prompt"] = job["prompt"]
if job.get("no_agent"):
blueprint_block["no_agent"] = True
for key in ("model", "provider", "enabled_toolsets"):
if job.get(key):
blueprint_block[key] = job[key]
block["no_agent"] = True
block.update({k: job[k] for k in ("model", "provider", "enabled_toolsets") if job.get(k)})
body = body.strip()
description = body.splitlines()[0][:200] if body else "Shared automation blueprint."
frontmatter = {
"name": name,
"description": description,
"version": "1.0.0",
"license": "MIT",
"metadata": {
"hermes": {
"tags": ["blueprint", "automation"],
"blueprint": blueprint_block,
}
},
"name": name, "description": body.splitlines()[0][:200] if body else "Shared automation blueprint.",
"version": "1.0.0", "license": "MIT",
"metadata": {"hermes": {"tags": ["blueprint", "automation"], "blueprint": block}},
}
fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip()
body_text = body or f"# {name}\n\nShared automation blueprint."
@@ -260,14 +196,12 @@ def _schedule_to_string(schedule: Any) -> str:
if kind == "cron" and schedule.get("expr"):
return str(schedule["expr"])
if kind == "interval":
# parse_schedule stores interval periods as "minutes"; tolerate a
# legacy/foreign "seconds" form too.
# parse_schedule stores interval periods as "minutes"; tolerate a legacy/foreign "seconds" form too.
if schedule.get("minutes"):
mins = int(schedule["minutes"])
return f"every {mins // 60}h" if mins % 60 == 0 else f"every {mins}m"
if schedule.get("seconds"):
secs = int(schedule["seconds"])
if secs % 3600 == 0:
return f"every {secs // 3600}h"
return f"every {secs // 60}m" if secs % 60 == 0 else f"every {secs}s"
return (f"every {secs // 3600}h" if secs % 3600 == 0
else f"every {secs // 60}m" if secs % 60 == 0 else f"every {secs}s")
return "0 9 * * *" # safe daily fallback

View File

@@ -1,10 +1,8 @@
"""File passthrough registry for remote terminal backends.
"""File passthrough registry for remote terminal backends (Docker, Modal, SSH).
Remote backends (Docker, Modal, SSH) create sandboxes with no host files.
This module tells them which credential files (skill ``required_credential_files``
+ ``terminal.credential_files`` config), skill directories, and host-side cache
directories (documents, images, audio, screenshots, uploads) to mount or sync
in, at sandbox creation and before each command (resync on Modal).
Sandboxes start with no host files; this module tells them which credential files
(skill ``required_credential_files`` + ``terminal.credential_files`` config), skill
dirs, and host cache dirs to mount or sync in, at creation and before each command.
"""
from __future__ import annotations
@@ -14,7 +12,7 @@ import os
import posixpath
from contextvars import ContextVar
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Tuple
from typing import Callable, Dict, Iterator, List, Optional, Tuple
from hermes_cli.config import cfg_get
from hermes_constants import get_hermes_dir, get_hermes_home
@@ -31,91 +29,67 @@ logger = logging.getLogger(__name__)
# Session-scoped registry; ContextVar prevents cross-session bleed in the gateway.
_registered_files_var: ContextVar[Dict[str, str]] = ContextVar("_registered_files")
# Cache for config-based file list (loaded once per process).
# Cache for config-based file list (loaded once per process; tests reset it).
_config_files: List[Dict[str, str]] | None = None
# Reused across calls so sanitized skill copies don't accumulate.
_safe_skills_tempdir: Path | None = None
def _get_registered() -> Dict[str, str]:
try:
return _registered_files_var.get()
except LookupError:
val: Dict[str, str] = {}
_registered_files_var.set(val)
return val
val = _registered_files_var.get(None)
if val is None:
_registered_files_var.set(val := {})
return val
def _mount(host_path: Path | str, container_path: str) -> Dict[str, str]:
return {"host_path": str(host_path), "container_path": container_path}
def _contained_host_path(
rel: str, hermes_home: Path, abs_msg: str, traversal_msg: str
) -> Optional[Path]:
def _contained_host_path(rel: str, hermes_home: Path, abs_msg: str, traversal_msg: str) -> Optional[Path]:
"""Resolve *rel* under HERMES_HOME, refusing absolute paths and escapes."""
if os.path.isabs(rel):
logger.warning(abs_msg, rel)
return None
host_path = hermes_home / rel
# Resolve symlinks and ``..`` before the containment check.
from tools.path_security import validate_within_dir
from tools.path_security import validate_within_dir # resolves symlinks and ``..`` before checking
containment_error = validate_within_dir(host_path, hermes_home)
if containment_error:
if containment_error := validate_within_dir(host_path, hermes_home):
logger.warning(traversal_msg, rel, containment_error)
return None
return host_path.resolve()
def register_credential_file(
relative_path: str,
container_base: str = "/root/.hermes",
) -> bool:
"""Register a HERMES_HOME-relative credential file for mounting.
def register_credential_file(relative_path: str, container_base: str = "/root/.hermes") -> bool:
"""Register a HERMES_HOME-relative credential file for mounting; True if it exists and was registered.
Returns True if the file exists on the host and was registered. Rejects
absolute paths and traversal out of HERMES_HOME. Containment alone is not
enough because HERMES_HOME holds the MASTER stores (``.env``, ``auth.json``,
``mcp-tokens/``): those are refused via the canonical read deny-list
(``agent.file_safety.get_read_block_error``), so the mount surface cannot
hand a skill what the read surface denies it.
Rejects absolute paths and traversal out of HERMES_HOME. Containment alone is not
enough: HERMES_HOME holds the MASTER stores (``.env``, ``auth.json``, ``mcp-tokens/``),
which are refused via the canonical read deny-list so the mount surface cannot hand a
skill what the read surface denies. Fails CLOSED (logged) if the guard is unavailable or raises.
"""
resolved = _contained_host_path(
relative_path,
get_hermes_home(),
relative_path, get_hermes_home(),
"credential_files: rejected absolute path %r (must be relative to HERMES_HOME)",
"credential_files: rejected path traversal %r (%s)",
)
"credential_files: rejected path traversal %r (%s)")
if resolved is None:
return False
if not resolved.is_file():
logger.debug("credential_files: skipping %s (not found)", resolved)
return False
# Master stores pass the containment check above, so the deny-list is the
# real gate. Fails CLOSED: if the guard can't be consulted, refuse rather
# than risk bind-mounting auth.json into a sandbox; the import sentinel +
# logger.exception keep guard failures debuggable, not silently swallowed.
if get_read_block_error is None:
logger.error(
"credential_files: refusing %r — agent.file_safety could not be "
"imported, so the master-store deny-list cannot be consulted",
relative_path,
)
logger.error("credential_files: refusing %r — agent.file_safety could not be "
"imported, so the master-store deny-list cannot be consulted", relative_path)
return False
try:
denied = get_read_block_error(str(resolved))
except Exception:
logger.exception(
"credential_files: refusing %r — read guard raised", relative_path
)
logger.exception("credential_files: refusing %r — read guard raised", relative_path)
return False
if denied:
logger.warning(
"credential_files: refused %r — it is a credential store the agent "
"is denied from reading; a skill may mount its own service token, "
"not the master key files",
relative_path,
)
logger.warning("credential_files: refused %r — it is a credential store the agent "
"is denied from reading; a skill may mount its own service token, "
"not the master key files", relative_path)
return False
container_path = f"{container_base.rstrip('/')}/{relative_path}"
@@ -124,19 +98,15 @@ def register_credential_file(
return True
def register_credential_files(
entries: list,
container_base: str = "/root/.hermes",
) -> List[str]:
def register_credential_files(entries: list, container_base: str = "/root/.hermes") -> List[str]:
"""Register skill-frontmatter entries (str or dict with ``path``); return missing paths."""
missing = []
for entry in entries:
if isinstance(entry, str):
rel_path = entry.strip()
elif isinstance(entry, dict):
rel_path = (entry.get("path") or entry.get("name") or "").strip()
else:
if isinstance(entry, dict):
entry = entry.get("path") or entry.get("name") or ""
elif not isinstance(entry, str):
continue
rel_path = entry.strip()
if rel_path and not register_credential_file(rel_path, container_base):
missing.append(rel_path)
return missing
@@ -154,15 +124,13 @@ def _load_config_files() -> List[Dict[str, str]]:
hermes_home = get_hermes_home()
cred_files = cfg_get(read_raw_config(), "terminal", "credential_files")
for item in cred_files if isinstance(cred_files, list) else []:
if not (isinstance(item, str) and item.strip()):
rel = item.strip() if isinstance(item, str) else ""
if not rel:
continue
rel = item.strip()
resolved_path = _contained_host_path(
rel,
hermes_home,
rel, hermes_home,
"credential_files: rejected absolute config path %r",
"credential_files: rejected config path traversal %r (%s)",
)
"credential_files: rejected config path traversal %r (%s)")
if resolved_path is not None and resolved_path.is_file():
result.append(_mount(resolved_path, f"/root/.hermes/{rel}"))
except Exception as e:
@@ -173,19 +141,12 @@ def _load_config_files() -> List[Dict[str, str]]:
def get_credential_file_mounts() -> List[Dict[str, str]]:
"""Skill-registered + config credential files as ``host_path``/``container_path`` dicts."""
mounts: Dict[str, str] = {}
# Re-check existence (file may have been deleted since registration).
for container_path, host_path in _get_registered().items():
if Path(host_path).is_file():
mounts[container_path] = host_path
"""Skill-registered + config credential files as ``host_path``/``container_path`` dicts (re-checked for existence)."""
mounts = {cp: hp for cp, hp in _get_registered().items() if Path(hp).is_file()}
for entry in _load_config_files():
cp = entry["container_path"]
if cp not in mounts and Path(entry["host_path"]).is_file():
mounts[cp] = entry["host_path"]
cp, hp = entry["container_path"], entry["host_path"]
if cp not in mounts and Path(hp).is_file():
mounts[cp] = hp
return [_mount(hp, cp) for cp, hp in mounts.items()]
@@ -194,10 +155,8 @@ def get_credential_file_mounts() -> List[Dict[str, str]]:
def _skill_dir_roots(container_base: str) -> Iterator[Tuple[Path, str]]:
"""Yield ``(host_dir, container_root)`` for every existing skills directory.
Local skills mount at ``<base>/skills``, external dirs at
``<base>/external_skills/<i>``, trusted project-local dirs at
``<base>/project_skills/<i>`` (separate namespace so container paths stay
stable if external_dirs change).
Local skills mount at ``<base>/skills``, external at ``<base>/external_skills/<i>``, trusted
project-local at ``<base>/project_skills/<i>`` (own namespace so paths stay stable if external_dirs change).
"""
base = container_base.rstrip("/")
skills_dir = get_hermes_home() / "skills"
@@ -207,122 +166,67 @@ def _skill_dir_roots(container_base: str) -> Iterator[Tuple[Path, str]]:
from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs
except ImportError:
return
for label, dirs in (("external_skills", get_external_skills_dirs()),
("project_skills", get_project_skills_dirs())):
for idx, d in enumerate(dirs):
if d.is_dir():
yield d, f"{base}/{label}/{idx}"
for label, dirs in (("external_skills", get_external_skills_dirs()), ("project_skills", get_project_skills_dirs())):
yield from ((d, f"{base}/{label}/{idx}") for idx, d in enumerate(dirs) if d.is_dir())
def _iter_regular_files(host_dir: Path, container_root: str) -> Iterator[Dict[str, str]]:
"""Per-file mount entries under *host_dir*, skipping symlinks."""
for item in host_dir.rglob("*"):
if item.is_symlink() or not item.is_file():
continue
yield _mount(item, f"{container_root}/{item.relative_to(host_dir)}")
def _walk_skill_tree(root: Path) -> Iterator[Tuple[Path, List[Path]]]:
"""Yield ``(dir, regular_non_symlink_files)`` for every directory a sandbox should receive.
Prunes ``EXCLUDED_SKILL_DIRS`` *before* descending so bookkeeping/dependency trees (``.hub``,
``.archive``, ``.curator_backups``, ``node_modules``, ``.git``, ...) the remote agent never reads
are never even walked; sync thus agrees with discovery on what is skill content. Deliberately
not ``is_excluded_skill_path()``: that also prunes ``references/``, ``templates/``, ``assets/``,
``scripts/`` — progressive-disclosure files and bundled scripts the sandbox does execute.
"""
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDED_SKILL_DIRS)
base = Path(dirpath)
yield base, [f for f in (base / n for n in filenames) if not f.is_symlink() and f.is_file()]
def get_skills_directory_mount(
container_base: str = "/root/.hermes",
) -> list[Dict[str, str]]:
def get_skills_directory_mount(container_base: str = "/root/.hermes") -> list[Dict[str, str]]:
"""Directory mount entries for all skill dirs (local + external + project).
Bind mounts follow symlinks, so a dir containing any symlink is replaced by
a sanitized temp copy (regular files only); symlink-free dirs are returned
directly with zero overhead.
Bind mounts follow symlinks, so a dir containing any symlink is replaced by a sanitized
temp copy (regular files only); symlink-free dirs are returned directly, zero overhead.
"""
return [
_mount(_safe_skills_path(host_dir), container_path)
for host_dir, container_path in _skill_dir_roots(container_base)
]
_safe_skills_tempdir: Path | None = None
return [_mount(_safe_skills_path(d), cp) for d, cp in _skill_dir_roots(container_base)]
def _safe_skills_path(skills_dir: Path) -> str:
"""Return *skills_dir* if symlink-free, else a sanitized temp copy."""
"""Return *skills_dir* if symlink-free, else a sanitized temp copy (same exclusions as sync)."""
global _safe_skills_tempdir
symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()]
if not symlinks:
return str(skills_dir)
for link in symlinks:
logger.warning("credential_files: skipping symlink in skills dir: %s -> %s",
link, os.readlink(link))
logger.warning("credential_files: skipping symlink in skills dir: %s -> %s", link, os.readlink(link))
import atexit
import shutil
import tempfile
# Reuse the same temp dir across calls to avoid accumulation.
if _safe_skills_tempdir and _safe_skills_tempdir.is_dir():
shutil.rmtree(_safe_skills_tempdir, ignore_errors=True)
safe_dir = _safe_skills_tempdir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-"))
safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-"))
_safe_skills_tempdir = safe_dir
# Same exclusion rule as the per-file sync path (_iter_syncable_files):
# the sanitized copy is what gets mounted, so it must not carry the
# bookkeeping trees either. Prune before descending so a multi-GB
# .curator_backups is never even walked.
for dirpath, dirnames, filenames in os.walk(skills_dir):
dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDED_SKILL_DIRS)
base = Path(dirpath)
for base, files in _walk_skill_tree(skills_dir):
(safe_dir / base.relative_to(skills_dir)).mkdir(parents=True, exist_ok=True)
for name in filenames:
item = base / name
if item.is_symlink() or not item.is_file():
continue
for item in files:
shutil.copy2(str(item), str(safe_dir / item.relative_to(skills_dir)))
def _cleanup():
if safe_dir.is_dir():
shutil.rmtree(safe_dir, ignore_errors=True)
atexit.register(_cleanup)
atexit.register(lambda: safe_dir.is_dir() and shutil.rmtree(safe_dir, ignore_errors=True))
logger.info("credential_files: created symlink-safe skills copy at %s", safe_dir)
return str(safe_dir)
def _iter_syncable_files(root: Path):
"""Yield ``(path, rel)`` for every regular, non-symlink file under *root*
that a sandbox should receive.
Prunes ``agent.skill_utils.EXCLUDED_SKILL_DIRS`` *before* descending, so
the walk never enters local bookkeeping and dependency trees (``.hub``
download cache, ``.archive``, ``.curator_backups``, ``node_modules``,
``__pycache__``, ``.git``, ...) that the remote agent never reads — the
sync path agrees with discovery on what counts as skill content.
This deliberately does not use ``is_excluded_skill_path()``, which also
prunes ``references/``, ``templates/``, ``assets/`` and ``scripts/``.
Those hold progressive-disclosure support files and bundled scripts the
sandbox does execute, so they must keep syncing.
"""
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDED_SKILL_DIRS)
base = Path(dirpath)
for name in filenames:
item = base / name
if item.is_symlink() or not item.is_file():
continue
yield item, item.relative_to(root)
def iter_skills_files(
container_base: str = "/root/.hermes",
) -> List[Dict[str, str]]:
"""Per-file entries for all skills files (for backends that upload individually).
Skips symlinks and anything under EXCLUDED_SKILL_DIRS (see _iter_syncable_files).
"""
return [
_mount(item, f"{container_root}/{rel}")
for host_dir, container_root in _skill_dir_roots(container_base)
for item, rel in _iter_syncable_files(host_dir)
]
def iter_skills_files(container_base: str = "/root/.hermes") -> List[Dict[str, str]]:
"""Per-file entries for all skills files (for backends that upload individually)."""
return [_mount(item, f"{container_root}/{item.relative_to(host_dir)}")
for host_dir, container_root in _skill_dir_roots(container_base)
for _base, files in _walk_skill_tree(host_dir) for item in files]
# --- Cache directory mounts (documents, images, audio, videos, screenshots) ---
@@ -336,12 +240,9 @@ _CACHE_DIRS: list[tuple[str, str]] = [
("cache/screenshots", "browser_screenshots"),
("cache/web", "web_cache"),
("cache/delegation", "delegation_cache"),
# Oversized tool results (tools/tool_result_storage.py); host side is the
# single canonical location.
("cache/spillover", "cache/spillover"),
# Flat top-level desktop staging dirs (tui_gateway attach RPCs), not under
# cache/; no legacy alias, so both slots match. Mounted so vision / file
# tools inside sandbox containers can reach uploads and dropped files.
("cache/spillover", "cache/spillover"), # oversized tool results; host side is canonical
# Flat top-level desktop staging dirs (tui_gateway attach RPCs; no legacy alias),
# mounted so vision/file tools in sandboxes reach uploads and dropped files.
("images", "images"),
("attachments", "attachments"),
]
@@ -355,11 +256,9 @@ def _cache_dir_roots(container_base: str, *, create_missing: bool) -> Iterator[T
if not host_dir.is_dir():
if not create_missing:
continue
# Docker snapshots this list at container CREATION, so a dir that
# appears later would dangle for the container's life: create it
# now; an empty bind mount costs nothing. get_hermes_dir already
# picked new-vs-legacy, so creating its answer can't shadow a
# populated legacy dir.
# Docker snapshots this list at container CREATION, so a dir appearing later
# would dangle for the container's life: create it now (empty bind mount is free).
# get_hermes_dir already picked new-vs-legacy, so this can't shadow a legacy dir.
try:
host_dir.mkdir(parents=True, exist_ok=True)
except OSError:
@@ -367,44 +266,30 @@ def _cache_dir_roots(container_base: str, *, create_missing: bool) -> Iterator[T
yield host_dir, f"{base}/{new_subpath}"
def get_cache_directory_mounts(
container_base: str = "/root/.hermes",
) -> List[Dict[str, str]]:
def get_cache_directory_mounts(container_base: str = "/root/.hermes") -> List[Dict[str, str]]:
"""Bind-mount entries for each cache directory (host layout via ``get_hermes_dir``)."""
return [_mount(h, c) for h, c in _cache_dir_roots(container_base, create_missing=True)]
def map_cache_path_to_container(
host_path: str,
container_base: str = "/root/.hermes",
) -> Optional[str]:
"""POSIX container path for a host path under an auto-mounted cache dir, else None."""
path = Path(host_path)
def _remap_cache_path(path: str, container_base: str, src: str, dst: str, join: Callable[[str, Path], str]) -> Optional[str]:
"""Translate *path* from the *src* side of a cache mount to its *dst* side; None if unmounted."""
for mount in get_cache_directory_mounts(container_base=container_base):
try:
rel = path.relative_to(mount["host_path"])
except ValueError:
continue
return posixpath.join(mount["container_path"], rel.as_posix())
if Path(path).is_relative_to(mount[src]):
return join(mount[dst], Path(path).relative_to(mount[src]))
return None
def from_agent_visible_cache_path(
container_path: str,
container_base: str = "/root/.hermes",
) -> str:
def map_cache_path_to_container(host_path: str, container_base: str = "/root/.hermes") -> Optional[str]:
"""POSIX container path for a host path under an auto-mounted cache dir, else None."""
return _remap_cache_path(host_path, container_base, "host_path", "container_path", lambda root, rel: posixpath.join(root, rel.as_posix()))
def from_agent_visible_cache_path(container_path: str, container_base: str = "/root/.hermes") -> str:
"""Inverse of :func:`to_agent_visible_cache_path`; unchanged unless Docker + cache dir."""
if os.environ.get("TERMINAL_ENV", "local") != "docker":
return container_path
path = Path(container_path)
for mount in get_cache_directory_mounts(container_base=container_base):
try:
rel = path.relative_to(mount["container_path"])
except ValueError:
continue
return str(Path(mount["host_path"]) / rel)
return container_path
mapped = _remap_cache_path(container_path, container_base, "container_path", "host_path", lambda root, rel: str(Path(root) / rel))
return mapped if mapped is not None else container_path
# Backends whose file-sync lands under the remote home: ``~/.hermes`` is
@@ -412,19 +297,13 @@ def from_agent_visible_cache_path(
_HOME_RELATIVE_BACKENDS = frozenset({"ssh", "daytona", "vercel_sandbox"})
def to_agent_visible_cache_path(
host_path: str,
container_base: str = "/root/.hermes",
) -> str:
"""Translate a host cache path to where the active backend sees it.
def to_agent_visible_cache_path(host_path: str, container_base: str = "/root/.hermes") -> str:
"""Translate a host cache path to where the active backend (TERMINAL_ENV) sees it.
Per-backend base (mirrors ``_agent_cache_base_for_env`` in
tools/image_generation_tool.py): docker/modal mount/sync at
``/root/.hermes``; ssh/daytona/vercel_sandbox under ``~/.hermes``; plugin
backends declare ``cache_path_base`` (None = host paths remain correct);
local/singularity/unknown stay unchanged (Apptainer auto-binds the host
home, so translation would dangle). Backend comes from TERMINAL_ENV, as in
terminal_tool._get_environment_config.
Mirrors ``_agent_cache_base_for_env`` in tools/image_generation_tool.py: docker/modal mount at
``/root/.hermes``; ssh/daytona/vercel_sandbox under ``~/.hermes``; plugin backends declare
``cache_path_base`` (None = host paths stay correct); local/singularity/unknown unchanged
(Apptainer auto-binds the host home, so translation would dangle).
"""
backend = (os.environ.get("TERMINAL_ENV") or "local").strip().lower()
if backend in _HOME_RELATIVE_BACKENDS:
@@ -443,15 +322,11 @@ def to_agent_visible_cache_path(
return mapped if mapped is not None else host_path
def iter_cache_files(
container_base: str = "/root/.hermes",
) -> List[Dict[str, str]]:
def iter_cache_files(container_base: str = "/root/.hermes") -> List[Dict[str, str]]:
"""Per-file cache entries (Modal upload/resync); skips symlinks."""
return [
entry
for host_dir, container_root in _cache_dir_roots(container_base, create_missing=False)
for entry in _iter_regular_files(host_dir, container_root)
]
return [_mount(item, f"{root}/{item.relative_to(host_dir)}")
for host_dir, root in _cache_dir_roots(container_base, create_missing=False)
for item in host_dir.rglob("*") if not item.is_symlink() and item.is_file()]
def clear_credential_files() -> None: