Skip to content

Add OpenClaw skill import system with security scanning#657

Open
emooreatx wants to merge 12 commits intomainfrom
claude/add-import-skill-tool-2lkp1
Open

Add OpenClaw skill import system with security scanning#657
emooreatx wants to merge 12 commits intomainfrom
claude/add-import-skill-tool-2lkp1

Conversation

@emooreatx
Copy link
Copy Markdown
Contributor

Summary

Implements a complete skill import system that allows users to import OpenClaw-format skills as CIRIS adapters. This includes parsing, security scanning, preview/review workflows, and adapter generation with a HyperCard-inspired card-based UI for skill creation and editing.

Key Changes

Backend Services

  • Skill Parser (parser.py): Parses OpenClaw SKILL.md format (YAML frontmatter + markdown) into structured ParsedSkill objects, supporting multiple metadata namespaces (openclaw, clawdbot, clawdis)
  • Security Scanner (scanner.py): Comprehensive threat detection based on real ClawHub malware analysis (Feb 2026), detecting prompt injection, credential exfiltration, reverse shells, cryptominers, typosquatting, and undeclared network access
  • Skill-to-Adapter Converter (converter.py): Transforms parsed skills into complete CIRIS adapter directories with generated Python code, manifests, and installation steps
  • Skill Builder (builder.py): HyperCard-inspired card system for skill creation with 6 card types (identity, tools, requires, instruct, behavior, install), supporting both form-based and raw JSON editing modes
  • API Endpoints:
    • Skill import endpoint (skill_import.py) for preview, security scanning, and importing skills
    • Skill builder endpoint (skill_builder.py) for managing skill drafts and card-based editing

Mobile UI

  • SkillImportDialog (SkillImportDialog.kt): Multi-phase dialog (paste → preview → result) with security findings display, skill metadata review, and import confirmation
  • SkillImportViewModel (SkillImportViewModel.kt): State management for import workflows and imported skills list
  • Data Models (SkillImport.kt): Kotlin data classes for preview data, import results, and imported skill metadata
  • Integration: Added skill import screen to main app navigation and adapters screen

Infrastructure

  • Tool Bus Enhancement (tool_bus.py): Added tool alias registration mechanism to support skill-defined tool name mappings
  • Localization: Added 140+ new localization strings across 10 languages for skill workshop UI
  • Comprehensive Tests: Full test coverage for parser, converter, builder, and scanner with real malware pattern detection

Notable Implementation Details

  • Security-First Design: Every imported skill is treated as untrusted code with multi-layer defense (scanner → manifest validation → adapter sandboxing)
  • Real Threat Patterns: Scanner detects actual attack vectors from documented ClawHub incidents (Snyk ToxicSkills audit, ClawHavoc campaign)
  • Adapter Generation: Automatically generates valid CIRIS adapter Python code with proper tool registration, environment variable handling, and installation step management
  • Card-Based UX: Follows HyperCard philosophy with simple form mode for casual users and advanced JSON mode for power users
  • Polyglot Support: Handles multiple skill metadata formats and install methods (brew, npm, pip, apt, manual, download)

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n

claude added 11 commits April 2, 2026 14:12
Introduces a complete pipeline for importing OpenClaw/ClawHub SKILL.md
skills as native CIRIS adapters, accessible via both REST API and the
agent's tool system:

- Parser: Handles YAML frontmatter (openclaw/clawdbot/clawdis namespaces)
  plus markdown instruction body with full field extraction
- Converter: Generates complete adapter directories (manifest.json,
  adapter.py, services.py) in ~/.ciris/adapters/ for auto-discovery
- API endpoints: POST /system/adapters/import-skill (with preview),
  GET /system/adapters/imported-skills, DELETE imported skills
- 34 tests covering parsing, conversion, and field consumption verification

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
Adds parsing and consumption of previously missing fields:
- homepage (top-level frontmatter, fallback to metadata.openclaw.homepage)
- disable-model-invocation -> context_enrichment=False on info tool
- command-arg-mode -> stored in manifest metadata
- requires.anyBins -> ToolRequirements.any_binaries
- requires.config -> ToolRequirements.config_keys (as ConfigRequirement)
- metadata.os -> manifest platform_requirements + ToolRequirements.platforms
- metadata.always/skill_key/emoji -> stored in manifest metadata
- install spec url/archive/stripComponents/targetDir -> manual command

43 tests now verify every OpenClaw field is either actively consumed
in the generated adapter or stored in manifest metadata with rationale.

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
Every stored field now has a concrete CIRIS integration:

- emoji -> ModuleTypeInfo.emoji (displayed in UI adapter list)
- homepage -> ModuleTypeInfo.homepage (displayed in UI adapter list)
- always -> context_enrichment=True on main skill tool (auto-injected
  into DMA prompt every cycle, not just the :info tool)
- skillKey -> ToolBus.register_tool_alias() (invoke "todoist" instead
  of "skill:todoist-cli")
- command_tool -> also registered as ToolBus alias
- user_invocable=false -> 'internal' tag on ToolInfo (UI can filter)
- command_dispatch=tool -> 'direct_dispatch' tag on ToolInfo
- disable_model_invocation -> context_enrichment=False on :info tool

ToolBus enhancements:
- register_tool_alias(alias, canonical) for skill aliasing
- resolve_tool_name() called in execute_tool and get_tool_info
- Generated adapters register aliases on startup via bus_manager

55 tests passing (12 new tests for these integrations).

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
Initial KMP (Kotlin Multiplatform) components for the skill import
feature, laying the groundwork for the full import/analyze/create/edit
workflow:

- SkillImport.kt: Data models (SkillPreviewData, SkillImportResult,
  ImportedSkillData)
- SkillImportViewModel.kt: MVVM state management with three-phase
  flow (paste/preview/result) and CRUD for imported skills
- SkillImportDialog.kt: Three-phase dialog composable with paste,
  preview, and result cards plus ImportedSkillCard for the list view
- CIRISApiClient.kt: API methods for preview, import, list, delete
- AdaptersScreen.kt: Import Skill button in top bar actions
- CIRISApp.kt: Screen.SkillImport added to navigation

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
The skill builder treats every CIRIS adapter as a stack of 6 editable
cards, each backed by a Pydantic model with full JSON Schema introspection.
Two modes: card mode (form rendered from schema) and edit mode (raw JSON).

Card stack:
  1. Identity  - name, description, emoji, version, homepage
  2. Tools     - tool definitions with parameters
  3. Requires  - env vars, binaries, platforms
  4. Instruct  - AI directive (the brain of the skill)
  5. Behavior  - DMA guidance: approval, confidence, ethics
  6. Install   - dependency installation steps

Key design: GET /skills/cards returns all card JSON Schemas in one call.
The UI renders forms from schemas. User edits. PUT sends data back.
Backend validates against Pydantic models. Everything is serializable.

Draft lifecycle: create (blank or from OpenClaw import) -> edit cards ->
validate -> build adapter. Drafts persist as JSON in ~/.ciris/skill_drafts/.

API endpoints:
  GET  /system/skills/cards           - All card schemas (bootstrap UI)
  GET  /system/skills/cards/{id}      - Single card schema
  POST /system/skills/drafts          - Create draft
  GET  /system/skills/drafts          - List drafts
  GET  /system/skills/drafts/{id}     - Get draft
  PUT  /system/skills/drafts/{id}     - Update draft
  PUT  /system/skills/drafts/{id}/cards/{card} - Update single card
  POST /system/skills/drafts/{id}/validate     - Validate
  POST /system/skills/drafts/{id}/build        - Build adapter

83 tests passing across parser, converter, builder, and ToolBus aliases.

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
…tion

"Keep the song singable for every voice not yet heard."

Every field now has plain English labels and hints written for people
with minimal tech competency. Complexity is scaffolded:

Card mode (default):
  - Identity: "Give your skill a name and tell people what it does"
  - Tools: "Each skill has tools — actions the agent can take"
  - Requirements: "Some skills need passwords, programs, or specific devices"
  - Instructions: "Write it like you're explaining to a helpful friend"
  - Safety: "Control how careful the agent should be"
  - Install: "If your skill needs extra software, list how to install it"

Scaffolded disclosure:
  - Identity + Tools cards expanded by default (essential)
  - Requirements collapsed (show only if present)
  - Instructions collapsed (for review)
  - Safety collapsed (auto-set for imports: approval=true, confidence=70%)

WorkshopCard component:
  - Emoji icon + title + plain English hint
  - Collapsible with animation
  - testable() tags for UI automation

100 localization keys added to en.json following existing patterns:
  skill_field_*, skill_card_*, skill_behavior_*, skill_import_*,
  skill_error_*, skill_mode_* (simple/advanced/json toggle)

Import flow redesigned:
  1. Paste: warning about untrusted skills, source URL optional
  2. Review: parsed skill shown as cards for human inspection
  3. Result: success/failure with tool list

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
Scans imported skills for 8 threat categories from the Feb 2026
ClawHub security crisis (Snyk ToxicSkills, Koi Security ClawHavoc):

1. Prompt injection (36% of ClawHub skills - "ignore previous instructions",
   identity reassignment, silent exfiltration via URL)
2. Credential theft (SSH keys, AWS creds, browser cookies, crypto wallets,
   .env files - from AMOS stealer campaign)
3. Reverse shell / backdoor (netcat, bash TCP, curl|bash, cron persistence,
   launchctl/systemctl service installation)
4. Cryptominer deployment (xmrig, mining pool URLs, stratum protocol)
5. Typosquatting (known ClawHavoc names + Levenshtein distance against
   popular skill names)
6. Undeclared network access (curl/wget in instructions not declared in
   requires.bins)
7. Code obfuscation (eval/exec, hex/unicode encoding, subprocess calls)
8. Metadata inconsistency (undeclared env vars, suspicious description
   length ratios)

Security enforcement:
- Preview endpoint returns full SecurityReportResponse with findings
- Import endpoint BLOCKS skills with critical or high findings
- Every finding has plain English title, description, and recommendation
- 30 scanner tests covering all threat categories

References:
- CVE-2026-25593, CVE-2026-24763, CVE-2026-25157
- Snyk ToxicSkills: 1,467 malicious skills, 91% combined prompt
  injection with traditional malware
- ClawHavoc campaign: 335 skills delivering Atomic Stealer (AMOS)

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
Documents the 5-layer defense-in-depth model:
1. Security scanner (8 threat categories from ClawHub crisis)
2. Schema validation (Pydantic strict models)
3. DMA guidance (requires_approval=true by default)
4. H3ERE pipeline (4 DMAs + conscience + Ed25519 audit)
5. Adapter isolation (user-space, ToolBus mediated)

Includes references to Snyk ToxicSkills, ClawHavoc, and CVEs.

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
Adds 135 localization keys for the skill builder UI covering:

Workshop keys (100):
  skill_card_* - Card titles/hints in simple English
  skill_field_* - Form field labels and placeholders
  skill_tool_* - Tool builder fields
  skill_req_* - Requirements (env vars, binaries, platforms)
  skill_behavior_* - Safety settings (approval, confidence, ethics)
  skill_import_* - Import flow (paste, analyze, review, approve)
  skill_error_* - Validation messages
  skill_mode_* - Simple/Advanced/JSON mode toggle

Security keys (35):
  skill_security_* - Scan results, severity labels, recommendations
  skill_finding_* - 8 threat categories in plain English
  skill_severity_* - Danger/Risky/Suspicious/Minor/Info
  skill_security_layer_* - 5-layer defense explanation

All strings written for minimal tech competency:
  "Ask permission first?" not "requires_approval"
  "Tries to manipulate the agent" not "prompt_injection detected"
  "Uses your device to mine cryptocurrency" not "cryptominer binary"

4 languages complete (en, de, es, fr). Remaining 11 in progress.

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
…tr/ru)

135 keys × 8 languages = 1,080 translated strings covering:

Workshop UI (100 keys):
  skill_card_* - Card titles/hints in plain language
  skill_field_* - Form labels and placeholders
  skill_tool_* - Tool builder fields
  skill_behavior_* - Safety settings (approval, confidence, ethics)
  skill_import_* - Import flow with security warnings

Security UI (35 keys):
  skill_security_* - Scan results, severity labels
  skill_finding_* - 8 threat categories from ClawHub crisis
  skill_severity_* - Danger levels in plain language
  skill_security_layer_* - 5-layer defense explanation

Every string written for minimal tech competency:
  DE: "GEFAHR: Dieser Skill könnte schädlich sein"
  ES: "PELIGRO: Este skill puede ser dañino"
  FR: "DANGER : Ce skill peut être dangereux"
  IT: "PERICOLO: Questo skill potrebbe essere dannoso"
  PT: "PERIGO: Este skill pode ser prejudicial"
  TR: "TEHLİKE: Bu beceri zararlı olabilir"
  RU: "ОПАСНО: Этот навык может быть вредоносным"

Remaining 7 languages (ja/ko/zh/ar/hi/am/sw) in progress.

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
Japanese (135 keys): polite casual register
  危険:このスキルは有害な可能性があります。インポートしないでください。
  すべてのアクションはエージェントの良心によってチェックされます

Korean (135 keys): polite formal register
  위험: 이 스킬은 해로울 수 있습니다. 가져오지 마세요.
  모든 행동은 에이전트의 양심에 의해 검사됩니다

Chinese Simplified (135 keys): simple 简体中文
  危险:此技能可能有害。请勿导入。
  每个操作都由智能助手的良知检查

11 of 15 languages complete. Remaining: ar, hi, am, sw.

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
@cla-assistant
Copy link
Copy Markdown

cla-assistant bot commented Apr 2, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.


if req.local_path:
path = Path(req.local_path)
if path.is_dir():

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 11 days ago

In general, to fix uncontrolled path use you should normalize the user-provided path and ensure it is constrained to an expected safe root directory (or use a strict allow list or filename sanitizer). Here, req.local_path is meant for "CLI/desktop use", but it still comes from the HTTP body and is used directly as a Path that can point anywhere. We should introduce a safe base directory for local imports (for example, the current working directory or a dedicated configuration directory), normalize the joined path, and verify that the resulting path is within that base. This prevents traversal (..), absolute paths, and paths that escape the designated root, while preserving the ability to specify relative paths under that root.

Concretely, within _parse_skill_from_request in ciris_engine/logic/adapters/api/routes/system/skill_import.py, we will:

  1. Define a constant LOCAL_SKILL_IMPORT_ROOT at module level, set to a safe base (e.g., Path.cwd() by default), with a comment explaining its purpose.
  2. Replace the direct path = Path(req.local_path) with:
    • base = LOCAL_SKILL_IMPORT_ROOT.resolve()
    • candidate = (base / req.local_path).resolve()
    • A check that candidate is within base using candidate.is_relative_to(base) (Python 3.9+); if not, raise ValueError with a clear message.
    • Assign path = candidate.
  3. Keep the existing logic (is_dir, is_file, read, etc.) but operate on this validated path.

This approach avoids changing external behavior for valid, in-root relative paths but blocks absolute paths and traversal outside the configured root. No new third‑party imports are needed; we only use pathlib.Path which is already imported. All changes are localized to the shown file and the _parse_skill_from_request helper.

Suggested changeset 1
ciris_engine/logic/adapters/api/routes/system/skill_import.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/ciris_engine/logic/adapters/api/routes/system/skill_import.py b/ciris_engine/logic/adapters/api/routes/system/skill_import.py
--- a/ciris_engine/logic/adapters/api/routes/system/skill_import.py
+++ b/ciris_engine/logic/adapters/api/routes/system/skill_import.py
@@ -22,6 +22,12 @@
 
 logger = logging.getLogger(__name__)
 
+# Base directory for local skill imports. All local_path values must resolve
+# to a location within this directory to be considered valid.
+# By default, use the current working directory; this can be adjusted as needed
+# for the deployment environment.
+LOCAL_SKILL_IMPORT_ROOT = Path.cwd()
+
 router = APIRouter()
 
 # Annotated type alias for FastAPI dependency injection
@@ -149,7 +155,19 @@
         return parser.parse_skill_md(req.skill_md_content, source_url=req.source_url)
 
     if req.local_path:
-        path = Path(req.local_path)
+        # Resolve the requested path relative to the configured local import root
+        base = LOCAL_SKILL_IMPORT_ROOT.resolve()
+        # Interpret req.local_path as a path under the base directory
+        candidate = (base / req.local_path).resolve()
+        try:
+            # Python 3.9+: ensure the path is within the allowed base directory
+            if not candidate.is_relative_to(base):
+                raise ValueError("Local path is outside the allowed import directory")
+        except AttributeError:
+            # Fallback for older Python: manual prefix check
+            if str(candidate) != str(base) and not str(candidate).startswith(str(base) + candidate.anchor.replace(candidate.anchor, "")):
+                raise ValueError("Local path is outside the allowed import directory")
+        path = candidate
         if path.is_dir():
             return parser.parse_directory(path, source_url=req.source_url)
         elif path.is_file():
EOF
@@ -22,6 +22,12 @@

logger = logging.getLogger(__name__)

# Base directory for local skill imports. All local_path values must resolve
# to a location within this directory to be considered valid.
# By default, use the current working directory; this can be adjusted as needed
# for the deployment environment.
LOCAL_SKILL_IMPORT_ROOT = Path.cwd()

router = APIRouter()

# Annotated type alias for FastAPI dependency injection
@@ -149,7 +155,19 @@
return parser.parse_skill_md(req.skill_md_content, source_url=req.source_url)

if req.local_path:
path = Path(req.local_path)
# Resolve the requested path relative to the configured local import root
base = LOCAL_SKILL_IMPORT_ROOT.resolve()
# Interpret req.local_path as a path under the base directory
candidate = (base / req.local_path).resolve()
try:
# Python 3.9+: ensure the path is within the allowed base directory
if not candidate.is_relative_to(base):
raise ValueError("Local path is outside the allowed import directory")
except AttributeError:
# Fallback for older Python: manual prefix check
if str(candidate) != str(base) and not str(candidate).startswith(str(base) + candidate.anchor.replace(candidate.anchor, "")):
raise ValueError("Local path is outside the allowed import directory")
path = candidate
if path.is_dir():
return parser.parse_directory(path, source_url=req.source_url)
elif path.is_file():
Copilot is powered by AI and may make mistakes. Always verify output.
path = Path(req.local_path)
if path.is_dir():
return parser.parse_directory(path, source_url=req.source_url)
elif path.is_file():

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 11 days ago

In general, the fix is to validate and constrain any filesystem paths derived from user input before accessing the filesystem. Common patterns: (1) restrict to a known safe root directory and normalize/resolve before checking containment; or (2) if local_path is not intended for remote use, explicitly disallow it in these HTTP endpoints and only support it in trusted contexts.

Given the context (“Local file path (for CLI/desktop use)”), the least invasive and safest fix for this API layer is: when handling an HTTP request, explicitly reject the local_path mode with a clear error. That keeps existing behavior for skill_md_content and source_url intact and avoids any path-based file IO using user-controlled data over HTTP. If the codebase has other non-HTTP entry points that construct a SkillImportRequest with local_path directly (e.g., a CLI), they can still call _parse_skill_from_request safely because they won't go through these FastAPI routes.

Concretely:

  • In both preview_skill_import and import_skill, before calling _parse_skill_from_request, check body.local_path. If it is set, raise an HTTPException (400) indicating that local_path is not supported via the HTTP API and that the caller must provide the SKILL.md content instead.
  • No changes are needed inside _parse_skill_from_request; we keep the same behavior for trusted, internal callers and eliminate the tainted-flow path only for HTTP requests.
  • All modifications occur within ciris_engine/logic/adapters/api/routes/system/skill_import.py at the two route functions around the existing try/except blocks.
Suggested changeset 1
ciris_engine/logic/adapters/api/routes/system/skill_import.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/ciris_engine/logic/adapters/api/routes/system/skill_import.py b/ciris_engine/logic/adapters/api/routes/system/skill_import.py
--- a/ciris_engine/logic/adapters/api/routes/system/skill_import.py
+++ b/ciris_engine/logic/adapters/api/routes/system/skill_import.py
@@ -258,6 +258,16 @@
 
     Requires ADMIN role.
     """
+    # For security reasons, do not allow arbitrary local filesystem paths to be
+    # supplied via the HTTP API. Callers must provide the SKILL.md content
+    # directly (skill_md_content) instead of a local_path.
+    if body.local_path:
+        raise HTTPException(
+            status_code=400,
+            detail="Importing skills via local_path is not supported over HTTP. "
+            "Provide SKILL.md content via skill_md_content instead.",
+        )
+
     try:
         skill = _parse_skill_from_request(body)
     except (ValueError, FileNotFoundError) as e:
@@ -293,6 +303,10 @@
     Parses the SKILL.md content, generates a full CIRIS adapter directory,
     and optionally loads it into the running runtime.
 
+    For security reasons, importing via an arbitrary local filesystem path
+    (local_path) is not supported over this HTTP API. Callers must provide
+    the SKILL.md content directly instead.
+
     The adapter is created in ~/.ciris/adapters/ by default, which is
     automatically discovered by the AdapterDiscoveryService.
 
EOF
@@ -258,6 +258,16 @@

Requires ADMIN role.
"""
# For security reasons, do not allow arbitrary local filesystem paths to be
# supplied via the HTTP API. Callers must provide the SKILL.md content
# directly (skill_md_content) instead of a local_path.
if body.local_path:
raise HTTPException(
status_code=400,
detail="Importing skills via local_path is not supported over HTTP. "
"Provide SKILL.md content via skill_md_content instead.",
)

try:
skill = _parse_skill_from_request(body)
except (ValueError, FileNotFoundError) as e:
@@ -293,6 +303,10 @@
Parses the SKILL.md content, generates a full CIRIS adapter directory,
and optionally loads it into the running runtime.

For security reasons, importing via an arbitrary local filesystem path
(local_path) is not supported over this HTTP API. Callers must provide
the SKILL.md content directly instead.

The adapter is created in ~/.ciris/adapters/ by default, which is
automatically discovered by the AdapterDiscoveryService.

Copilot is powered by AI and may make mistakes. Always verify output.
if path.is_dir():
return parser.parse_directory(path, source_url=req.source_url)
elif path.is_file():
content = path.read_text(encoding="utf-8")

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 11 days ago

In general, to fix uncontrolled-path issues you must constrain or validate any user-provided path before using it, typically by (1) defining a safe root directory, (2) resolving the user path against that root, (3) normalizing it (e.g., Path.resolve()), and (4) verifying that the final path is still within the allowed root. Absolute paths, traversal attempts (..), or paths escaping the root should be rejected.

For this specific code, the best single fix with minimal behavior change is:

  • Introduce a helper function _resolve_local_path that:

    • Accepts the raw local_path string.
    • Resolves it against a configured base directory (e.g., an environment variable CIRIS_SKILL_IMPORT_ROOT or a default such as the current working directory).
    • Uses Path.resolve(strict=False) to normalize the path.
    • Verifies that the resolved path is within the base directory by checking resolved_path == base_dir or base_dir in resolved_path.parents.
    • Raises a ValueError with a clear message if the path is outside the allowed root or is otherwise invalid.
  • Update _parse_skill_from_request so that instead of path = Path(req.local_path) it calls _resolve_local_path(req.local_path) and uses the returned safe Path. The rest of the logic (is_dir(), is_file(), read_text()) remains unchanged.

  • Because we already import Path from pathlib, no extra third-party packages are needed. For reading an environment variable we can import os (standard library) at the top of the file.

Concretely, in ciris_engine/logic/adapters/api/routes/system/skill_import.py:

  1. Add import os alongside existing imports.
  2. Add a new helper _resolve_local_path(local_path: str) -> Path in the “Helper Functions” section before _parse_skill_from_request.
  3. Modify _parse_skill_from_request to use _resolve_local_path instead of Path(req.local_path).

This ensures both alert variants (for the two endpoints using body) are addressed, since they both call _parse_skill_from_request.


Suggested changeset 1
ciris_engine/logic/adapters/api/routes/system/skill_import.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/ciris_engine/logic/adapters/api/routes/system/skill_import.py b/ciris_engine/logic/adapters/api/routes/system/skill_import.py
--- a/ciris_engine/logic/adapters/api/routes/system/skill_import.py
+++ b/ciris_engine/logic/adapters/api/routes/system/skill_import.py
@@ -11,6 +11,7 @@
 from pathlib import Path
 from typing import Annotated, Any, Dict, List, Optional
 
+import os
 from fastapi import APIRouter, Body, Depends, HTTPException, Request
 from pydantic import BaseModel, ConfigDict, Field
 
@@ -141,6 +142,29 @@
 # Helper Functions
 # ============================================================================
 
+def _resolve_local_path(local_path: str) -> Path:
+    """Resolve and validate a local path for skill import.
+
+    The path is resolved against a configured root directory to prevent
+    access outside the allowed tree.
+    """
+    # Base directory for local skill imports. Can be overridden via env var.
+    base_dir_env = os.environ.get("CIRIS_SKILL_IMPORT_ROOT")
+    base_dir = Path(base_dir_env) if base_dir_env else Path.cwd()
+    base_dir = base_dir.resolve(strict=False)
+
+    # Always interpret the provided path relative to the base directory.
+    candidate = (base_dir / local_path).resolve(strict=False)
+
+    # Ensure the resolved path is within the base directory.
+    try:
+        candidate.relative_to(base_dir)
+    except ValueError:
+        raise ValueError("Local path is outside the allowed import directory")
+
+    return candidate
+
+
 def _parse_skill_from_request(req: SkillImportRequest) -> ParsedSkill:
     """Parse a skill from the request, handling all input modes."""
     parser = OpenClawSkillParser()
@@ -149,7 +173,7 @@
         return parser.parse_skill_md(req.skill_md_content, source_url=req.source_url)
 
     if req.local_path:
-        path = Path(req.local_path)
+        path = _resolve_local_path(req.local_path)
         if path.is_dir():
             return parser.parse_directory(path, source_url=req.source_url)
         elif path.is_file():
EOF
@@ -11,6 +11,7 @@
from pathlib import Path
from typing import Annotated, Any, Dict, List, Optional

import os
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field

@@ -141,6 +142,29 @@
# Helper Functions
# ============================================================================

def _resolve_local_path(local_path: str) -> Path:
"""Resolve and validate a local path for skill import.

The path is resolved against a configured root directory to prevent
access outside the allowed tree.
"""
# Base directory for local skill imports. Can be overridden via env var.
base_dir_env = os.environ.get("CIRIS_SKILL_IMPORT_ROOT")
base_dir = Path(base_dir_env) if base_dir_env else Path.cwd()
base_dir = base_dir.resolve(strict=False)

# Always interpret the provided path relative to the base directory.
candidate = (base_dir / local_path).resolve(strict=False)

# Ensure the resolved path is within the base directory.
try:
candidate.relative_to(base_dir)
except ValueError:
raise ValueError("Local path is outside the allowed import directory")

return candidate


def _parse_skill_from_request(req: SkillImportRequest) -> ParsedSkill:
"""Parse a skill from the request, handling all input modes."""
parser = OpenClawSkillParser()
@@ -149,7 +173,7 @@
return parser.parse_skill_md(req.skill_md_content, source_url=req.source_url)

if req.local_path:
path = Path(req.local_path)
path = _resolve_local_path(req.local_path)
if path.is_dir():
return parser.parse_directory(path, source_url=req.source_url)
elif path.is_file():
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ffd0fa0299

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +361 to +364
install_steps_code = "[]"
if skill.metadata and skill.metadata.install:
steps = _build_install_steps(skill.metadata.install)
install_steps_code = repr(steps)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Attach converted install steps to generated tool metadata

The converter builds install_steps_code from metadata.install, but the generated ToolInfo never consumes that value, so imported skills lose all dependency installation guidance. In practice, skills that declare brew/pip/npm installs will appear to have no install steps, which breaks setup workflows for users and any UI that relies on ToolInfo.install_steps.

Useful? React with 👍 / 👎.

supporting_dir.mkdir(exist_ok=True)

for rel_path, content in skill.supporting_files.items():
target = supporting_dir / Path(rel_path).name # Flatten to single directory
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve supporting file subpaths when writing adapter assets

Supporting files are flattened to Path(rel_path).name, so two files from different directories with the same basename overwrite each other (e.g., references/readme.md and scripts/readme.md). This silently drops content from imported skills and can alter execution context whenever filename collisions occur.

Useful? React with 👍 / 👎.

Comment on lines +442 to +444
skill_key=None,
install=[],
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Carry install-card steps into ParsedSkill during draft builds

The builder discards user-authored install-card data by hardcoding metadata.install to an empty list when converting a draft to ParsedSkill. As a result, skills built from the card UI never include the install instructions users entered, even though the draft model captures them.

Useful? React with 👍 / 👎.

)""")

# Build install steps
install_steps_code = "[]"
install_steps_code = "[]"
if skill.metadata and skill.metadata.install:
steps = _build_install_steps(skill.metadata.install)
install_steps_code = repr(steps)
escaped_instructions = skill.instructions.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')

# Build platform list
platforms = skill.metadata.os if skill.metadata and skill.metadata.os else []
def _check_metadata_consistency(self, skill: ParsedSkill) -> List[SkillSecurityFinding]:
"""Check for inconsistencies between metadata and content."""
findings = []
instructions = (skill.instructions or "").lower()
Comment on lines +28 to +35
from ciris_engine.schemas.adapters.tools import (
InstallStep,
ToolDMAGuidance,
ToolDocumentation,
ToolInfo,
ToolParameterSchema,
ToolRequirements,
)
import logging
import re
from enum import Enum
from typing import Any, Dict, List, Optional
Comment on lines +30 to +36
from ciris_engine.logic.services.skill_import.builder import (
CARD_DEFINITIONS,
SkillBuilder,
SkillDraft,
get_all_card_schemas,
get_card_schema,
)
@@ -0,0 +1,332 @@
"""Tests for the skill builder (HyperCard-style card system)."""

import json
Comment on lines +9 to +21
from ciris_engine.logic.services.skill_import.builder import (
BehaviorCard,
IdentityCard,
InstructCard,
RequiresCard,
SkillBuilder,
SkillDraft,
ToolCard,
ToolParameter,
ToolsCard,
get_all_card_schemas,
get_card_schema,
)
Comment on lines +10 to +16
from ciris_engine.logic.services.skill_import.parser import (
OpenClawSkillParser,
ParsedSkill,
SkillInstallSpec,
SkillMetadata,
SkillRequirements,
)
Final 4 languages complete the full polyglot coverage:

Arabic (ar): خطر: هذه المهارة قد تكون ضارة. لا تستوردها.
Hindi (hi): खतरा: यह स्किल हानिकारक हो सकता है। आयात न करें।
Amharic (am): አደጋ፡ ይህ ክህሎት ጎጂ ሊሆን ይችላል። አያስገቡ።
Swahili (sw): HATARI: Ujuzi huu unaweza kuwa na madhara. USIUINGIZE.

Total: 2,025 translated strings (135 keys × 15 languages)
All languages: en, de, es, fr, it, pt, tr, ru, ja, ko, zh, ar, hi, am, sw

Every security warning communicates danger clearly in every language.
Every card hint uses simple, conversational tone for non-technical users.
"Keep the song singable for every voice not yet heard."

https://claude.ai/code/session_01361QLEDwCP5FrbykjoWi4n
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.

3 participants