BigPizzaV3/CodexPlusPlus · error · anyhow::Error

refusing to delete script outside user script directory

Error message

refusing to delete script outside user script directory

What it means

The final filesystem guard in delete_user_script: if the target exists, both the file and the user scripts directory are canonicalized (symlinks resolved), and the file's canonical path must start_with the canonical directory's path; otherwise it bails with "refusing to delete script outside user script directory". This defeats symlink attacks — a link placed inside user_dir but pointing elsewhere must not cause deletion of the link target.

Source

Thrown at crates/codex-plus-core/src/user_scripts.rs:139

        let canonical_user_dir = self
            .user_dir
            .canonicalize()
            .or_else(|_| {
                fs::create_dir_all(&self.user_dir)?;
                self.user_dir.canonicalize()
            })
            .with_context(|| {
                format!(
                    "failed to resolve user script directory {}",
                    self.user_dir.display()
                )
            })?;
        if path.exists() {
            let canonical_path = path
                .canonicalize()
                .with_context(|| format!("failed to resolve user script {}", path.display()))?;
            if !canonical_path.starts_with(&canonical_user_dir) {
                anyhow::bail!("refusing to delete script outside user script directory");
            }
            fs::remove_file(&canonical_path).with_context(|| {
                format!("failed to delete user script {}", canonical_path.display())
            })?;
        }

        let _guard = self.config_lock.lock().unwrap();
        let mut config = self.load_config_unlocked();
        config.scripts.remove(key);
        config.market.remove(key);
        self.save_config_unlocked(&config)?;
        Ok(config)
    }

    pub fn user_script_path_for_market_id(&self, id: &str) -> PathBuf {
        self.user_dir.join(market_script_filename(id))
    }

View on GitHub (pinned to 1f431ae49b)

Solutions

  1. Replace symlinks in the user scripts directory with real copies of the scripts
  2. If deleting the symlink itself is the intent, remove it manually (rm <user_dir>/x.sh) — the library deliberately refuses
  3. Audit how the symlink got there if you did not create it; treat it as a possible tampering signal

Example fix

# instead of a symlink inside the scripts dir
cp /path/to/shared/tool.js ~/.codex-plus/user-scripts/tool.js

# then the manager delete works:
# manager.delete_user_script("user:tool.js")
Defensive patterns

Strategy: try-catch

Validate before calling

let name = key.strip_prefix("user:").unwrap_or(key);
let path = user_dir.join(name);
if path.is_symlink() {
    anyhow::bail!("refusing to operate on symlinked script: {}", path.display());
}
let config = manager.delete_user_script(&key)?;

Try / catch

match manager.delete_user_script(&key) {
    Ok(config) => { /* done */ }
    Err(err) if err.to_string().contains("outside user script directory") => {
        // security signal: possible symlink/tampering — report, do NOT retry or delete manually from app code
    }
    Err(err) => return Err(err),
}

Prevention

When it happens

Trigger: A file inside the user scripts directory is a symlink to a file elsewhere (e.g. ln -s ~/.bashrc <user_dir>/x.sh) and delete_user_script("user:x.sh") is called; canonicalize() resolves the link, the target lies outside canonical_user_dir, and the delete is refused. Also fires in the race window if the entry is swapped for a symlink between the exists() check and canonicalization.

Common situations: Users symlinking shared scripts into the scripts dir instead of copying; dotfile-manager cross-links; security tests verifying the guard; TOCTOU attempts.

Related errors


AI-assisted analysis of BigPizzaV3/CodexPlusPlus@1f431ae49b (2026-08-16). Data as JSON: /api/errors/e95803fc6a6719e6. Report an issue: GitHub.