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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions src/appliers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,13 @@ class BaseApplier(ABC):

# Subclasses MUST override this with the directory the LLM is allowed to
# write memory files into. apply_memory_via_llm() rejects any path that
# does not resolve inside this directory. Defaults to Path.home() as a
# minimum guard; narrow it in each applier.
# does not resolve inside this directory.
#
# Subclasses that set MEMORY_SCHEMA MUST also override MEMORY_ALLOWED_BASE
# to a narrow directory (e.g. ~/.claude, ~/.cursor). The base class
# raises RuntimeError if MEMORY_SCHEMA is non-empty and MEMORY_ALLOWED_BASE
# is still None — this prevents accidental whole-home writes when a new
# applier forgets to set the guard.
MEMORY_ALLOWED_BASE: Optional[Path] = None

def get_manifest(self) -> ToolManifest:
Expand Down Expand Up @@ -163,6 +168,17 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
if not collected_memory:
return 0

# Guard: subclasses MUST override MEMORY_ALLOWED_BASE when they set
# MEMORY_SCHEMA. Fail loudly here (before any LLM call) so a missing
# override is caught at the start of the sync, not after an expensive
# network round-trip (#37).
if self.MEMORY_ALLOWED_BASE is None:
raise RuntimeError(
f"{self.__class__.__name__} sets MEMORY_SCHEMA but did not override "
"MEMORY_ALLOWED_BASE. Set MEMORY_ALLOWED_BASE to a narrow directory "
"(e.g. Path.home() / '.claude') to restrict LLM-driven file writes."
)

# Try to import and call LLM
try:
from llm_client import call_llm
Expand Down Expand Up @@ -226,9 +242,9 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
warning(f"Raw LLM response: {response[:500]}")
return 0

# Determine the allowed write root for this applier.
# Resolving at call-time so tests can monkeypatch Path.home().
allowed_base = (self.MEMORY_ALLOWED_BASE or Path.home()).resolve()
# Resolve at call-time so tests can monkeypatch Path.home() or the property.
# (MEMORY_ALLOWED_BASE is guaranteed non-None by the guard above.)
allowed_base = self.MEMORY_ALLOWED_BASE.resolve()

# Write files
count = 0
Expand All @@ -240,10 +256,11 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
if not file_path or content is None:
continue

# Security: resolve the path (collapses `..`) and assert it lands
# inside the allowed base directory. Rejects prompt-injection or
# hallucinated paths like /etc/cron.d/evil.
resolved = Path(file_path).resolve()
# Security: expand ~ and resolve (collapses `..`), then assert the
# path lands inside the allowed base directory. Rejects
# prompt-injection or hallucinated paths like /etc/cron.d/evil,
# and also handles LLM output that uses "~/" tilde notation (#38).
resolved = Path(file_path).expanduser().resolve()
if not str(resolved).startswith(str(allowed_base) + "/") and resolved != allowed_base:
warning(
f"[security] Rejecting LLM-suggested write outside allowed path: "
Expand Down
46 changes: 33 additions & 13 deletions src/appliers/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@
from appliers.base import BaseApplier
from appliers.manifest import ToolManifest


def _copilot_instructions() -> Path:
return Path.cwd() / ".github" / "copilot-instructions.md"


def _copilot_instructions_dir() -> Path:
return Path.cwd() / ".github" / "instructions"


def _vscode_mcp_json() -> Path:
return Path.cwd() / ".vscode" / "mcp.json"


# Module-level aliases kept for backward compatibility with extractors
# (evaluated lazily through the functions above inside the class methods)
COPILOT_INSTRUCTIONS = Path(".github") / "copilot-instructions.md"
COPILOT_INSTRUCTIONS_DIR = Path(".github") / "instructions"
VSCODE_MCP_JSON = Path(".vscode") / "mcp.json"
Expand Down Expand Up @@ -76,19 +91,22 @@ class CopilotApplier(BaseApplier):

@property # type: ignore[override]
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
# Copilot writes to .github/ in the current project directory.
return Path.cwd()
# Copilot writes to .github/ / .vscode/ in the current project directory.
# Using the resolved CWD ensures a stable absolute path even if the
# calling process later changes directory (#42).
return Path.cwd().resolve()

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
count = 0
instructions = _copilot_instructions()
for skill in skills:
if skill.get("name") == "copilot-instructions":
COPILOT_INSTRUCTIONS.parent.mkdir(parents=True, exist_ok=True)
instructions.parent.mkdir(parents=True, exist_ok=True)
content = skill.get("body", "")
COPILOT_INSTRUCTIONS.write_text(content, encoding="utf-8")
instructions.write_text(content, encoding="utf-8")
manifest.record_skill(
"copilot-instructions",
file_path=str(COPILOT_INSTRUCTIONS.resolve()),
file_path=str(instructions.resolve()),
content=content,
)
count += 1
Expand All @@ -101,9 +119,10 @@ def apply_mcp_servers(
manifest: ToolManifest,
override: bool = False,
) -> int:
if VSCODE_MCP_JSON.exists():
vscode_mcp = _vscode_mcp_json()
if vscode_mcp.exists():
try:
data = json.loads(VSCODE_MCP_JSON.read_text(encoding="utf-8"))
data = json.loads(vscode_mcp.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
else:
Expand Down Expand Up @@ -143,7 +162,6 @@ def apply_mcp_servers(
count += 1

data["servers"] = vscode_servers
vscode_mcp = Path(VSCODE_MCP_JSON).resolve()
vscode_mcp.parent.mkdir(parents=True, exist_ok=True)
vscode_mcp.write_text(json.dumps(data, indent=2), encoding="utf-8")
# Restrict to owner-only since the file may contain resolved API keys (#32)
Expand All @@ -153,16 +171,18 @@ def apply_mcp_servers(
def _read_existing_memory_files(self) -> Dict[str, str]:
"""Return {file_path: content} for Copilot's instruction files."""
result = {}
if COPILOT_INSTRUCTIONS.exists():
instructions = _copilot_instructions()
instructions_dir = _copilot_instructions_dir()
if instructions.exists():
try:
result[str(COPILOT_INSTRUCTIONS)] = COPILOT_INSTRUCTIONS.read_text(encoding="utf-8")
result[str(instructions.resolve())] = instructions.read_text(encoding="utf-8")
except IOError:
pass
if COPILOT_INSTRUCTIONS_DIR.exists():
for path in COPILOT_INSTRUCTIONS_DIR.glob("*.instructions.md"):
if instructions_dir.exists():
for path in instructions_dir.glob("*.instructions.md"):
if path.is_file():
try:
result[str(path)] = path.read_text(encoding="utf-8")
result[str(path.resolve())] = path.read_text(encoding="utf-8")
except IOError:
pass
return result
210 changes: 210 additions & 0 deletions tests/test_security_llm_memory_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""Tests for LLM memory write path restriction fix (#37, #38-#43)."""

import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch


class TestMemoryAllowedBaseGuard(unittest.TestCase):
"""#37 — MEMORY_ALLOWED_BASE too broad: missing override must be caught."""

def test_no_allowed_base_raises_runtime_error(self):
"""Applier with MEMORY_SCHEMA but no MEMORY_ALLOWED_BASE must raise."""
from appliers.base import BaseApplier
from appliers.manifest import ToolManifest

class BadApplier(BaseApplier):
TOOL_NAME = "bad-tool"
MEMORY_SCHEMA = "Write to ~/.bad/file.md"
# MEMORY_ALLOWED_BASE intentionally NOT overridden (stays None)

@property
def SKILL_DIR(self):
return Path(tempfile.mkdtemp())

def apply_skills(self, skills, manifest):
return 0

def apply_mcp_servers(self, servers, secrets, manifest, override=False):
return 0

def _read_existing_memory_files(self):
return {}

applier = BadApplier()
manifest = ToolManifest("bad-tool", path=Path(tempfile.mkdtemp()) / "m.json")

# RuntimeError is raised before any LLM call, no patching needed
with self.assertRaises(RuntimeError) as ctx:
applier.apply_memory_via_llm(
[{"id": "x", "source_tool": "t", "content": "hello"}], manifest
)

self.assertIn("MEMORY_ALLOWED_BASE", str(ctx.exception))

def test_correct_allowed_base_does_not_raise(self):
"""An applier that properly sets MEMORY_ALLOWED_BASE works without error."""
from appliers.base import BaseApplier
from appliers.manifest import ToolManifest

tmpdir = Path(tempfile.mkdtemp())

class GoodApplier(BaseApplier):
TOOL_NAME = "good-tool"
MEMORY_SCHEMA = "Write to a specific narrow directory."

@property
def MEMORY_ALLOWED_BASE(self):
return tmpdir

@property
def SKILL_DIR(self):
return tmpdir / "skills"

def apply_skills(self, skills, manifest):
return 0

def apply_mcp_servers(self, servers, secrets, manifest, override=False):
return 0

def _read_existing_memory_files(self):
return {}

applier = GoodApplier()
manifest = ToolManifest("good-tool", path=tmpdir / "m.json")

# LLM call fails with no model configured — that's fine;
# we just check it doesn't raise RuntimeError for the base guard.
with patch("llm_client.call_llm", side_effect=Exception("no model")):
result = applier.apply_memory_via_llm(
[{"id": "x", "source_tool": "t", "content": "hello"}], manifest
)
# Returns 0 due to LLM exception, not RuntimeError
self.assertEqual(result, 0)


class TestExpandUserInLLMWritePath(unittest.TestCase):
"""#38-#43 — LLM output with tilde paths must be resolved correctly."""

def test_tilde_path_inside_allowed_base_accepted(self):
"""A tilde path that expands to inside MEMORY_ALLOWED_BASE is accepted."""
from appliers.base import BaseApplier
from appliers.manifest import ToolManifest

# Use the real home dir as allowed base (simulating ~/.claude)
allowed_base = Path.home() / ".test-apc-temp-allowed"

class TildeApplier(BaseApplier):
TOOL_NAME = "tilde-tool"
MEMORY_SCHEMA = "Write to the allowed directory."

@property
def MEMORY_ALLOWED_BASE(self):
return allowed_base

@property
def SKILL_DIR(self):
return Path(tempfile.mkdtemp())

def apply_skills(self, skills, manifest):
return 0

def apply_mcp_servers(self, servers, secrets, manifest, override=False):
return 0

def _read_existing_memory_files(self):
return {}

applier = TildeApplier()
manifest = ToolManifest("tilde-tool", path=Path(tempfile.mkdtemp()) / "m.json")

# Simulate LLM returning a tilde path inside the allowed base
tilde_path = "~/.test-apc-temp-allowed/memory.md"
file_ops = [{"file_path": tilde_path, "content": "# Memory\nSome content"}]

written_files = []

def mock_write(path, content):
written_files.append(str(path))

with (
patch("llm_client.call_llm", return_value=str(file_ops).replace("'", '"')),
patch(
"appliers.memory_section.write_memory_file",
side_effect=lambda p, c, **kw: written_files.append(str(p)),
),
):
# Create the parent dir so the write succeeds
allowed_base.mkdir(parents=True, exist_ok=True)
try:
applier.apply_memory_via_llm(
[{"id": "x", "source_tool": "t", "content": "hello"}], manifest
)
finally:
import shutil

if allowed_base.exists():
shutil.rmtree(allowed_base)

# The tilde path should have been resolved and accepted (no security rejection)
# (If rejected, written_files would be empty)
# We just verify no exception was raised with the tilde path

def test_path_outside_allowed_base_rejected(self):
"""A path outside MEMORY_ALLOWED_BASE is rejected even after expanduser."""
from appliers.base import BaseApplier
from appliers.manifest import ToolManifest

tmpdir = Path(tempfile.mkdtemp())
allowed_base = tmpdir / "safe"
allowed_base.mkdir()

class StrictApplier(BaseApplier):
TOOL_NAME = "strict-tool"
MEMORY_SCHEMA = "Write only inside the safe dir."

@property
def MEMORY_ALLOWED_BASE(self):
return allowed_base

@property
def SKILL_DIR(self):
return tmpdir / "skills"

def apply_skills(self, skills, manifest):
return 0

def apply_mcp_servers(self, servers, secrets, manifest, override=False):
return 0

def _read_existing_memory_files(self):
return {}

applier = StrictApplier()
manifest = ToolManifest("strict-tool", path=tmpdir / "m.json")

# LLM tries to write outside the allowed base
import json as _json

evil_ops = [{"file_path": "/etc/passwd", "content": "evil"}]
warnings_issued = []

with (
patch("llm_client.call_llm", return_value=_json.dumps(evil_ops)),
patch("appliers.base.warning", side_effect=lambda m: warnings_issued.append(m)),
):
result = applier.apply_memory_via_llm(
[{"id": "x", "source_tool": "t", "content": "hello"}], manifest
)

# Should return 0 and issue a warning (not write the file)
self.assertEqual(result, 0)
self.assertTrue(
any("/etc/passwd" in w for w in warnings_issued),
f"Expected rejection warning for /etc/passwd, got: {warnings_issued}",
)


if __name__ == "__main__":
unittest.main()