// ---------------------------------------------------------------------------
// skill-resolver.ts -- shared skill directory resolution
//
// 0769 T-006: adds resolveAllowedSkillDir() — a registry-aware variant that
// supports skills whose directory lives OUTSIDE the studio root (e.g. plugin-
// cache snapshots at ~/.claude/plugins/cache/...) while still preventing path
// traversal via an explicit allowlist of root prefixes.
// ---------------------------------------------------------------------------
import { existsSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { join, basename, resolve } from "node:path";
import { getSkillDirEntry } from "./skill-dir-registry.js";
function assertContained(candidate, root) {
    const resolved = resolve(candidate);
    if (!resolved.startsWith(resolve(root))) {
        throw new Error("Invalid skill path: directory traversal detected");
    }
}
export function resolveSkillDir(root, plugin, skill) {
    // Layout 4 (self): root IS the skill directory (has SKILL.md)
    if (basename(root) === skill && existsSync(join(root, "SKILL.md")))
        return root;
    // Try direct layout: {root}/{plugin}/skills/{skill}/
    const directPath = join(root, plugin, "skills", skill);
    assertContained(directPath, root);
    if (existsSync(directPath))
        return directPath;
    // Try nested plugins/ layout: {root}/plugins/{plugin}/skills/{skill}/
    const nestedPath = join(root, "plugins", plugin, "skills", skill);
    assertContained(nestedPath, root);
    if (existsSync(nestedPath))
        return nestedPath;
    // Try root layout: {root}/skills/{skill}/
    const rootPath = join(root, "skills", skill);
    assertContained(rootPath, root);
    if (existsSync(rootPath))
        return rootPath;
    // Try flat layout: {root}/{skill}/ (skills as direct children)
    const flatPath = join(root, skill);
    assertContained(flatPath, root);
    if (existsSync(join(flatPath, "SKILL.md")))
        return flatPath;
    return directPath;
}
/**
 * 0769 T-006: registry-aware skill directory resolver with explicit allowlist.
 *
 * - If the registry has an entry for (plugin, skill), validate its `dir`
 *   against `allowedRoots` (after path.resolve normalization, plus a
 *   realpath check to defeat symlink escapes) and return it.
 * - Otherwise fall back to the root-based `resolveSkillDir()`.
 *
 * Allowlist matching is prefix-based: a candidate is accepted iff its
 * resolved path starts with the resolved form of at least one allowed root.
 * Inputs containing '..' or symlinks whose realpath escapes the allowlist
 * are rejected with an informative error.
 */
export function resolveAllowedSkillDir(root, plugin, skill, allowedRoots) {
    const entry = getSkillDirEntry(plugin, skill);
    if (entry) {
        return validateAgainstAllowlist(entry.dir, allowedRoots);
    }
    // 0823 F-006: when no registry entry exists, try plugin-scoped fallbacks
    // for installed-agent-dir plugins (.claude, .cursor, .gemini, ...). Each
    // candidate is restricted to roots that match the plugin's agent dir to
    // prevent cross-plugin name collisions (e.g. a `.cursor` request must NOT
    // accidentally resolve to a same-named skill under `~/.claude/skills/`).
    //
    // The plugin-aware preference order is:
    //   (1) ~/{plugin}/skills/<skill>      — agent's own canonical location
    //   (2) ~/.agents/skills/<skill>       — vskill --global canonical location
    //                                         (cross-agent, accepted only when
    //                                         the plugin is an agent-dir form)
    // Both candidates still pass through validateAgainstAllowlist so symlink
    // escapes and traversal segments are rejected.
    if (plugin && plugin.startsWith(".")) {
        // Tier 1: skill under the agent's own home dir (~/.claude/skills/<skill>)
        const home = homedir();
        const agentLocal = join(home, plugin, "skills", skill);
        if (existsSync(join(agentLocal, "SKILL.md"))) {
            try {
                return validateAgainstAllowlist(agentLocal, allowedRoots);
            }
            catch (err) {
                // 0823 F-005: log allowlist rejections (typically symlink escape) so
                // operators can spot tampering. Caller still gets the next-tier
                // candidate via the fall-through below.
                console.warn(`[skill-resolver] tier-1 candidate rejected for ${plugin}/${skill}:`, err.message);
            }
        }
        // Tier 2: shared ~/.agents/skills/<skill> (vskill --global)
        const agentsShared = join(home, ".agents", "skills", skill);
        if (existsSync(join(agentsShared, "SKILL.md"))) {
            try {
                return validateAgainstAllowlist(agentsShared, allowedRoots);
            }
            catch (err) {
                console.warn(`[skill-resolver] tier-2 candidate rejected for ${plugin}/${skill}:`, err.message);
            }
        }
    }
    return resolveSkillDir(root, plugin, skill);
}
function validateAgainstAllowlist(candidate, allowedRoots) {
    // 0769 F-001: segment-based traversal check — `includes("..")` is too coarse
    // and would also reject legitimate paths whose filename contains a literal
    // ".." substring (e.g. "a..b"). Reject only when ".." appears as a path
    // SEGMENT separator-bounded.
    const segments = candidate.split(/[\\/]/);
    if (segments.some((seg) => seg === "..")) {
        throw new Error(`Invalid skill path: directory traversal detected in "${candidate}"`);
    }
    const resolved = resolve(candidate);
    // Normalize roots both logically (path.resolve) AND via realpath so symlink-
    // backed temp dirs (e.g. macOS /tmp -> /private/tmp) match in both directions.
    const logicalRoots = allowedRoots.map((r) => resolve(r));
    const realRoots = logicalRoots.map((r) => safeRealpath(r));
    const fitsLogically = logicalRoots.some((r) => resolved.startsWith(r)) ||
        realRoots.some((r) => resolved.startsWith(r));
    if (!fitsLogically) {
        throw new Error(`Invalid skill path: "${resolved}" is outside allowlist`);
    }
    // Defeat symlink escape: the realpath must also remain inside the allowlist.
    let real;
    try {
        real = realpathSync(resolved);
    }
    catch {
        // The path may not exist yet (route handler will then return 404). Fall
        // back to the logical-path check above when realpath fails.
        return resolved;
    }
    const fitsReal = logicalRoots.some((r) => real.startsWith(r)) ||
        realRoots.some((r) => real.startsWith(r));
    if (!fitsReal) {
        throw new Error(`Invalid skill path: realpath "${real}" is outside allowlist`);
    }
    return resolved;
}
function safeRealpath(p) {
    try {
        return realpathSync(p);
    }
    catch {
        return p;
    }
}
//# sourceMappingURL=skill-resolver.js.map