Skip to content

Release/v2.0.0#2742

Open
WMC001 wants to merge 114 commits intomainfrom
release/v2.0.0
Open

Release/v2.0.0#2742
WMC001 wants to merge 114 commits intomainfrom
release/v2.0.0

Conversation

@WMC001
Copy link
Copy Markdown
Contributor

@WMC001 WMC001 commented Mar 28, 2026

No description provided.

葛锐 and others added 30 commits February 14, 2026 14:55
1. 抽取 get_local_tools_description_zh 到独立模块 tool_local_service.py,避免循环导入
2. ToolInfo 模型添加 params: List 字段
3. 移除用户不感知参数(exclude=True)的 description_zh
4. 修复 send_email_tool description 中的 typo
- Restore SSETransport and StreamableHttpTransport imports
- Restore get_mcp_authorization_token_by_name_and_url import
- Restore _create_mcp_transport function
- Update get_tool_from_remote_mcp_server to support tenant_id and authorization_token
- Update _call_mcp_tool to support authorization_token
- Update _validate_mcp_tool_remote to get authorization token from database
- Update get_all_mcp_tools to pass tenant_id
…description_zh

- Update mock paths from tool_configuration_service to tool_local_service
- Fix mock tool classes to use Field() for default values
- Update test_tool_db.py mock module setup
- Restore MCP tools unique key logic (name&source&usage) in update_tool_table_from_scan_tool_list
- Add user_id to MockToolInstanceClass in test
- Fix mock path for get_local_tools_description_zh in test_tool_db.py
…ool_by_tool_info logic

- Add ToolSourceEnum import to tool_db.py
- Restore create_or_update_tool_by_tool_info to not filter by user_id
- Restore proper ToolInstance creation logic
- Add tests for get_local_tools with description_zh extraction
- Add tests for list_all_tools merging description_zh from SDK
- Add tests for add_tool_field merging params and inputs description_zh
- Add tests for get_local_tools_classes direct call
- Add edge case tests for JSON decode errors and missing attributes
- Fix mock path from tool_local_service to tool_configuration_service for get_local_tools_classes
- Fix mock path for get_local_tools_description_zh
- Add @pytest.mark.asyncio for async list_all_tools tests
- Mock importlib.import_module for get_local_tools_classes direct test
Pydantic V2 doesn't support Field(description_zh=...) directly.
The actual tool classes use init_param_descriptions class attribute
for parameter description_zh. Updated get_local_tools() to check
init_param_descriptions as fallback when param.default.description_zh
is not available.
…ls_providers

# Conflicts:
#	sdk/nexent/core/models/openai_vlm.py
WMC001 and others added 27 commits March 27, 2026 11:43
✨ Change provideRunSummary param from read-only to editable
…name

✨ Fix KnowledgeBaseSearchTool: Add index_names parameter to forward method and update tests accordingly
📝 Update docs to illustrate agent params including provideRunSummary
[Specification Details]
1. The directory is divided into charts, and each chart corresponds to one service.
2. Fixed an issue that would add an endpoint to the nexent-mcp pod after starting the containerized mcp service.
[Specification Details]
1. Remove the secrets generated in local env.
✨ Kubernetes Helm deployment directory reconstruction #2722
♻️ Ignore unexpected meta keys in SKILL.md
…ters in search tools

- Changed default page index from 0 to 1 in DataMateClient and DataMateSearchTool for consistency.
- Updated KnowledgeBaseSearchTool to accept index_names as a list instead of a comma-separated string.
- Modified tests to reflect changes in parameter handling and pagination behavior.
…_connect

🐛 Bugfix    Update pagination logic in DataMateClient and adjust related parame…
📝 Update readme to illustrate harness-engineering
🐛 Bugfix: exit create mode and select newly created agent after saving
# Conflicts:
#	frontend/hooks/agent/useSaveGuard.ts
✨ Support skill features, including creation, preview, set in agent version
@WMC001 WMC001 requested a review from Phinease as a code owner March 28, 2026 15:35

# Check if skill directory already exists locally
resolved = self._resolve_local_skills_dir_for_overlay()
if resolved and os.path.exists(os.path.join(resolved, skill_name)):

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
local_path = os.path.join(local_dir, relative_path)
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as f:

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI about 8 hours ago

In general, to fix uncontrolled-path issues you must (a) strictly validate user-controlled components used in paths (e.g., skill_name), and (b) ensure that any final path derived from user input is normalized and checked to be contained within an expected root directory before use. This prevents directory traversal (..), absolute paths, or other manipulations from writing outside the intended storage area.

For this code, the best minimal fix is:

  1. Introduce a helper in backend/services/skill_service.py to validate and normalize skill_name when used as a directory name. This helper should:

    • Reject empty names.
    • Reject names containing path separators (/ or \), drive letters, .., or starting with . or a separator.
    • Optionally enforce a simple regex (e.g., letters, digits, _, -) to preserve current semantics while blocking traversal.
  2. Introduce another helper to safely join any relative path under a base directory:

    • Use os.path.join followed by os.path.normpath.
    • Compute the absolute path of both base and candidate.
    • Ensure that the candidate path starts with the base path followed by a separator (or equal), otherwise raise an exception.
  3. Apply these helpers within _upload_zip_files:

    • Before using skill_name, call the validation helper and use the sanitized version.
    • When computing relative_path from ZIP entries, use posixpath operations to normalize them (ZIP uses /), remove any leading /, and eliminate ./.. segments; skip entries that resolve outside the intended tree.
    • Replace the plain os.path.join calls for local_dir and local_path with calls that use the secure join helper, rooting everything under self.skill_manager.local_skills_dir. This addresses both tainted skill_name and any malicious relative_path from the ZIP archive.

These changes are all within backend/services/skill_service.py, in the existing helper section (near _normalize_zip_entry_path) and the _upload_zip_files method. You do not need to modify imports beyond adding posixpath and possibly re / os.path usage that is already present.


Suggested changeset 1
backend/services/skill_service.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/services/skill_service.py b/backend/services/skill_service.py
--- a/backend/services/skill_service.py
+++ b/backend/services/skill_service.py
@@ -28,6 +28,39 @@
     return norm
 
 
+def _sanitize_skill_dir_name(name: str) -> str:
+    """Validate and sanitize a skill directory name used on the filesystem.
+
+    Only allow simple names without path separators or traversal components.
+    """
+    if not name:
+        raise SkillException("Skill name is required")
+    # Disallow path separators and traversal
+    if any(ch in name for ch in ("/", "\\", os.path.sep, os.path.altsep or "")):
+        raise SkillException("Invalid skill name: must not contain path separators")
+    if name in (".", "..") or ".." in name:
+        raise SkillException("Invalid skill name")
+    # Prevent names starting with a dot to avoid hidden/system dirs if undesired
+    if name.startswith("."):
+        raise SkillException("Invalid skill name")
+    return name
+
+
+def _safe_join(base_dir: str, *paths: str) -> str:
+    """Join one or more path components to base_dir and ensure containment.
+
+    Raises SkillException if the resulting path escapes base_dir.
+    """
+    # Build the candidate path and normalize
+    candidate = os.path.normpath(os.path.join(base_dir, *paths))
+    base_abs = os.path.abspath(base_dir)
+    cand_abs = os.path.abspath(candidate)
+    # Ensure the candidate is within base_abs
+    if os.path.commonpath([base_abs, cand_abs]) != base_abs:
+        raise SkillException("Invalid file path")
+    return cand_abs
+
+
 def _find_zip_member_config_yaml(
     file_list: List[str],
     preferred_skill_root: Optional[str] = None,
@@ -819,49 +852,63 @@
             original_folder_name: Original folder name in ZIP (if different from skill_name)
         """
         import zipfile
+        import posixpath
 
+        # Validate skill_name as a safe directory name
+        safe_skill_name = _sanitize_skill_dir_name(skill_name)
+
         zip_stream = io.BytesIO(zip_bytes)
 
         # Determine if folder renaming is needed
         needs_rename = (
             original_folder_name is not None
-            and original_folder_name != skill_name
+            and original_folder_name != safe_skill_name
         )
 
         logger.info(
             "Starting ZIP extraction for skill '%s': needs_rename=%s, original_folder='%s'",
-            skill_name, needs_rename, original_folder_name
+            safe_skill_name, needs_rename, original_folder_name
         )
 
         try:
             with zipfile.ZipFile(zip_stream, "r") as zf:
                 file_list = zf.namelist()
-                logger.info("ZIP contains %d entries for skill '%s'", len(file_list), skill_name)
+                logger.info("ZIP contains %d entries for skill '%s'", len(file_list), safe_skill_name)
 
                 extracted_count = 0
                 for file_path in file_list:
                     if file_path.endswith("/"):
                         continue
 
+                    # Normalize ZIP entry path using POSIX-style separators
                     normalized_path = file_path.replace("\\", "/")
                     parts = normalized_path.split("/")
 
-                    # Calculate target relative path
+                    # Calculate target relative path within the skill directory
                     if needs_rename and len(parts) >= 2 and parts[0] == original_folder_name:
-                        # Replace original folder name with skill_name
-                        relative_path = parts[0].replace(original_folder_name, skill_name) + "/" + "/".join(parts[1:])
+                        # Replace original folder name with safe_skill_name
+                        rel_parts = [safe_skill_name] + parts[1:]
+                        relative_path = "/".join(rel_parts[1:])
                     elif len(parts) >= 2:
+                        # Strip the top-level folder name in the ZIP
                         relative_path = "/".join(parts[1:])
                     else:
                         relative_path = normalized_path
 
-                    if not relative_path:
+                    # Normalize the relative path to eliminate "." and ".."
+                    relative_path = posixpath.normpath(relative_path)
+                    # Skip any attempts to traverse upwards or use absolute paths
+                    if relative_path in ("", ".", "/") or relative_path.startswith("../") or relative_path.startswith("/"):
+                        logger.warning("Skipping unsafe ZIP entry path '%s' (normalized to '%s')", file_path, relative_path)
                         continue
 
                     file_data = zf.read(file_path)
 
-                    local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
-                    local_path = os.path.join(local_dir, relative_path)
+                    base_dir = self.skill_manager.local_skills_dir
+                    # Build a safe directory path for this skill
+                    local_dir = _safe_join(base_dir, safe_skill_name)
+                    # Build a safe full path for the extracted file
+                    local_path = _safe_join(local_dir, relative_path)
                     os.makedirs(os.path.dirname(local_path), exist_ok=True)
                     with open(local_path, "wb") as f:
                         f.write(file_data)
@@ -870,10 +883,10 @@
 
             logger.info(
                 "Completed ZIP extraction for skill '%s': %d files extracted to '%s'",
-                skill_name, extracted_count, self.skill_manager.local_skills_dir
+                safe_skill_name, extracted_count, self.skill_manager.local_skills_dir
             )
         except Exception as e:
-            logger.error("Failed to extract ZIP files for skill '%s': %s", skill_name, e)
+            logger.error("Failed to extract ZIP files for skill '%s': %s", safe_skill_name, e)
             raise
 
     def update_skill_from_file(
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
try:
# Delete local skill files from filesystem
skill_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
if os.path.exists(skill_dir):

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI about 8 hours ago

To fix the problem, we need to ensure that any filesystem path derived from skill_name remains under a trusted root directory (self.skill_manager.local_skills_dir) and does not allow traversal or absolute-path tricks. The standard pattern is:

  1. Join the user-provided component with the base directory.
  2. Normalize / resolve the resulting path (e.g., os.path.realpath or os.path.normpath).
  3. Verify that the normalized path is still within the base dir (e.g., using os.path.commonpath or a prefix check after normalization).
  4. Only then perform filesystem operations, otherwise raise an error.

In backend/services/skill_service.py, inside delete_skill, we should:

  • Compute base_dir = os.path.realpath(self.skill_manager.local_skills_dir).
  • Compute candidate_dir = os.path.realpath(os.path.join(base_dir, skill_name)).
  • Use os.path.commonpath([base_dir, candidate_dir]) == base_dir to verify containment.
  • If the containment check fails, log an error and raise SkillException("Invalid skill name") instead of deleting anything.
  • Use candidate_dir (renamed e.g. to skill_dir) for existence check and deletion.

This keeps existing functionality (deleting the corresponding local directory when given a valid skill name) while preventing path traversal or arbitrary directory deletion. No new external dependencies are required; os.path already provides what we need.


Suggested changeset 1
backend/services/skill_service.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/services/skill_service.py b/backend/services/skill_service.py
--- a/backend/services/skill_service.py
+++ b/backend/services/skill_service.py
@@ -1139,7 +1139,14 @@
         """
         try:
             # Delete local skill files from filesystem
-            skill_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
+            base_dir = os.path.realpath(self.skill_manager.local_skills_dir)
+            candidate_dir = os.path.realpath(os.path.join(base_dir, skill_name))
+            # Ensure the resolved path stays within the local_skills_dir to prevent path traversal
+            if os.path.commonpath([base_dir, candidate_dir]) != base_dir:
+                logger.error("Attempt to delete skill outside of local_skills_dir: %s", candidate_dir)
+                raise SkillException("Invalid skill name")
+
+            skill_dir = candidate_dir
             if os.path.exists(skill_dir):
                 import shutil
                 shutil.rmtree(skill_dir)
EOF
@@ -1139,7 +1139,14 @@
"""
try:
# Delete local skill files from filesystem
skill_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
base_dir = os.path.realpath(self.skill_manager.local_skills_dir)
candidate_dir = os.path.realpath(os.path.join(base_dir, skill_name))
# Ensure the resolved path stays within the local_skills_dir to prevent path traversal
if os.path.commonpath([base_dir, candidate_dir]) != base_dir:
logger.error("Attempt to delete skill outside of local_skills_dir: %s", candidate_dir)
raise SkillException("Invalid skill name")

skill_dir = candidate_dir
if os.path.exists(skill_dir):
import shutil
shutil.rmtree(skill_dir)
Copilot is powered by AI and may make mistakes. Always verify output.
skill_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
if os.path.exists(skill_dir):
import shutil
shutil.rmtree(skill_dir)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI about 8 hours ago

In general, the fix is to ensure that any user‑supplied component used in a filesystem path is validated and constrained so it cannot escape a designated root directory. For this case, we need to ensure that skill_name cannot cause skill_dir to point outside self.skill_manager.local_skills_dir and ideally restrict skill_name to a simple, safe identifier (e.g., no slashes, no .., no leading . etc.).

The least intrusive, backward‑compatible fix is to normalize the joined path and verify that the resulting real path is still inside self.skill_manager.local_skills_dir. If it is not, we should abort with a controlled exception instead of calling shutil.rmtree. Concretely, in SkillService.delete_skill in backend/services/skill_service.py, we will:

  1. Compute base_dir = os.path.realpath(self.skill_manager.local_skills_dir).
  2. Compute skill_dir = os.path.realpath(os.path.join(base_dir, skill_name)).
  3. Check that os.path.commonpath([base_dir, skill_dir]) == base_dir. If not, raise SkillException("Invalid skill name") (or similar) before any filesystem deletion.
  4. Proceed to shutil.rmtree(skill_dir) only if the check passes.

We already import os and SkillException at the top of skill_service.py, and shutil is imported locally in the function, so no new imports are strictly necessary beyond using existing ones. All changes are confined to the delete_skill method in backend/services/skill_service.py around lines 1140–1146.

Suggested changeset 1
backend/services/skill_service.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/services/skill_service.py b/backend/services/skill_service.py
--- a/backend/services/skill_service.py
+++ b/backend/services/skill_service.py
@@ -1139,7 +1139,11 @@
         """
         try:
             # Delete local skill files from filesystem
-            skill_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
+            base_dir = os.path.realpath(self.skill_manager.local_skills_dir)
+            skill_dir = os.path.realpath(os.path.join(base_dir, skill_name))
+            # Ensure the resolved skill_dir is within the allowed base directory
+            if os.path.commonpath([base_dir, skill_dir]) != base_dir:
+                raise SkillException(f"Invalid skill name: {skill_name}")
             if os.path.exists(skill_dir):
                 import shutil
                 shutil.rmtree(skill_dir)
EOF
@@ -1139,7 +1139,11 @@
"""
try:
# Delete local skill files from filesystem
skill_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
base_dir = os.path.realpath(self.skill_manager.local_skills_dir)
skill_dir = os.path.realpath(os.path.join(base_dir, skill_name))
# Ensure the resolved skill_dir is within the allowed base directory
if os.path.commonpath([base_dir, skill_dir]) != base_dir:
raise SkillException(f"Invalid skill name: {skill_name}")
if os.path.exists(skill_dir):
import shutil
shutil.rmtree(skill_dir)
Copilot is powered by AI and may make mistakes. Always verify output.
local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
full_path = os.path.join(local_dir, file_path)

if not os.path.exists(full_path):

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI about 8 hours ago

In general, to fix uncontrolled-path issues you should: (1) define a trusted root directory, (2) construct the candidate path, (3) normalize/resolve it (e.g., with os.path.realpath or os.path.normpath), and (4) verify that the normalized path is still within the trusted root (e.g., with os.path.commonpath or a robust prefix comparison). Only then should you perform filesystem operations on it.

For this specific code, the best fix is to add a small helper inside SkillService to safely resolve a skill file path and then use it in get_skill_file_content (and, by extension, any future methods like delete/update that manipulate skill files) so that both skill_name and file_path cannot escape self.skill_manager.local_skills_dir. We will:

  1. Add a private method _resolve_skill_file_path(self, skill_name: str, file_path: str) -> str near the other helper methods in SkillService. This method will:

    • Build local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name).
    • Compute root = os.path.realpath(local_dir) and candidate = os.path.realpath(os.path.join(local_dir, file_path)).
    • Use os.path.commonpath([root, candidate]) == root to ensure candidate is inside root. If not, raise SkillException("Invalid file path").
    • Return candidate.
  2. Update get_skill_file_content to use this method instead of directly joining the paths:

    • Replace:
      local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
      full_path = os.path.join(local_dir, file_path)
      with:
      full_path = self._resolve_skill_file_path(skill_name, file_path)
    • Keep the existing existence check and file reading logic unchanged.

No new imports are required: os and SkillException are already imported in backend/services/skill_service.py. The changes are confined to this file and specifically inside the shown snippets.


Suggested changeset 1
backend/services/skill_service.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/services/skill_service.py b/backend/services/skill_service.py
--- a/backend/services/skill_service.py
+++ b/backend/services/skill_service.py
@@ -1337,6 +1337,31 @@
             logger.error(f"Error getting skill file tree: {e}")
             raise SkillException(f"Failed to get skill file tree: {str(e)}") from e
 
+    def _resolve_skill_file_path(self, skill_name: str, file_path: str) -> str:
+        """Resolve and validate a file path within a skill directory.
+
+        Ensures that the resolved path stays within the skill's root directory
+        under ``self.skill_manager.local_skills_dir`` to prevent directory traversal.
+        """
+        # Base directory for this skill
+        local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
+        root = os.path.realpath(local_dir)
+
+        # Candidate file path
+        candidate = os.path.realpath(os.path.join(local_dir, file_path))
+
+        # Ensure the candidate path is within the skill's root directory
+        try:
+            common = os.path.commonpath([root, candidate])
+        except ValueError:
+            # Raised if paths are on different drives (Windows) or otherwise incomparable
+            raise SkillException("Invalid file path") from None
+
+        if common != root:
+            raise SkillException("Invalid file path")
+
+        return candidate
+
     def get_skill_file_content(
         self,
         skill_name: str,
@@ -1354,8 +1379,7 @@
             File content as string, or None if file not found
         """
         try:
-            local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
-            full_path = os.path.join(local_dir, file_path)
+            full_path = self._resolve_skill_file_path(skill_name, file_path)
 
             if not os.path.exists(full_path):
                 logger.warning(f"File not found: {full_path}")
EOF
@@ -1337,6 +1337,31 @@
logger.error(f"Error getting skill file tree: {e}")
raise SkillException(f"Failed to get skill file tree: {str(e)}") from e

def _resolve_skill_file_path(self, skill_name: str, file_path: str) -> str:
"""Resolve and validate a file path within a skill directory.

Ensures that the resolved path stays within the skill's root directory
under ``self.skill_manager.local_skills_dir`` to prevent directory traversal.
"""
# Base directory for this skill
local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
root = os.path.realpath(local_dir)

# Candidate file path
candidate = os.path.realpath(os.path.join(local_dir, file_path))

# Ensure the candidate path is within the skill's root directory
try:
common = os.path.commonpath([root, candidate])
except ValueError:
# Raised if paths are on different drives (Windows) or otherwise incomparable
raise SkillException("Invalid file path") from None

if common != root:
raise SkillException("Invalid file path")

return candidate

def get_skill_file_content(
self,
skill_name: str,
@@ -1354,8 +1379,7 @@
File content as string, or None if file not found
"""
try:
local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
full_path = os.path.join(local_dir, file_path)
full_path = self._resolve_skill_file_path(skill_name, file_path)

if not os.path.exists(full_path):
logger.warning(f"File not found: {full_path}")
Copilot is powered by AI and may make mistakes. Always verify output.
logger.warning(f"File not found: {full_path}")
return None

with open(full_path, "r", encoding="utf-8") as f:

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI about 8 hours ago

In general, to fix this kind of issue you must ensure that any path derived from user input is constrained to stay within a known safe root. The usual pattern is: (1) construct a candidate path under a trusted base directory, (2) normalize it with os.path.abspath/os.path.realpath or os.path.normpath, and (3) verify that the normalized path is still within the intended base directory, rejecting it otherwise. You can also separately validate the components (such as skill_name) against an allowlist (e.g. only alphanumeric, dashes, underscores) to rule out weird path characters.

For this code, the best minimal fix without changing existing functionality is:

  1. In SkillService.get_skill_file_content, compute a base directory for the skill (local_dir) as before, but then:
    • Resolve both local_dir and the final full_path to absolute, normalized paths using os.path.abspath.
    • Check that full_path is still inside local_dir. We can do this safely with os.path.commonpath([local_dir, full_path]) == local_dir.
    • If the check fails, log a warning and raise a SkillException (or return None) indicating an invalid file path, instead of attempting to open the file.
  2. Also ensure file_path is treated as a relative path (it might start with /); we can normalize and strip leading separators before joining, or simply rely on the commonpath check to block absolute paths that escape the base.
  3. Keep the rest of the behavior the same: if the file does not exist after validation, log and return None; if it exists and is permitted, open and read it as before.

This change all happens inside backend/services/skill_service.py in the get_skill_file_content method, between the try: and the open(...) call. No changes are needed in backend/apps/skill_app.py for this specific vulnerability. We do not need extra imports because os.path and os are already imported at the top of the file.

Suggested changeset 1
backend/services/skill_service.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/services/skill_service.py b/backend/services/skill_service.py
--- a/backend/services/skill_service.py
+++ b/backend/services/skill_service.py
@@ -1354,10 +1354,22 @@
             File content as string, or None if file not found
         """
         try:
+            # Construct base directory for this skill under the local skills root
             local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
-            full_path = os.path.join(local_dir, file_path)
+            local_dir = os.path.abspath(local_dir)
 
-            if not os.path.exists(full_path):
+            # Build the requested file path and normalize to an absolute path
+            full_path = os.path.abspath(os.path.join(local_dir, file_path))
+
+            # Ensure the resolved path is still within the skill's directory
+            if os.path.commonpath([local_dir, full_path]) != local_dir:
+                logger.warning(
+                    "Attempted access to file outside skill directory: "
+                    f"skill_name={skill_name}, file_path={file_path}, resolved={full_path}"
+                )
+                raise SkillException("Invalid file path")
+
+            if not os.path.exists(full_path) or not os.path.isfile(full_path):
                 logger.warning(f"File not found: {full_path}")
                 return None
 
EOF
@@ -1354,10 +1354,22 @@
File content as string, or None if file not found
"""
try:
# Construct base directory for this skill under the local skills root
local_dir = os.path.join(self.skill_manager.local_skills_dir, skill_name)
full_path = os.path.join(local_dir, file_path)
local_dir = os.path.abspath(local_dir)

if not os.path.exists(full_path):
# Build the requested file path and normalize to an absolute path
full_path = os.path.abspath(os.path.join(local_dir, file_path))

# Ensure the resolved path is still within the skill's directory
if os.path.commonpath([local_dir, full_path]) != local_dir:
logger.warning(
"Attempted access to file outside skill directory: "
f"skill_name={skill_name}, file_path={file_path}, resolved={full_path}"
)
raise SkillException("Invalid file path")

if not os.path.exists(full_path) or not os.path.isfile(full_path):
logger.warning(f"File not found: {full_path}")
return None

Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants