Back to skill

Security audit

Openclaw Migration

Security checks for vulnerabilities and agentic risk

Overview

This migration skill mostly matches its stated purpose, but it can copy sensitive tokens and persist command permissions in ways that are broader than the user-facing instructions imply.

Review this skill before installing or running it. Use dry-run first, avoid user-data mode unless Discord and Slack token copying is fixed or explicitly acceptable, inspect any command allowlist entries before importing them, and ensure the target Hermes .env is protected with owner-only permissions.

Vulnerability Patterns
  • Unauthorized Access and Privilege EscalationObtains permissions beyond the task's legitimate needs
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
Findings (4)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/openclaw_to_hermes.py:924
Finding
Secret migration controls are bypassed for Discord and Slack tokens<![CDATA[ ## Vulnerability Details **File Location**: `scripts/openclaw_to_hermes.py:924-959` (preset inclusion also occurs at lines 118-139) **Vulnerability Type**: Secret migration without explicit authorization **Risk Level**: High ### Complete Code Snippet ```python def migrate_discord_settings(self, config: Optional[Dict[str, Any]] = None) -> None: config = config or self.load_openclaw_config() additions: Dict[str, str] = {} discord = config.get("channels", {}).get("discord", {}) if isinstance(discord, dict): token = discord.get("token") if isinstance(token, str) and token.strip(): additions["DISCORD_BOT_TOKEN"] = token.strip() allow_from = discord.get("allowFrom", []) if isinstance(allow_from, list): users = [str(u).strip() for u in allow_from if str(u).strip()] if users: additions["DISCORD_ALLOWED_USERS"] = ",".join(users) if additions: self.merge_env_values( additions, "discord-settings", self.source_root / "openclaw.json", ) else: self.record( "discord-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Discord settings found", ) def migrate_slack_settings(self, config: Optional[Dict[str, Any]] = None) -> None: config = config or self.load_openclaw_config() additions: Dict[str, str] = {} slack = config.get("channels", {}).get("slack", {}) if isinstance(slack, dict): bot_token = slack.get("botToken") if isinstance(bot_token, str) and bot_token.strip(): additions["SLACK_BOT_TOKEN"] = bot_token.strip() app_token = slack.get("appToken") if isinstance(app_token, str) and app_token.strip(): additions["SLACK_APP_TOKEN"] = app_token.strip() allow_from = slack.get("allowFrom", []) if isinstance(allow_from ...[truncated 2161 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Require `self.migrate_secrets` before extracting or writing every token: ```python if self.migrate_secrets: token = discord.get("token") if isinstance(token, str) and token.strip(): additions["DISCORD_BOT_TOKEN"] = token.strip() ``` 2. Separate non-secret channel settings from token migration so allowlists can still be migrated under `user-data`. 3. Remove token-bearing groups from the `user-data` preset, or split them into groups such as `discord-settings` and `discord-secret-settings`. 4. Maintain a single centralized allowlist of secret source fields and destination variables. 5. Add tests verifying that `--preset user-data` never modifies token-related environment variables. 6. Update `SKILL.md` so the documented secret allowlist exactly matches implementation behavior. ]]>

T09 · Insecure Skill Coding Practices

Error
Location
scripts/openclaw_to_hermes.py:256
Finding
Migrated credentials may be written to a permissively readable .env file<![CDATA[ ## Vulnerability Details **File Location**: `scripts/openclaw_to_hermes.py:256-260` **Vulnerability Type**: Insecure permissions for plaintext credential storage **Risk Level**: High ### Complete Code Snippet ```python def save_env_file(path: Path, data: Dict[str, str]) -> None: ensure_parent(path) lines = [f"{key}={value}" for key, value in data.items()] path.write_text( "\n".join(lines) + ("\n" if lines else ""), encoding="utf-8", ) ``` ### Technical Analysis The migration stores API keys and messaging tokens as plaintext environment variables. When `.env` does not already exist, `Path.write_text` creates it using permissions derived from the process umask. The function does not explicitly create the file with owner-only permissions or verify its final mode. Under a permissive umask, the file can be created with permissions such as `0644`, making it readable by other local users. Existing files with insecure permissions also remain insecure because the function does not correct their mode. This affects values such as Telegram, Discord, Slack, OpenRouter, OpenAI, Anthropic, and TTS provider credentials when the corresponding migration paths execute. ### Attack Path 1. A user executes migration on a multi-user system or under a process with a permissive umask. 2. The target `~/.hermes/.env` does not exist, or already has overly broad permissions. 3. The migration writes plaintext credentials using `Path.write_text`. 4. The resulting file is readable by users or processes outside the intended account boundary. 5. A local attacker reads `.env` and reuses the exposed tokens or API keys. ### Impact Assessment A local user with filesystem read access could obtain all credentials stored in the Hermes `.env` file. Compromise may enable unauthorized API consumption, financial charges, bot impersonation, access to messaging integrations, or use of provider accounts within the permissions granted to each exposed credenti ...[truncated 147 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Create new secret files atomically with owner-only mode `0600`. 2. Correct permissions on existing `.env` files before and after modification: ```python os.chmod(path, 0o600) ``` 3. Prefer writing to a temporary file created with `os.open` using `O_CREAT | O_EXCL` and mode `0600`, then atomically replace the destination. 4. Confirm that the parent directory is owned by the expected user and is not writable by untrusted users. 5. Preserve owner-only permissions during backup and restoration. 6. Add automated tests that run under permissive umasks and verify that the final file mode remains `0600`. ]]>

T05 · Unauthorized Access and Privilege Escalation

Error
Location
scripts/openclaw_to_hermes.py:1360
Finding
Directory migration follows untrusted symlinks without path-containment checks<![CDATA[ ## Vulnerability Details **File Location**: `scripts/openclaw_to_hermes.py:1360-1401` **Additional Locations**: `scripts/openclaw_to_hermes.py:1208-1226`, `1317-1335`, and `1450-1457` **Vulnerability Type**: Symlink traversal and out-of-scope filesystem access **Risk Level**: High ### Complete Code Snippet ```python def copy_tree_non_destructive( self, source_root: Optional[Path], destination_root: Path, kind: str, ignore_dir_names: Optional[set[str]] = None, ) -> None: if not source_root or not source_root.exists(): self.record(kind, None, destination_root, "skipped", "Source directory not found") return ignore_dir_names = ignore_dir_names or set() files = [ p for p in source_root.rglob("*") if p.is_file() and not any( part in ignore_dir_names for part in p.relative_to(source_root).parts[:-1] ) ] if not files: self.record(kind, source_root, destination_root, "skipped", "No files found") return copied = 0 skipped = 0 conflicts = 0 for source in files: rel = source.relative_to(source_root) destination = destination_root / rel if destination.exists(): if sha256_file(source) == sha256_file(destination): skipped += 1 continue if not self.overwrite: conflicts += 1 self.record( kind, source, destination, "conflict", "Destination file already exists", ) continue if self.execute: self.maybe_backup(destination) ensure_parent(destination) shutil.copy2(source, destination) copied += 1 ``` Related skill-copy paths use: ```python if final_destination == destination and destination.exists(): shutil.rmtree(destina ...[truncated 2425 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Reject symbolic links in all imported trees: ```python if source.is_symlink(): self.record(kind, source, None, "skipped", "Symbolic links are not permitted") continue ``` 2. Resolve every source and verify containment: ```python resolved = source.resolve(strict=True) resolved.relative_to(source_root.resolve(strict=True)) ``` 3. Resolve and validate destination parent paths against the approved target root before every write. 4. Use no-follow file operations where supported and fail closed if any path component is a symlink. 5. Avoid calling `rmtree` on paths whose ownership and containment have not been verified. 6. For `copytree`, provide a policy that rejects symlinks rather than following them. 7. Validate archive and backup destinations using the same containment rules. 8. Add tests containing source-file, source-directory, destination-file, and destination-directory symlink attacks. ]]>

T05 · Unauthorized Access and Privilege Escalation

Warning
Location
scripts/openclaw_to_hermes.py:737
Finding
Unvalidated command approval patterns can weaken Hermes execution controls<![CDATA[ ## Vulnerability Details **File Location**: `scripts/openclaw_to_hermes.py:737-786` **Vulnerability Type**: Unsafe import of execution authorization policy **Risk Level**: Medium ### Complete Code Snippet ```python patterns: List[str] = [] agents = data.get("agents", {}) if isinstance(agents, dict): for agent_data in agents.values(): allowlist = ( agent_data.get("allowlist", []) if isinstance(agent_data, dict) else [] ) for entry in allowlist: pattern = entry.get("pattern") if isinstance(entry, dict) else None if pattern: patterns.append(pattern) patterns = sorted(dict.fromkeys(patterns)) if not patterns: self.record( "command-allowlist", source, destination, "skipped", "No allowlist patterns found", ) return if not destination.exists(): self.record( "command-allowlist", source, destination, "skipped", "Hermes config.yaml does not exist yet", ) return config = load_yaml_file(destination) current = config.get("command_allowlist", []) if not isinstance(current, list): current = [] merged = sorted(dict.fromkeys(list(current) + patterns)) added = [pattern for pattern in merged if pattern not in current] if not added: self.record( "command-allowlist", source, destination, "skipped", "All patterns already present", ) return if self.execute: backup_path = self.maybe_backup(destination) config["command_allowlist"] = merged dump_yaml_file(destination, config) ``` ### Technical Analysis The script imports arbitrary command approval patterns from `exec-approvals.json` and merges them directly into Hermes' `command_allowlist`. It does not validate pattern syntax, reject broad wildcards, assess dangerous binaries, or require explicit approval for each new entry. An allowlist is a securi ...[truncated 1619 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. Do not import command approval rules by default. 2. Present every proposed new pattern to the user and require explicit confirmation before writing it. 3. Reject unrestricted wildcards and patterns matching shells, interpreters, privilege tools, downloaders, or destructive utilities. 4. Define a narrow, documented grammar for supported patterns and validate them before migration. 5. Treat patterns from different execution engines as incompatible unless their semantics have been verified. 6. Store imported patterns in a disabled review section until the user activates them. 7. Include old and new authorization policies in the migration report without including sensitive command arguments. 8. Add tests proving that broad patterns such as shell-wide or universal wildcards are rejected. ]]>
Vulnerability Patterns
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Rogue AgentSelf-Modification, Session Persistence
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
  • MCP Tool PoisoningHidden Instructions, Unicode Deception, Parameter Description Injection
  • Prompt InjectionInstruction Override, Hidden Instructions, Exfiltration Commands
Findings (19)

Ae1

High
Category
analysis-evasion
Content
2. If that path fails, inspect the installed skill directory and resolve the script relative to the installed `SKILL.md`.
Confidence
100% confidence
Finding
Referenced artifact was not completely inspected

Credential Access

High
Category
Privilege Escalation
Content
},
    "provider-keys": {
        "label": "Provider API keys",
        "description": "Import model provider API keys into Hermes .env (requires --migrate-secrets).",
    },
    "model-config": {
        "label": "Default model",
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
},
    "provider-keys": {
        "label": "Provider API keys",
        "description": "Import model provider API keys into Hermes .env (requires --migrate-secrets).",
    },
    "model-config": {
        "label": "Default model",
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
return {}

    def merge_env_values(self, additions: Dict[str, str], kind: str, source: Path) -> None:
        destination = self.target_root / ".env"
        env_data = parse_env_file(destination)
        added: Dict[str, str] = {}
        conflicts: List[str] = []
Confidence
90% confidence
Finding
The script merges sensitive values from OpenClaw configuration into Hermes .env, including tokens and API keys, and stores them in plaintext. In this skill context, credential movement is expected, but it is still dangerous because it broadens secret exposure, may overwrite existing values, and may place credentials into a less protected or more broadly used environment file.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
try:
                allow_data = json.loads(allowlist_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                self.record("messaging-settings", allowlist_path, self.target_root / ".env", "error", "Invalid JSON in Telegram allowlist file")
            else:
                allow_from = allow_data.get("allowFrom", [])
                if isinstance(allow_from, list):
Confidence
60% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Credential Access

High
Category
Privilege Escalation
Content
if additions:
            self.merge_env_values(additions, "discord-settings", self.source_root / "openclaw.json")
        else:
            self.record("discord-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Discord settings found")

    def migrate_slack_settings(self, config: Optional[Dict[str, Any]] = None) -> None:
        config = config or self.load_openclaw_config()
Confidence
84% confidence
Finding
Discord settings migration imports bot tokens into Hermes .env without additional per-secret confirmation or secret-store protections. In context, moving service bot credentials can enable account takeover or unauthorized messaging if the target environment is less trusted, backed up broadly, or accessible to other tools and users.

Credential Access

High
Category
Privilege Escalation
Content
if additions:
            self.merge_env_values(additions, "slack-settings", self.source_root / "openclaw.json")
        else:
            self.record("slack-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Slack settings found")

    def migrate_whatsapp_settings(self, config: Optional[Dict[str, Any]] = None) -> None:
        config = config or self.load_openclaw_config()
Confidence
84% confidence
Finding
Slack settings migration can copy bot and app tokens into the target .env, increasing credential exposure and potentially enabling unauthorized API access if the file is leaked or shared. The migration context makes this understandable but still sensitive because these tokens commonly grant broad workspace capabilities.

Lp3

Medium
Category
MCP Least Privilege
Confidence
88% confidence
Finding
The skill instructs the agent to read from and write to sensitive filesystem locations such as ~/.openclaw and ~/.hermes, but it declares no explicit tool scope or permission boundaries. That mismatch can cause an agent runtime to grant broader file capabilities than users expect, increasing the risk of unintended modification or import of sensitive local data during migration.

Session Persistence

Medium
Category
Rogue Agent
Content
hermes claw migrate              # Full interactive migration
hermes claw migrate --dry-run    # Preview what would be migrated
hermes claw migrate --preset user-data   # Migrate without secrets
hermes claw migrate --overwrite  # Overwrite existing conflicts
hermes claw migrate --source /custom/path/.openclaw  # Custom source
```
Confidence
60% confidence
Finding
Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Description-Behavior Mismatch

Medium
Confidence
96% confidence
Finding
The skill metadata and option descriptions materially understate behavior: the script can import multiple channel tokens, provider API keys, config values, and archive additional raw files beyond the high-level description. In a migration skill, incomplete disclosure is security-relevant because users may authorize execution without realizing sensitive credentials and configuration will be copied into Hermes-managed locations.

Intent-Code Divergence

Low
Confidence
82% confidence
Finding
The top-level docstring describes archiving selected unmapped docs for manual review, implying archival is limited to unmapped source documents. However, the code also writes overflowed memory entries to separate overflow files when character limits are exceeded, which is a different archival/output behavior not reflected by that documentation. This is a real intent/documentation mismatch, though low impact.

Static analysis

No suspicious patterns detected.