Skip to content

Commit 2b5d2b1

Browse files
FZ2000Ace
andauthored
fix(security): LLM memory write path traversal + skill name sanitization (#17)
* fix(security): path traversal guard on LLM memory writes + skill name sanitization * style: ruff format --------- Co-authored-by: Ace <ace@openclaw.ai>
1 parent 96ac0b3 commit 2b5d2b1

9 files changed

Lines changed: 99 additions & 7 deletions

File tree

src/appliers/base.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ class BaseApplier(ABC):
7070
# with a description of how the tool expects its memory files.
7171
MEMORY_SCHEMA: str = ""
7272

73+
# Subclasses MUST override this with the directory the LLM is allowed to
74+
# write memory files into. apply_memory_via_llm() rejects any path that
75+
# does not resolve inside this directory. Defaults to Path.home() as a
76+
# minimum guard; narrow it in each applier.
77+
MEMORY_ALLOWED_BASE: Optional[Path] = None
78+
7379
def get_manifest(self) -> ToolManifest:
7480
"""Return (or create) the manifest for this tool."""
7581
return ToolManifest(self.TOOL_NAME)
@@ -95,7 +101,15 @@ def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManife
95101
count = 0
96102

97103
for skill in skills:
98-
name = skill.get("name", "unnamed")
104+
raw_name = skill.get("name", "unnamed")
105+
try:
106+
from skills import sanitize_skill_name
107+
108+
name = sanitize_skill_name(raw_name)
109+
except (ValueError, ImportError):
110+
warning(f"Skipping skill with invalid name: {raw_name!r}")
111+
continue
112+
99113
source = source_dir / name
100114
if not source.exists():
101115
continue
@@ -212,6 +226,10 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
212226
warning(f"Raw LLM response: {response[:500]}")
213227
return 0
214228

229+
# Determine the allowed write root for this applier.
230+
# Resolving at call-time so tests can monkeypatch Path.home().
231+
allowed_base = (self.MEMORY_ALLOWED_BASE or Path.home()).resolve()
232+
215233
# Write files
216234
count = 0
217235
for op in file_ops:
@@ -222,11 +240,21 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
222240
if not file_path or content is None:
223241
continue
224242

225-
path = Path(file_path)
226-
path.parent.mkdir(parents=True, exist_ok=True)
227-
path.write_text(content, encoding="utf-8")
243+
# Security: resolve the path (collapses `..`) and assert it lands
244+
# inside the allowed base directory. Rejects prompt-injection or
245+
# hallucinated paths like /etc/cron.d/evil.
246+
resolved = Path(file_path).resolve()
247+
if not str(resolved).startswith(str(allowed_base) + "/") and resolved != allowed_base:
248+
warning(
249+
f"[security] Rejecting LLM-suggested write outside allowed path: "
250+
f"{file_path!r} (resolved: {resolved}, allowed base: {allowed_base})"
251+
)
252+
continue
253+
254+
resolved.parent.mkdir(parents=True, exist_ok=True)
255+
resolved.write_text(content, encoding="utf-8")
228256
manifest.record_memory(
229-
file_path=str(path),
257+
file_path=str(resolved),
230258
content=content,
231259
entry_ids=[e.get("entry_id") or e.get("id", "") for e in collected_memory],
232260
)

src/appliers/claude.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ def SKILL_DIR(self, value):
5050
TOOL_NAME = "claude-code"
5151
MEMORY_SCHEMA = CLAUDE_MEMORY_SCHEMA
5252

53+
@property # type: ignore[override]
54+
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
55+
return _claude_dir()
56+
5357
def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
5458
_claude_commands_dir().mkdir(parents=True, exist_ok=True)
5559
count = 0

src/appliers/copilot.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ class CopilotApplier(BaseApplier):
7272
TOOL_NAME = "github-copilot"
7373
MEMORY_SCHEMA = COPILOT_MEMORY_SCHEMA
7474

75+
@property # type: ignore[override]
76+
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
77+
# Copilot writes to .github/ in the current project directory.
78+
return Path.cwd()
79+
7580
def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
7681
count = 0
7782
for skill in skills:

src/appliers/cursor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ class CursorApplier(BaseApplier):
7777
TOOL_NAME = "cursor"
7878
MEMORY_SCHEMA = CURSOR_MEMORY_SCHEMA
7979

80+
@property # type: ignore[override]
81+
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
82+
return _cursor_dir()
83+
8084
@property
8185
def SKILL_DIR(self) -> Path: # type: ignore[override]
8286
return _cursor_rules_dir()

src/appliers/gemini.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ class GeminiApplier(BaseApplier):
7575
TOOL_NAME = "gemini-cli"
7676
MEMORY_SCHEMA = GEMINI_MEMORY_SCHEMA
7777

78+
@property # type: ignore[override]
79+
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
80+
return _gemini_dir()
81+
7882
def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
7983
return 0 # Gemini doesn't have a skills format
8084

src/appliers/openclaw.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ def SKILL_DIR(self, value):
6464
TOOL_NAME = "openclaw"
6565
MEMORY_SCHEMA = OPENCLAW_MEMORY_SCHEMA
6666

67+
@property # type: ignore[override]
68+
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
69+
return _openclaw_workspace()
70+
6771
def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
6872
_openclaw_skills_dir().mkdir(parents=True, exist_ok=True)
6973
count = 0

src/appliers/windsurf.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ class WindsurfApplier(BaseApplier):
9191
TOOL_NAME = "windsurf"
9292
MEMORY_SCHEMA = WINDSURF_MEMORY_SCHEMA
9393

94+
@property # type: ignore[override]
95+
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
96+
return _windsurf_dir()
97+
9498
def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
9599
return 0
96100

src/install.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from appliers import get_applier
1111
from cache import load_skills, merge_skills, save_skills
1212
from extractors import detect_installed_tools
13-
from skills import fetch_skill_from_repo, list_skills_in_repo, save_skill_file
13+
from skills import fetch_skill_from_repo, list_skills_in_repo, sanitize_skill_name, save_skill_file
1414

1515
_AGENTS = ["claude-code", "cursor", "gemini-cli", "github-copilot", "openclaw", "windsurf"]
1616

@@ -176,6 +176,13 @@ def install(repo, skills, install_all, targets, branch, list_only, yes):
176176
click.echo(f" not found in {repo}")
177177
continue
178178

179+
# Validate name once more before writing to disk (save_skill_file also validates)
180+
try:
181+
sanitize_skill_name(skill["name"])
182+
except ValueError as exc:
183+
click.echo(f" skipped — invalid name: {exc}", err=True)
184+
continue
185+
179186
# Save to ~/.apc/skills/<name>/SKILL.md
180187
raw_content = skill.pop("_raw_content", skill.get("body", ""))
181188
save_skill_file(skill["name"], raw_content)

src/skills.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
tool's skill directory on sync.
55
"""
66

7+
import re
78
from pathlib import Path
89
from typing import Any, Dict, List, Optional
910

@@ -16,6 +17,29 @@
1617
_GITHUB_TREE_API = "https://api.github.com/repos/{repo}/git/trees/{branch}?recursive=1"
1718
_GITHUB_RAW = "https://raw.githubusercontent.com/{repo}/{branch}/skills/{skill}/SKILL.md"
1819

20+
_SKILL_NAME_SAFE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$")
21+
22+
23+
def sanitize_skill_name(name: str) -> str:
24+
"""Return a safe skill name or raise ValueError.
25+
26+
Rules:
27+
- Basename only (strips directory components).
28+
- No path separators, no '..' traversal.
29+
- Must match [A-Za-z0-9][A-Za-z0-9_\\-]{0,63}.
30+
"""
31+
# Take basename — eliminates leading paths like "../../etc"
32+
safe = Path(name).name
33+
# Reject anything that still looks path-like after basename
34+
if not safe or safe in (".", ".."):
35+
raise ValueError(f"Invalid skill name (empty or dot): {name!r}")
36+
if not _SKILL_NAME_SAFE.match(safe):
37+
raise ValueError(
38+
f"Skill name {safe!r} contains invalid characters. "
39+
"Only letters, digits, hyphens, and underscores are allowed."
40+
)
41+
return safe
42+
1943

2044
# ---------------------------------------------------------------------------
2145
# Skills directory
@@ -31,6 +55,7 @@ def get_skills_dir() -> Path:
3155

3256
def save_skill_file(skill_name: str, raw_content: str) -> Path:
3357
"""Save raw SKILL.md to ~/.apc/skills/<name>/SKILL.md. Returns the path."""
58+
skill_name = sanitize_skill_name(skill_name) # raises ValueError on traversal
3459
skill_dir = get_skills_dir() / skill_name
3560
skill_dir.mkdir(exist_ok=True)
3661
path = skill_dir / "SKILL.md"
@@ -86,8 +111,15 @@ def fetch_skill_from_repo(
86111
return None
87112

88113
metadata, body = parse_frontmatter(resp.text)
114+
# Sanitize the name from frontmatter — it comes from an untrusted source.
115+
raw_name = metadata.get("name", skill_name)
116+
try:
117+
safe_name = sanitize_skill_name(raw_name)
118+
except ValueError:
119+
# Fall back to the URL-path component (already validated by list_skills_in_repo)
120+
safe_name = sanitize_skill_name(skill_name)
89121
return {
90-
"name": metadata.get("name", skill_name),
122+
"name": safe_name,
91123
"description": metadata.get("description", ""),
92124
"body": body.strip(),
93125
"tags": metadata.get("tags", []),

0 commit comments

Comments
 (0)