Back to skill

Security audit

Codex Account Switcher

Security checks for vulnerabilities and agentic risk

Overview

The skill mostly matches its stated account-switching purpose, but it needs review because it rewrites and duplicates login tokens, performs some of that persistence silently, and does not safely contain account-name file paths.

Review before installing. This skill handles live Codex credentials and can copy them into saved snapshots and OpenClaw agent files. Use it only on a machine where you trust local users and agents, avoid untrusted or unusual account names, prefer --dry-run and --agent for OpenClaw sync, and ensure credential directories are private. The path-validation and silent snapshot-write behavior should be fixed before broad use.

Vulnerability Patterns
  • 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
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (1)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/codex-accounts.py:556
Finding
Unrestricted account names allow path traversal and unintended credential file access<![CDATA[ ## Vulnerability Details **File Location**: `scripts/codex-accounts.py:556-568, 1005-1019, 1266-1280, 1578-1599, 1641-1644, 1661-1685` **Vulnerability Type**: Path traversal and unsafe credential-file handling **Risk Level**: High ### Vulnerable Code Account paths are constructed directly from unvalidated names: ```python def _resolve_unique_name_path(base_name: str) -> tuple[str, Path]: base = (base_name or "account").strip() or "account" target = ACCOUNTS_DIR / f"{base}.json" if not target.exists(): return base, target suffix = 2 while True: candidate_name = f"{base}-{suffix}" candidate = ACCOUNTS_DIR / f"{candidate_name}.json" if not candidate.exists(): return candidate_name, candidate suffix += 1 ``` The `compare` command uses caller-controlled names as read paths: ```python def cmd_compare(name_a: str, name_b: str, json_mode: bool = False): path_a = ACCOUNTS_DIR / f"{name_a}.json" path_b = ACCOUNTS_DIR / f"{name_b}.json" if not path_a.exists(): print(f"❌ Account snapshot not found for '{name_a}': {path_a}") return if not path_b.exists(): print(f"❌ Account snapshot not found for '{name_b}': {path_b}") return with open(path_a, "r") as f: a = json.load(f) with open(path_b, "r") as f: b = json.load(f) ``` The `use` command similarly accepts an unvalidated source path and copies it over the active authentication file: ```python def cmd_use(name, sync_openclaw: bool = False, agent_names: list[str] | None = None): ensure_dirs() source = ACCOUNTS_DIR / f"{name}.json" if not source.exists(): print(f"❌ Account '{name}' not found.") print("Available accounts:") for f in _iter_account_snapshot_files(): print(f" - {f.stem}") return # Backup current if it's not saved? # Maybe risky to overwrite silently, but that's what a switcher does ...[truncated 6818 chars]
Remediation
<![CDATA[ ## Remediation Suggestions 1. **Apply strict account-name validation** Use a centralized validator and permit only a conservative filename format, for example: ```python import re ACCOUNT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") def validate_account_name(name: str) -> str: value = name.strip() if not ACCOUNT_NAME_RE.fullmatch(value): raise ValueError("Invalid account name") return value ``` Apply this validator to `add --name`, `save`, `use`, `compare`, and all JWT-derived names. 2. **Enforce path containment** Construct paths through one helper, resolve the account directory and candidate path, and verify that the candidate is a direct child: ```python def account_path(name: str) -> Path: safe_name = validate_account_name(name) base = ACCOUNTS_DIR.resolve() candidate = (base / f"{safe_name}.json").resolve(strict=False) if candidate.parent != base: raise ValueError("Account path escapes the accounts directory") return candidate ``` Replace every direct expression of the form `ACCOUNTS_DIR / f"{name}.json"` with this helper. 3. **Reject symbolic links** Before reading or writing, use `lstat()` or `Path.is_symlink()` to reject symlink sources and destinations. Where supported, open files with no-follow semantics. 4. **Use atomic, restrictive credential writes** Write credentials to a temporary file created inside `ACCOUNTS_DIR`, set mode `0600`, flush and synchronize it, and atomically replace the validated destination. Explicitly create `ACCOUNTS_DIR` with mode `0700`. 5. **Treat JWT claims as untrusted input** Sanitize email local parts and user IDs before using them as filenames. If sanitization produces an empty or invalid name, require an explicit safe name instead of preserving path characters. 6. **Limit implicit writes** Reconsider running `sync_current_login_to_snapshot()` on every command ...[truncated 439 chars]
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Rogue AgentSelf-Modification, Session Persistence
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
Findings (20)

Credential Access

High
Category
Privilege Escalation
Content
account_id = _read_codex_account_id(data)
        user_id = _read_codex_user_id(data)

        # Prefer id_token for human-readable identity, then fall back to access token profile claims.
        tokens = _get_tokens(data)
        id_token = tokens.get('id_token')
        email = 'unknown'
Confidence
70% confidence
Finding
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Env Variable Harvesting

High
Category
Data Exfiltration
Content
# Prevent auto-opening the default browser. This avoids instantly re-logging
    # into whatever account is already signed into your primary browser profile.
    # You'll open the printed URL in the browser/profile you want.
    env = dict(os.environ)
    env["BROWSER"] = "/usr/bin/false"

    process = subprocess.Popen(
Confidence
60% confidence
Finding
Code enumerates, copies, or searches environment variables for secrets. Bulk environment access can collect credentials unrelated to the skill's stated purpose.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
Ensure these files have appropriate permissions:
```bash
chmod 600 ~/.codex/auth.json
chmod 700 ~/.codex/accounts
chmod 600 ~/.codex/accounts/*.json
```
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
Ensure these files have appropriate permissions:
```bash
chmod 600 ~/.codex/auth.json
chmod 700 ~/.codex/accounts
chmod 600 ~/.codex/accounts/*.json
```
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Content
Ensure these files have appropriate permissions:
```bash
chmod 600 ~/.codex/auth.json
chmod 700 ~/.codex/accounts
chmod 600 ~/.codex/accounts/*.json
```
Confidence
80% confidence
Finding
Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Lp3

Medium
Category
MCP Least Privilege
Confidence
97% confidence
Finding
The skill advertises and documents shell execution plus sensitive file read/write behavior, but it does not declare an explicit tool scope such as permissions or allowed-tools. That creates a real security gap because the runtime or reviewer cannot enforce least privilege from structured metadata, increasing the chance that an agent can access environment data and modify authentication material more broadly than expected.

Session Persistence

Medium
Category
Rogue Agent
Content
- Old name-based keys (e.g. `openai-codex:oliver`) are migrated automatically
- Each profile includes: `type`, `provider`, `access`, `refresh`, `expires`, `accountId`, `email`
- Also updates each selected agent's `auth.json` when it already has an `openai-codex` entry
- `--agent <name>` narrows the write scope to specific agents
- `sync --dry-run` shows what would be changed without writing files

This allows OpenClaw to use Codex accounts internally without requiring every local agent to be updated automatically.
Confidence
91% confidence
Finding
The skill intentionally persists and propagates access and refresh tokens into multiple local auth stores, including OpenClaw agent profiles and agent auth.json files. Even if intended functionality, duplicating bearer credentials across files and agents enlarges the attack surface: compromise of any one agent directory or profile store can expose reusable tokens and enable account switching or impersonation.

External Transmission

Medium
Category
Data Exfiltration
Content
return {}

    payload = decode_jwt_payload(access_token)
    auth = payload.get("https://api.openai.com/auth")
    return auth if isinstance(auth, dict) else {}
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

External Transmission

Medium
Category
Data Exfiltration
Content
return {}

    payload = decode_jwt_payload(access_token)
    auth = payload.get("https://api.openai.com/auth")
    return auth if isinstance(auth, dict) else {}
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

External Transmission

Medium
Category
Data Exfiltration
Content
return {}

    payload = decode_jwt_payload(access_token)
    auth = payload.get("https://api.openai.com/auth")
    return auth if isinstance(auth, dict) else {}
Confidence
60% confidence
Finding
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
before_mtime = AUTH_FILE.stat().st_mtime if AUTH_FILE.exists() else 0

    subprocess.run(["codex", "logout"], capture_output=True)

    # This typically opens the system browser and completes via localhost callback.
    # Prevent auto-opening the default browser. This avoids instantly re-logging
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
before_mtime = AUTH_FILE.stat().st_mtime if AUTH_FILE.exists() else 0

    subprocess.run(["codex", "logout"], capture_output=True)

    # This typically opens the system browser and completes via localhost callback.
    # Prevent auto-opening the default browser. This avoids instantly re-logging
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
env = dict(os.environ)
    env["BROWSER"] = "/usr/bin/false"

    process = subprocess.Popen(
        ["codex", "login"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
subprocess.run(["codex", "logout"], capture_output=True)
    
    # 2. Start login process
    process = subprocess.Popen(
        ["codex", "login", "--device-auth"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

subprocess module call

Medium
Category
Dangerous Code Execution
Content
# Probe codex to get a fresh session (for rate limit info)
    session_id = None
    try:
        result = subprocess.run(
            [
                "codex",
                "exec",
Confidence
70% confidence
Finding
subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Missing User Warnings

Medium
Confidence
94% confidence
Finding
`cmd_auto` iterates through accounts by copying each snapshot into the active `AUTH_FILE` and finally rewrites `auth.json` to the selected account, all without confirmation. Because this directly changes live authentication state and can also optionally propagate tokens into OpenClaw stores, an unintended invocation can switch identities and affect downstream tools or sessions.

Description-Behavior Mismatch

Medium
Confidence
95% confidence
Finding
The code annotates saved snapshots with `decoded_tokens`, persisting decoded JWT headers and payload claims into account files and exposing them through comparison features. This unnecessarily broadens collection and retention of identity metadata from tokens, increasing local sensitivity and the blast radius if those files are accessed by other users or tools.

Description-Behavior Mismatch

Medium
Confidence
96% confidence
Finding
The script silently persists the current `~/.codex/auth.json` back into saved account snapshots on every invocation via `sync_current_login_to_snapshot()`, even for commands like listing accounts. Because these files contain live authentication material, this expands behavior beyond explicit user intent and can duplicate or refresh sensitive credentials without clear consent.

Missing User Warnings

Medium
Confidence
97% confidence
Finding
Routine execution paths silently update credential-bearing snapshot files without a warning, causing authentication material to be copied and retained even when the user did not request saving or syncing. In a skill explicitly marked as sensitive and handling local auth stores, this hidden write behavior materially increases the risk of accidental credential persistence and misuse.

Intent-Code Divergence

Low
Confidence
91% confidence
Finding
The inline comment says 'Prefer access_token expiry (long-lived)' while _read_token_exp_seconds explicitly documents that access/id tokens are intentionally short-lived. The implementation also does not truly prefer access_token; it iterates both access_token and id_token and keeps whichever exp is later, so the comment misstates both token characteristics and the actual logic.

Static analysis

No suspicious patterns detected.