2026-02-19 18:25:53 -08:00
#!/usr/bin/env python3
"""
Skill Manager Tool - - Agent - Managed Skill Creation & Editing
Allows the agent to create , update , and delete skills , turning successful
approaches into reusable procedural knowledge . New skills are created in
~ / . hermes / skills / . Existing skills ( bundled , hub - installed , or user - created )
can be modified or deleted wherever they live .
Skills are the agent ' s procedural memory: they capture *how to do a specific
type of task * based on proven experience . General memory ( MEMORY . md , USER . md ) is
broad and declarative . Skills are narrow and actionable .
Actions :
create - - Create a new skill ( SKILL . md + directory structure )
edit - - Replace the SKILL . md content of a user skill ( full rewrite )
patch - - Targeted find - and - replace within SKILL . md or any supporting file
delete - - Remove a user skill entirely
write_file - - Add / overwrite a supporting file ( reference , template , script , asset )
remove_file - - Remove a supporting file from a user skill
Directory layout for user skills :
~ / . hermes / skills /
├ ─ ─ my - skill /
│ ├ ─ ─ SKILL . md
│ ├ ─ ─ references /
│ ├ ─ ─ templates /
│ ├ ─ ─ scripts /
│ └ ─ ─ assets /
└ ─ ─ category - name /
└ ─ ─ another - skill /
└ ─ ─ SKILL . md
"""
import json
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
import logging
2026-02-19 18:25:53 -08:00
import os
import re
import shutil
2026-03-07 00:49:10 +03:00
import tempfile
2026-02-19 18:25:53 -08:00
from pathlib import Path
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
from hermes_constants import get_hermes_home
2026-02-19 18:25:53 -08:00
from typing import Dict , Any , Optional
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
logger = logging . getLogger ( __name__ )
# Import security scanner — agent-created skills get the same scrutiny as
# community hub installs.
try :
from tools . skills_guard import scan_skill , should_allow_install , format_scan_report
_GUARD_AVAILABLE = True
except ImportError :
_GUARD_AVAILABLE = False
def _security_scan_skill ( skill_dir : Path ) - > Optional [ str ] :
""" Scan a skill directory after write. Returns error string if blocked, else None. """
if not _GUARD_AVAILABLE :
return None
try :
result = scan_skill ( skill_dir , source = " agent-created " )
allowed , reason = should_allow_install ( result )
2026-03-22 03:56:02 -07:00
if allowed is False :
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
report = format_scan_report ( result )
return f " Security scan blocked this skill ( { reason } ): \n { report } "
2026-03-22 03:56:02 -07:00
if allowed is None :
# "ask" — allow but include the warning so the user sees the findings
report = format_scan_report ( result )
logger . warning ( " Agent-created skill has security findings: %s " , reason )
# Don't block — return None to allow, but log the warning
return None
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
except Exception as e :
2026-03-16 15:25:30 +03:00
logger . warning ( " Security scan failed for %s : %s " , skill_dir , e , exc_info = True )
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
return None
2026-02-19 18:25:53 -08:00
import yaml
# All skills live in ~/.hermes/skills/ (single source of truth)
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
HERMES_HOME = get_hermes_home ( )
2026-02-19 18:25:53 -08:00
SKILLS_DIR = HERMES_HOME / " skills "
MAX_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024
# Characters allowed in skill names (filesystem-safe, URL-friendly)
VALID_NAME_RE = re . compile ( r ' ^[a-z0-9][a-z0-9._-]*$ ' )
# Subdirectories allowed for write_file/remove_file
ALLOWED_SUBDIRS = { " references " , " templates " , " scripts " , " assets " }
def check_skill_manage_requirements ( ) - > bool :
""" Skill management has no external requirements -- always available. """
return True
# =============================================================================
# Validation helpers
# =============================================================================
def _validate_name ( name : str ) - > Optional [ str ] :
""" Validate a skill name. Returns error message or None if valid. """
if not name :
return " Skill name is required. "
if len ( name ) > MAX_NAME_LENGTH :
return f " Skill name exceeds { MAX_NAME_LENGTH } characters. "
if not VALID_NAME_RE . match ( name ) :
return (
f " Invalid skill name ' { name } ' . Use lowercase letters, numbers, "
f " hyphens, dots, and underscores. Must start with a letter or digit. "
)
return None
2026-03-29 20:08:22 -07:00
def _validate_category ( category : Optional [ str ] ) - > Optional [ str ] :
""" Validate an optional category name used as a single directory segment. """
if category is None :
return None
if not isinstance ( category , str ) :
return " Category must be a string. "
category = category . strip ( )
if not category :
return None
if " / " in category or " \\ " in category :
return (
f " Invalid category ' { category } ' . Use lowercase letters, numbers, "
" hyphens, dots, and underscores. Categories must be a single directory name. "
)
if len ( category ) > MAX_NAME_LENGTH :
return f " Category exceeds { MAX_NAME_LENGTH } characters. "
if not VALID_NAME_RE . match ( category ) :
return (
f " Invalid category ' { category } ' . Use lowercase letters, numbers, "
" hyphens, dots, and underscores. Categories must be a single directory name. "
)
return None
2026-02-19 18:25:53 -08:00
def _validate_frontmatter ( content : str ) - > Optional [ str ] :
"""
Validate that SKILL . md content has proper frontmatter with required fields .
Returns error message or None if valid .
"""
if not content . strip ( ) :
return " Content cannot be empty. "
if not content . startswith ( " --- " ) :
return " SKILL.md must start with YAML frontmatter (---). See existing skills for format. "
end_match = re . search ( r ' \ n--- \ s* \ n ' , content [ 3 : ] )
if not end_match :
return " SKILL.md frontmatter is not closed. Ensure you have a closing ' --- ' line. "
yaml_content = content [ 3 : end_match . start ( ) + 3 ]
try :
parsed = yaml . safe_load ( yaml_content )
except yaml . YAMLError as e :
return f " YAML frontmatter parse error: { e } "
if not isinstance ( parsed , dict ) :
return " Frontmatter must be a YAML mapping (key: value pairs). "
if " name " not in parsed :
return " Frontmatter must include ' name ' field. "
if " description " not in parsed :
return " Frontmatter must include ' description ' field. "
if len ( str ( parsed [ " description " ] ) ) > MAX_DESCRIPTION_LENGTH :
return f " Description exceeds { MAX_DESCRIPTION_LENGTH } characters. "
body = content [ end_match . end ( ) + 3 : ] . strip ( )
if not body :
return " SKILL.md must have content after the frontmatter (instructions, procedures, etc.). "
return None
def _resolve_skill_dir ( name : str , category : str = None ) - > Path :
""" Build the directory path for a new skill, optionally under a category. """
if category :
return SKILLS_DIR / category / name
return SKILLS_DIR / name
def _find_skill ( name : str ) - > Optional [ Dict [ str , Any ] ] :
"""
Find a skill by name in ~ / . hermes / skills / .
Returns { " path " : Path } or None .
"""
if not SKILLS_DIR . exists ( ) :
return None
for skill_md in SKILLS_DIR . rglob ( " SKILL.md " ) :
if skill_md . parent . name == name :
return { " path " : skill_md . parent }
return None
def _validate_file_path ( file_path : str ) - > Optional [ str ] :
"""
Validate a file path for write_file / remove_file .
Must be under an allowed subdirectory and not escape the skill dir .
"""
if not file_path :
return " file_path is required. "
normalized = Path ( file_path )
# Prevent path traversal
if " .. " in normalized . parts :
return " Path traversal ( ' .. ' ) is not allowed. "
# Must be under an allowed subdirectory
if not normalized . parts or normalized . parts [ 0 ] not in ALLOWED_SUBDIRS :
allowed = " , " . join ( sorted ( ALLOWED_SUBDIRS ) )
return f " File must be under one of: { allowed } . Got: ' { file_path } ' "
# Must have a filename (not just a directory)
if len ( normalized . parts ) < 2 :
return f " Provide a file path, not just a directory. Example: ' { normalized . parts [ 0 ] } /myfile.md ' "
return None
2026-03-07 00:49:10 +03:00
def _atomic_write_text ( file_path : Path , content : str , encoding : str = " utf-8 " ) - > None :
"""
Atomically write text content to a file .
Uses a temporary file in the same directory and os . replace ( ) to ensure
the target file is never left in a partially - written state if the process
crashes or is interrupted .
Args :
file_path : Target file path
content : Content to write
encoding : Text encoding ( default : utf - 8 )
"""
file_path . parent . mkdir ( parents = True , exist_ok = True )
fd , temp_path = tempfile . mkstemp (
dir = str ( file_path . parent ) ,
prefix = f " . { file_path . name } .tmp. " ,
suffix = " " ,
)
try :
with os . fdopen ( fd , " w " , encoding = encoding ) as f :
f . write ( content )
os . replace ( temp_path , file_path )
except Exception :
# Clean up temp file on error
try :
os . unlink ( temp_path )
except OSError :
2026-03-16 15:25:30 +03:00
logger . error ( " Failed to remove temporary file %s during atomic write " , temp_path , exc_info = True )
2026-03-07 00:49:10 +03:00
raise
2026-02-19 18:25:53 -08:00
# =============================================================================
# Core actions
# =============================================================================
def _create_skill ( name : str , content : str , category : str = None ) - > Dict [ str , Any ] :
""" Create a new user skill with SKILL.md content. """
# Validate name
err = _validate_name ( name )
if err :
return { " success " : False , " error " : err }
2026-03-29 20:08:22 -07:00
err = _validate_category ( category )
if err :
return { " success " : False , " error " : err }
2026-02-19 18:25:53 -08:00
# Validate content
err = _validate_frontmatter ( content )
if err :
return { " success " : False , " error " : err }
# Check for name collisions across all directories
existing = _find_skill ( name )
if existing :
return {
" success " : False ,
" error " : f " A skill named ' { name } ' already exists at { existing [ ' path ' ] } . "
}
# Create the skill directory
skill_dir = _resolve_skill_dir ( name , category )
skill_dir . mkdir ( parents = True , exist_ok = True )
2026-03-07 00:49:10 +03:00
# Write SKILL.md atomically
2026-02-19 18:25:53 -08:00
skill_md = skill_dir / " SKILL.md "
2026-03-07 00:49:10 +03:00
_atomic_write_text ( skill_md , content )
2026-02-19 18:25:53 -08:00
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
# Security scan — roll back on block
scan_error = _security_scan_skill ( skill_dir )
if scan_error :
shutil . rmtree ( skill_dir , ignore_errors = True )
return { " success " : False , " error " : scan_error }
2026-02-19 18:25:53 -08:00
result = {
" success " : True ,
" message " : f " Skill ' { name } ' created. " ,
" path " : str ( skill_dir . relative_to ( SKILLS_DIR ) ) ,
" skill_md " : str ( skill_md ) ,
}
if category :
result [ " category " ] = category
result [ " hint " ] = (
" To add reference files, templates, or scripts, use "
" skill_manage(action= ' write_file ' , name= ' {} ' , file_path= ' references/example.md ' , file_content= ' ... ' ) " . format ( name )
)
return result
def _edit_skill ( name : str , content : str ) - > Dict [ str , Any ] :
""" Replace the SKILL.md of any existing skill (full rewrite). """
err = _validate_frontmatter ( content )
if err :
return { " success " : False , " error " : err }
existing = _find_skill ( name )
if not existing :
return { " success " : False , " error " : f " Skill ' { name } ' not found. Use skills_list() to see available skills. " }
skill_md = existing [ " path " ] / " SKILL.md "
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
# Back up original content for rollback
original_content = skill_md . read_text ( encoding = " utf-8 " ) if skill_md . exists ( ) else None
2026-03-07 00:49:10 +03:00
_atomic_write_text ( skill_md , content )
2026-02-19 18:25:53 -08:00
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
# Security scan — roll back on block
scan_error = _security_scan_skill ( existing [ " path " ] )
if scan_error :
if original_content is not None :
2026-03-07 00:49:10 +03:00
_atomic_write_text ( skill_md , original_content )
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
return { " success " : False , " error " : scan_error }
2026-02-19 18:25:53 -08:00
return {
" success " : True ,
" message " : f " Skill ' { name } ' updated. " ,
" path " : str ( existing [ " path " ] ) ,
}
def _patch_skill (
name : str ,
old_string : str ,
new_string : str ,
file_path : str = None ,
replace_all : bool = False ,
) - > Dict [ str , Any ] :
""" Targeted find-and-replace within a skill file.
Defaults to SKILL . md . Use file_path to patch a supporting file instead .
Requires a unique match unless replace_all is True .
"""
if not old_string :
return { " success " : False , " error " : " old_string is required for ' patch ' . " }
if new_string is None :
return { " success " : False , " error " : " new_string is required for ' patch ' . Use an empty string to delete matched text. " }
existing = _find_skill ( name )
if not existing :
return { " success " : False , " error " : f " Skill ' { name } ' not found. " }
skill_dir = existing [ " path " ]
if file_path :
# Patching a supporting file
err = _validate_file_path ( file_path )
if err :
return { " success " : False , " error " : err }
target = skill_dir / file_path
else :
# Patching SKILL.md
target = skill_dir / " SKILL.md "
if not target . exists ( ) :
return { " success " : False , " error " : f " File not found: { target . relative_to ( skill_dir ) } " }
content = target . read_text ( encoding = " utf-8 " )
count = content . count ( old_string )
if count == 0 :
# Show a short preview of the file so the model can self-correct
preview = content [ : 500 ] + ( " ... " if len ( content ) > 500 else " " )
return {
" success " : False ,
" error " : " old_string not found in the file. " ,
" file_preview " : preview ,
}
if count > 1 and not replace_all :
return {
" success " : False ,
" error " : (
f " old_string matched { count } times. Provide more surrounding context "
f " to make the match unique, or set replace_all=true to replace all occurrences. "
) ,
" match_count " : count ,
}
new_content = content . replace ( old_string , new_string ) if replace_all else content . replace ( old_string , new_string , 1 )
# If patching SKILL.md, validate frontmatter is still intact
if not file_path :
err = _validate_frontmatter ( new_content )
if err :
return {
" success " : False ,
" error " : f " Patch would break SKILL.md structure: { err } " ,
}
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
original_content = content # for rollback
2026-03-07 00:49:10 +03:00
_atomic_write_text ( target , new_content )
2026-02-19 18:25:53 -08:00
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
# Security scan — roll back on block
scan_error = _security_scan_skill ( skill_dir )
if scan_error :
2026-03-07 00:49:10 +03:00
_atomic_write_text ( target , original_content )
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
return { " success " : False , " error " : scan_error }
2026-02-19 18:25:53 -08:00
replacements = count if replace_all else 1
return {
" success " : True ,
" message " : f " Patched { ' SKILL.md ' if not file_path else file_path } in skill ' { name } ' ( { replacements } replacement { ' s ' if replacements > 1 else ' ' } ). " ,
}
def _delete_skill ( name : str ) - > Dict [ str , Any ] :
""" Delete a skill. """
existing = _find_skill ( name )
if not existing :
return { " success " : False , " error " : f " Skill ' { name } ' not found. " }
skill_dir = existing [ " path " ]
shutil . rmtree ( skill_dir )
# Clean up empty category directories (don't remove SKILLS_DIR itself)
parent = skill_dir . parent
if parent != SKILLS_DIR and parent . exists ( ) and not any ( parent . iterdir ( ) ) :
parent . rmdir ( )
return {
" success " : True ,
" message " : f " Skill ' { name } ' deleted. " ,
}
def _write_file ( name : str , file_path : str , file_content : str ) - > Dict [ str , Any ] :
""" Add or overwrite a supporting file within any skill directory. """
err = _validate_file_path ( file_path )
if err :
return { " success " : False , " error " : err }
if not file_content and file_content != " " :
return { " success " : False , " error " : " file_content is required. " }
existing = _find_skill ( name )
if not existing :
return { " success " : False , " error " : f " Skill ' { name } ' not found. Create it first with action= ' create ' . " }
target = existing [ " path " ] / file_path
target . parent . mkdir ( parents = True , exist_ok = True )
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
# Back up for rollback
original_content = target . read_text ( encoding = " utf-8 " ) if target . exists ( ) else None
2026-03-07 00:49:10 +03:00
_atomic_write_text ( target , file_content )
2026-02-19 18:25:53 -08:00
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
# Security scan — roll back on block
scan_error = _security_scan_skill ( existing [ " path " ] )
if scan_error :
if original_content is not None :
2026-03-07 00:49:10 +03:00
_atomic_write_text ( target , original_content )
Harden agent attack surface: scan writes to memory, skills, cron, and context files
The security scanner (skills_guard.py) was only wired into the hub install path.
All other write paths to persistent state — skills created by the agent, memory
entries, cron prompts, and context files — bypassed it entirely. This closes
those gaps:
- file_operations: deny-list blocks writes to ~/.ssh, ~/.aws, ~/.hermes/.env, etc.
- code_execution_tool: filter secret env vars from sandbox child process
- skill_manager_tool: wire scan_skill() into create/edit/patch/write_file with rollback
- skills_guard: add "agent-created" trust level (same policy as community)
- memory_tool: scan content for injection/exfil before system prompt injection
- prompt_builder: scan AGENTS.md, .cursorrules, SOUL.md for prompt injection
- cronjob_tools: scan cron prompts for critical threats before scheduling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:43:15 -05:00
else :
target . unlink ( missing_ok = True )
return { " success " : False , " error " : scan_error }
2026-02-19 18:25:53 -08:00
return {
" success " : True ,
" message " : f " File ' { file_path } ' written to skill ' { name } ' . " ,
" path " : str ( target ) ,
}
def _remove_file ( name : str , file_path : str ) - > Dict [ str , Any ] :
""" Remove a supporting file from any skill directory. """
err = _validate_file_path ( file_path )
if err :
return { " success " : False , " error " : err }
existing = _find_skill ( name )
if not existing :
return { " success " : False , " error " : f " Skill ' { name } ' not found. " }
skill_dir = existing [ " path " ]
target = skill_dir / file_path
if not target . exists ( ) :
# List what's actually there for the model to see
available = [ ]
for subdir in ALLOWED_SUBDIRS :
d = skill_dir / subdir
if d . exists ( ) :
for f in d . rglob ( " * " ) :
if f . is_file ( ) :
available . append ( str ( f . relative_to ( skill_dir ) ) )
return {
" success " : False ,
" error " : f " File ' { file_path } ' not found in skill ' { name } ' . " ,
" available_files " : available if available else None ,
}
target . unlink ( )
# Clean up empty subdirectories
parent = target . parent
if parent != skill_dir and parent . exists ( ) and not any ( parent . iterdir ( ) ) :
parent . rmdir ( )
return {
" success " : True ,
" message " : f " File ' { file_path } ' removed from skill ' { name } ' . " ,
}
# =============================================================================
# Main entry point
# =============================================================================
def skill_manage (
action : str ,
name : str ,
content : str = None ,
category : str = None ,
file_path : str = None ,
file_content : str = None ,
old_string : str = None ,
new_string : str = None ,
replace_all : bool = False ,
) - > str :
"""
Manage user - created skills . Dispatches to the appropriate action handler .
Returns JSON string with results .
"""
if action == " create " :
if not content :
return json . dumps ( { " success " : False , " error " : " content is required for ' create ' . Provide the full SKILL.md text (frontmatter + body). " } , ensure_ascii = False )
result = _create_skill ( name , content , category )
elif action == " edit " :
if not content :
return json . dumps ( { " success " : False , " error " : " content is required for ' edit ' . Provide the full updated SKILL.md text. " } , ensure_ascii = False )
result = _edit_skill ( name , content )
elif action == " patch " :
if not old_string :
return json . dumps ( { " success " : False , " error " : " old_string is required for ' patch ' . Provide the text to find. " } , ensure_ascii = False )
if new_string is None :
return json . dumps ( { " success " : False , " error " : " new_string is required for ' patch ' . Use empty string to delete matched text. " } , ensure_ascii = False )
result = _patch_skill ( name , old_string , new_string , file_path , replace_all )
elif action == " delete " :
result = _delete_skill ( name )
elif action == " write_file " :
if not file_path :
return json . dumps ( { " success " : False , " error " : " file_path is required for ' write_file ' . Example: ' references/api-guide.md ' " } , ensure_ascii = False )
if file_content is None :
return json . dumps ( { " success " : False , " error " : " file_content is required for ' write_file ' . " } , ensure_ascii = False )
result = _write_file ( name , file_path , file_content )
elif action == " remove_file " :
if not file_path :
return json . dumps ( { " success " : False , " error " : " file_path is required for ' remove_file ' . " } , ensure_ascii = False )
result = _remove_file ( name , file_path )
else :
result = { " success " : False , " error " : f " Unknown action ' { action } ' . Use: create, edit, patch, delete, write_file, remove_file " }
2026-03-27 10:54:02 -07:00
if result . get ( " success " ) :
try :
from agent . prompt_builder import clear_skills_system_prompt_cache
clear_skills_system_prompt_cache ( clear_snapshot = True )
except Exception :
pass
2026-02-19 18:25:53 -08:00
return json . dumps ( result , ensure_ascii = False )
# =============================================================================
# OpenAI Function-Calling Schema
# =============================================================================
SKILL_MANAGE_SCHEMA = {
" name " : " skill_manage " ,
" description " : (
" Manage skills (create, update, delete). Skills are your procedural "
" memory — reusable approaches for recurring task types. "
" New skills go to ~/.hermes/skills/; existing skills can be modified wherever they live. \n \n "
" Actions: create (full SKILL.md + optional category), "
" patch (old_string/new_string — preferred for fixes), "
" edit (full SKILL.md rewrite — major overhauls only), "
" delete, write_file, remove_file. \n \n "
" Create when: complex task succeeded (5+ calls), errors overcome, "
" user-corrected approach worked, non-trivial workflow discovered, "
" or user asks you to remember a procedure. \n "
" Update when: instructions stale/wrong, OS-specific failures, "
2026-03-16 06:52:32 -07:00
" missing steps or pitfalls found during use. "
" If you used a skill and hit issues not covered by it, patch it immediately. \n \n "
2026-02-19 18:25:53 -08:00
" After difficult/iterative tasks, offer to save as a skill. "
" Skip for simple one-offs. Confirm with user before creating/deleting. \n \n "
" Good skills: trigger conditions, numbered steps with exact commands, "
" pitfalls section, verification steps. Use skill_view() to see format examples. "
) ,
" parameters " : {
" type " : " object " ,
" properties " : {
" action " : {
" type " : " string " ,
" enum " : [ " create " , " patch " , " edit " , " delete " , " write_file " , " remove_file " ] ,
" description " : " The action to perform. "
} ,
" name " : {
" type " : " string " ,
" description " : (
" Skill name (lowercase, hyphens/underscores, max 64 chars). "
" Must match an existing skill for patch/edit/delete/write_file/remove_file. "
)
} ,
" content " : {
" type " : " string " ,
" description " : (
" Full SKILL.md content (YAML frontmatter + markdown body). "
" Required for ' create ' and ' edit ' . For ' edit ' , read the skill "
" first with skill_view() and provide the complete updated text. "
)
} ,
" old_string " : {
" type " : " string " ,
" description " : (
" Text to find in the file (required for ' patch ' ). Must be unique "
" unless replace_all=true. Include enough surrounding context to "
" ensure uniqueness. "
)
} ,
" new_string " : {
" type " : " string " ,
" description " : (
" Replacement text (required for ' patch ' ). Can be empty string "
" to delete the matched text. "
)
} ,
" replace_all " : {
" type " : " boolean " ,
" description " : " For ' patch ' : replace all occurrences instead of requiring a unique match (default: false). "
} ,
" category " : {
" type " : " string " ,
" description " : (
" Optional category/domain for organizing the skill (e.g., ' devops ' , "
" ' data-science ' , ' mlops ' ). Creates a subdirectory grouping. "
" Only used with ' create ' . "
)
} ,
" file_path " : {
" type " : " string " ,
" description " : (
" Path to a supporting file within the skill directory. "
" For ' write_file ' / ' remove_file ' : required, must be under references/, "
" templates/, scripts/, or assets/. "
" For ' patch ' : optional, defaults to SKILL.md if omitted. "
)
} ,
" file_content " : {
" type " : " string " ,
" description " : " Content for the file. Required for ' write_file ' . "
} ,
} ,
" required " : [ " action " , " name " ] ,
} ,
}
2026-02-21 20:22:33 -08:00
# --- Registry ---
from tools . registry import registry
registry . register (
name = " skill_manage " ,
toolset = " skills " ,
schema = SKILL_MANAGE_SCHEMA ,
handler = lambda args , * * kw : skill_manage (
action = args . get ( " action " , " " ) ,
name = args . get ( " name " , " " ) ,
content = args . get ( " content " ) ,
category = args . get ( " category " ) ,
file_path = args . get ( " file_path " ) ,
file_content = args . get ( " file_content " ) ,
old_string = args . get ( " old_string " ) ,
new_string = args . get ( " new_string " ) ,
replace_all = args . get ( " replace_all " , False ) ) ,
2026-03-15 20:21:21 -07:00
emoji = " 📝 " ,
2026-02-21 20:22:33 -08:00
)