fix(security): apply file safety to copilot acp fs

This commit is contained in:
ifrederico 2026-03-20 13:00:37 -04:00 committed by Teknium
parent 517f5e2639
commit 9b36636363
5 changed files with 295 additions and 84 deletions

View file

@ -21,6 +21,9 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from agent.file_safety import get_read_block_error, is_write_denied
from agent.redact import redact_sensitive_text
ACP_MARKER_BASE_URL = "acp://copilot" ACP_MARKER_BASE_URL = "acp://copilot"
_DEFAULT_TIMEOUT_SECONDS = 900.0 _DEFAULT_TIMEOUT_SECONDS = 900.0
@ -54,6 +57,18 @@ def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]:
} }
def _permission_denied(message_id: Any) -> dict[str, Any]:
return {
"jsonrpc": "2.0",
"id": message_id,
"result": {
"outcome": {
"outcome": "cancelled",
}
},
}
def _format_messages_as_prompt( def _format_messages_as_prompt(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
model: str | None = None, model: str | None = None,
@ -535,18 +550,13 @@ class CopilotACPClient:
params = msg.get("params") or {} params = msg.get("params") or {}
if method == "session/request_permission": if method == "session/request_permission":
response = { response = _permission_denied(message_id)
"jsonrpc": "2.0",
"id": message_id,
"result": {
"outcome": {
"outcome": "allow_once",
}
},
}
elif method == "fs/read_text_file": elif method == "fs/read_text_file":
try: try:
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
block_error = get_read_block_error(str(path))
if block_error:
raise PermissionError(block_error)
content = path.read_text() if path.exists() else "" content = path.read_text() if path.exists() else ""
line = params.get("line") line = params.get("line")
limit = params.get("limit") limit = params.get("limit")
@ -555,6 +565,8 @@ class CopilotACPClient:
start = line - 1 start = line - 1
end = start + limit if isinstance(limit, int) and limit > 0 else None end = start + limit if isinstance(limit, int) and limit > 0 else None
content = "".join(lines[start:end]) content = "".join(lines[start:end])
if content:
content = redact_sensitive_text(content)
response = { response = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": message_id, "id": message_id,
@ -567,6 +579,10 @@ class CopilotACPClient:
elif method == "fs/write_text_file": elif method == "fs/write_text_file":
try: try:
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
if is_write_denied(str(path)):
raise PermissionError(
f"Write denied: '{path}' is a protected system/credential file."
)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(params.get("content") or "")) path.write_text(str(params.get("content") or ""))
response = { response = {

111
agent/file_safety.py Normal file
View file

@ -0,0 +1,111 @@
"""Shared file safety rules used by both tools and ACP shims."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
def _hermes_home_path() -> Path:
"""Resolve the active HERMES_HOME (profile-aware) without circular imports."""
try:
from hermes_constants import get_hermes_home # local import to avoid cycles
return get_hermes_home()
except Exception:
return Path(os.path.expanduser("~/.hermes"))
def build_write_denied_paths(home: str) -> set[str]:
"""Return exact sensitive paths that must never be written."""
hermes_home = _hermes_home_path()
return {
os.path.realpath(p)
for p in [
os.path.join(home, ".ssh", "authorized_keys"),
os.path.join(home, ".ssh", "id_rsa"),
os.path.join(home, ".ssh", "id_ed25519"),
os.path.join(home, ".ssh", "config"),
str(hermes_home / ".env"),
os.path.join(home, ".bashrc"),
os.path.join(home, ".zshrc"),
os.path.join(home, ".profile"),
os.path.join(home, ".bash_profile"),
os.path.join(home, ".zprofile"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
os.path.join(home, ".pypirc"),
"/etc/sudoers",
"/etc/passwd",
"/etc/shadow",
]
}
def build_write_denied_prefixes(home: str) -> list[str]:
"""Return sensitive directory prefixes that must never be written."""
return [
os.path.realpath(p) + os.sep
for p in [
os.path.join(home, ".ssh"),
os.path.join(home, ".aws"),
os.path.join(home, ".gnupg"),
os.path.join(home, ".kube"),
"/etc/sudoers.d",
"/etc/systemd",
os.path.join(home, ".docker"),
os.path.join(home, ".azure"),
os.path.join(home, ".config", "gh"),
]
]
def get_safe_write_root() -> Optional[str]:
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset."""
root = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
if not root:
return None
try:
return os.path.realpath(os.path.expanduser(root))
except Exception:
return None
def is_write_denied(path: str) -> bool:
"""Return True if path is blocked by the write denylist or safe root."""
home = os.path.realpath(os.path.expanduser("~"))
resolved = os.path.realpath(os.path.expanduser(str(path)))
if resolved in build_write_denied_paths(home):
return True
for prefix in build_write_denied_prefixes(home):
if resolved.startswith(prefix):
return True
safe_root = get_safe_write_root()
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
return True
return False
def get_read_block_error(path: str) -> Optional[str]:
"""Return an error message when a read targets internal Hermes cache files."""
resolved = Path(path).expanduser().resolve()
hermes_home = _hermes_home_path().resolve()
blocked_dirs = [
hermes_home / "skills" / ".hub" / "index-cache",
hermes_home / "skills" / ".hub",
]
for blocked in blocked_dirs:
try:
resolved.relative_to(blocked)
except ValueError:
continue
return (
f"Access denied: {path} is an internal Hermes cache file "
"and cannot be read directly to prevent prompt injection. "
"Use the skills_list or skill_view tools instead."
)
return None

View file

@ -0,0 +1,142 @@
"""Focused regressions for the Copilot ACP shim safety layer."""
from __future__ import annotations
import io
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from agent.copilot_acp_client import CopilotACPClient
class _FakeProcess:
def __init__(self) -> None:
self.stdin = io.StringIO()
class CopilotACPClientSafetyTests(unittest.TestCase):
def setUp(self) -> None:
self.client = CopilotACPClient(acp_cwd="/tmp")
def _dispatch(self, message: dict, *, cwd: str) -> dict:
process = _FakeProcess()
handled = self.client._handle_server_message(
message,
process=process,
cwd=cwd,
text_parts=[],
reasoning_parts=[],
)
self.assertTrue(handled)
payload = process.stdin.getvalue().strip()
self.assertTrue(payload)
return json.loads(payload)
def test_request_permission_is_not_auto_allowed(self) -> None:
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 1,
"method": "session/request_permission",
"params": {},
},
cwd="/tmp",
)
outcome = (((response.get("result") or {}).get("outcome") or {}).get("outcome"))
self.assertEqual(outcome, "cancelled")
def test_read_text_file_blocks_internal_hermes_hub_files(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir) / "home"
blocked = home / ".hermes" / "skills" / ".hub" / "index-cache" / "entry.json"
blocked.parent.mkdir(parents=True, exist_ok=True)
blocked.write_text('{"token":"sk-test-secret-1234567890"}')
with patch.dict(os.environ, {"HOME": str(home)}, clear=False):
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 2,
"method": "fs/read_text_file",
"params": {"path": str(blocked)},
},
cwd=str(home),
)
self.assertIn("error", response)
def test_read_text_file_redacts_sensitive_content(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
secret_file = root / "config.env"
secret_file.write_text("OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012")
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 3,
"method": "fs/read_text_file",
"params": {"path": str(secret_file)},
},
cwd=str(root),
)
content = ((response.get("result") or {}).get("content") or "")
self.assertNotIn("abc123def456", content)
self.assertIn("OPENAI_API_KEY=", content)
def test_write_text_file_reuses_write_denylist(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir) / "home"
target = home / ".ssh" / "id_rsa"
target.parent.mkdir(parents=True, exist_ok=True)
with patch("agent.copilot_acp_client.is_write_denied", return_value=True, create=True):
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 4,
"method": "fs/write_text_file",
"params": {
"path": str(target),
"content": "fake-private-key",
},
},
cwd=str(home),
)
self.assertIn("error", response)
self.assertFalse(target.exists())
def test_write_text_file_respects_safe_root(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
safe_root = root / "workspace"
safe_root.mkdir()
outside = root / "outside.txt"
with patch.dict(os.environ, {"HERMES_WRITE_SAFE_ROOT": str(safe_root)}, clear=False):
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 5,
"method": "fs/write_text_file",
"params": {
"path": str(outside),
"content": "should-not-write",
},
},
cwd=str(root),
)
self.assertIn("error", response)
self.assertFalse(outside.exists())
if __name__ == "__main__":
unittest.main()

View file

@ -35,6 +35,13 @@ from pathlib import Path
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
from tools.binary_extensions import BINARY_EXTENSIONS from tools.binary_extensions import BINARY_EXTENSIONS
from agent.file_safety import (
build_write_denied_paths,
build_write_denied_prefixes,
get_safe_write_root as _shared_get_safe_write_root,
is_write_denied as _shared_is_write_denied,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Write-path deny list — blocks writes to sensitive system/credential files # Write-path deny list — blocks writes to sensitive system/credential files
@ -42,41 +49,9 @@ from tools.binary_extensions import BINARY_EXTENSIONS
_HOME = str(Path.home()) _HOME = str(Path.home())
WRITE_DENIED_PATHS = { WRITE_DENIED_PATHS = build_write_denied_paths(_HOME)
os.path.realpath(p) for p in [
os.path.join(_HOME, ".ssh", "authorized_keys"),
os.path.join(_HOME, ".ssh", "id_rsa"),
os.path.join(_HOME, ".ssh", "id_ed25519"),
os.path.join(_HOME, ".ssh", "config"),
str(get_hermes_home() / ".env"),
os.path.join(_HOME, ".bashrc"),
os.path.join(_HOME, ".zshrc"),
os.path.join(_HOME, ".profile"),
os.path.join(_HOME, ".bash_profile"),
os.path.join(_HOME, ".zprofile"),
os.path.join(_HOME, ".netrc"),
os.path.join(_HOME, ".pgpass"),
os.path.join(_HOME, ".npmrc"),
os.path.join(_HOME, ".pypirc"),
"/etc/sudoers",
"/etc/passwd",
"/etc/shadow",
]
}
WRITE_DENIED_PREFIXES = [ WRITE_DENIED_PREFIXES = build_write_denied_prefixes(_HOME)
os.path.realpath(p) + os.sep for p in [
os.path.join(_HOME, ".ssh"),
os.path.join(_HOME, ".aws"),
os.path.join(_HOME, ".gnupg"),
os.path.join(_HOME, ".kube"),
"/etc/sudoers.d",
"/etc/systemd",
os.path.join(_HOME, ".docker"),
os.path.join(_HOME, ".azure"),
os.path.join(_HOME, ".config", "gh"),
]
]
def _get_safe_write_root() -> Optional[str]: def _get_safe_write_root() -> Optional[str]:
@ -87,33 +62,12 @@ def _get_safe_write_root() -> Optional[str]:
not on the static deny list. Opt-in hardening for gateway/messaging not on the static deny list. Opt-in hardening for gateway/messaging
deployments that should only touch a workspace checkout. deployments that should only touch a workspace checkout.
""" """
root = os.getenv("HERMES_WRITE_SAFE_ROOT", "") return _shared_get_safe_write_root()
if not root:
return None
try:
return os.path.realpath(os.path.expanduser(root))
except Exception:
return None
def _is_write_denied(path: str) -> bool: def _is_write_denied(path: str) -> bool:
"""Return True if path is on the write deny list.""" """Return True if path is on the write deny list."""
resolved = os.path.realpath(os.path.expanduser(str(path))) return _shared_is_write_denied(path)
# 1) Static deny list
if resolved in WRITE_DENIED_PATHS:
return True
for prefix in WRITE_DENIED_PREFIXES:
if resolved.startswith(prefix):
return True
# 2) Optional safe-root sandbox
safe_root = _get_safe_write_root()
if safe_root:
if not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
return True
return False
# ============================================================================= # =============================================================================

View file

@ -7,6 +7,9 @@ import logging
import os import os
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional
from agent.file_safety import get_read_block_error
from tools.binary_extensions import has_binary_extension from tools.binary_extensions import has_binary_extension
from tools.file_operations import ShellFileOperations from tools.file_operations import ShellFileOperations
from agent.redact import redact_sensitive_text from agent.redact import redact_sensitive_text
@ -373,24 +376,9 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
# ── Hermes internal path guard ──────────────────────────────── # ── Hermes internal path guard ────────────────────────────────
# Prevent prompt injection via catalog or hub metadata files. # Prevent prompt injection via catalog or hub metadata files.
from hermes_constants import get_hermes_home as _get_hh block_error = get_read_block_error(path)
_hermes_home = _get_hh().resolve() if block_error:
_blocked_dirs = [ return json.dumps({"error": block_error})
_hermes_home / "skills" / ".hub" / "index-cache",
_hermes_home / "skills" / ".hub",
]
for _blocked in _blocked_dirs:
try:
_resolved.relative_to(_blocked)
return json.dumps({
"error": (
f"Access denied: {path} is an internal Hermes cache file "
"and cannot be read directly to prevent prompt injection. "
"Use the skills_list or skill_view tools instead."
)
})
except ValueError:
pass
# ── Dedup check ─────────────────────────────────────────────── # ── Dedup check ───────────────────────────────────────────────
# If we already read this exact (path, offset, limit) and the # If we already read this exact (path, offset, limit) and the