|
9 | 9 |
|
10 | 10 | from .config import SKILLS_ENABLED, SKILLS_DIR, SKILLS_EXEC_TIMEOUT, logger |
11 | 11 |
|
| 12 | +import re |
| 13 | + |
12 | 14 | # Maximum characters of SKILL.md instructions to include in tool description |
13 | 15 | _MAX_INSTRUCTIONS_LENGTH = 2000 |
14 | 16 |
|
| 17 | +# Tool names must be alphanumeric + hyphens/underscores, max 64 chars |
| 18 | +_VALID_TOOL_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') |
| 19 | + |
15 | 20 |
|
16 | 21 | def _parse_skill_md(content: str) -> Optional[dict]: |
17 | 22 | """Parse SKILL.md content into frontmatter dict + instructions body. |
@@ -120,14 +125,35 @@ async def startup_skills(self) -> None: |
120 | 125 | logger.warning(f"[SKILLS-STARTUP] Skipping '{entry.name}': invalid SKILL.md frontmatter") |
121 | 126 | continue |
122 | 127 |
|
| 128 | + if not _VALID_TOOL_NAME_RE.match(parsed["name"]): |
| 129 | + logger.warning( |
| 130 | + f"[SKILLS-STARTUP] Skipping '{entry.name}': " |
| 131 | + f"skill name '{parsed['name']}' contains invalid characters " |
| 132 | + f"(must be alphanumeric, hyphens, underscores, max 64 chars)" |
| 133 | + ) |
| 134 | + continue |
| 135 | + |
123 | 136 | scripts_dir = entry / "scripts" |
124 | 137 | references_dir = entry / "references" |
125 | 138 |
|
126 | 139 | available_scripts: List[str] = [] |
127 | 140 | if scripts_dir.is_dir(): |
| 141 | + scripts_dir_resolved = scripts_dir.resolve() |
128 | 142 | for script_file in sorted(scripts_dir.iterdir()): |
129 | | - if script_file.is_file() and os.access(script_file, os.X_OK): |
130 | | - available_scripts.append(script_file.name) |
| 143 | + if not script_file.is_file() or not os.access(script_file, os.X_OK): |
| 144 | + continue |
| 145 | + # Reject symlinks pointing outside scripts/ |
| 146 | + try: |
| 147 | + resolved = script_file.resolve() |
| 148 | + except OSError: |
| 149 | + continue |
| 150 | + if not str(resolved).startswith(str(scripts_dir_resolved) + os.sep): |
| 151 | + logger.warning( |
| 152 | + f"[SKILLS-STARTUP] Skipping symlink '{script_file.name}' " |
| 153 | + f"in skill '{parsed['name']}': points outside scripts/" |
| 154 | + ) |
| 155 | + continue |
| 156 | + available_scripts.append(script_file.name) |
131 | 157 |
|
132 | 158 | if not available_scripts: |
133 | 159 | logger.warning(f"[SKILLS-STARTUP] Skill '{parsed['name']}' has no executable scripts in scripts/") |
@@ -289,11 +315,19 @@ async def execute_skill_tool(self, tool_name: str, arguments: dict) -> str: |
289 | 315 | logger.warning(f"[SKILLS-EXEC] {error_msg}") |
290 | 316 | raise RuntimeError(error_msg) |
291 | 317 |
|
| 318 | + # Include stderr warnings in output when script succeeds |
| 319 | + output = stdout_text |
| 320 | + if stderr_text: |
| 321 | + if output: |
| 322 | + output += "\n\n[stderr]\n" + stderr_text |
| 323 | + else: |
| 324 | + output = stderr_text |
| 325 | + |
292 | 326 | logger.info( |
293 | 327 | f"[SKILLS-EXEC] Skill '{skill.name}' script '{script_name}' " |
294 | | - f"completed successfully ({len(stdout_text)} chars output)" |
| 328 | + f"completed successfully ({len(output)} chars output)" |
295 | 329 | ) |
296 | | - return stdout_text if stdout_text else "(no output)" |
| 330 | + return output if output else "(no output)" |
297 | 331 |
|
298 | 332 | except asyncio.TimeoutError: |
299 | 333 | logger.error( |
|
0 commit comments