midudev/autoskills · critical · Error

refusing to download disallowed skill archive

Error message

refusing to download disallowed skill archive: ${normalizedRel}

What it means

Before downloading any file from the skills registry, downloadRegistryFile normalizes the relative path and checks it against isDisallowedSkillFile(). This is a security guard: archives (e.g. .zip/.tgz) and other forbidden file types must never be fetched, even if the registry manifest lists them. Throwing here prevents a malicious or corrupted registry entry from pulling executable archives onto the machine.

Solutions

  1. Inspect the registry entry and remove/replace any disallowed archive file references with the plain skill file paths the installer expects.
  2. Verify the registry manifest comes from a trusted source (check its hash/signature or re-sync it) in case it was tampered with.
  3. Fix the rel path you requested — it should point to a permitted skill file, not an archive.
  4. If you control a custom registry, repackage skills as individual files instead of archives.

Example fix

// registry entry (before)
{ "files": ["skills/foo/foo.zip"] }
// after
{ "files": ["skills/foo/SKILL.md", "skills/foo/scripts/run.sh"] }
Defensive patterns

Strategy: validation

Validate before calling

import { statSync } from "node:fs";
const FORBIDDEN = [/\.zip$/i, /\.(t|tar)\.gz$/i, /\.tgz$/i, /\.tar$/i];
function registryEntryIsSafe(entry) {
  return Object.keys(entry.sha256).every(rel => !FORBIDDEN.some(re => re.test(rel)));
}
// before installing: if (!registryEntryIsSafe(entry)) throw new Error("registry entry contains archives");

Type guard

const isSafeRelPath = (rel) => typeof rel === "string" && !rel.startsWith("/") && !rel.includes("..") && !/\.(zip|tgz|tar|gz)$/i.test(rel);

Try / catch

try {
  await downloadRegistryEntry(name, entry, dest);
} catch (e) {
  if (e.message.startsWith("refusing to download disallowed skill archive")) {
    reportSecurityEvent(e.message); // never bypass; fix the registry
  } else throw e;
}

Prevention

When it happens

Trigger: Requesting a registry rel path that passes normalizeRegistryRelPath but matches the disallowed-skill-file patterns (e.g. a skill tarball or zip referenced by a RegistryEntry), via downloadRegistryFile -> downloadRegistryEntry.

Common situations: A tampered or hand-edited skills-registry.json listing a skill as an archive; a typo in a rel path that accidentally resolves to a forbidden filename; testing against a custom registry that uses archives while the installer only permits plain skill files.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.


AI-assisted analysis of midudev/autoskills@0ec725320d (2026-09-15). Data as JSON: /api/errors/a48c26d976cc654d. Report an issue: GitHub.

Appendix: source

Thrown at packages/autoskills/installer.ts:281

    headers.Authorization = `Bearer ${GITHUB_TOKEN}`;
  }
  return headers;
}

function isDisallowedSkillFile(rel: string): boolean {
  return rel.toLowerCase().endsWith(".zip");
}

async function downloadRegistryFile(
  skillName: string,
  entry: RegistryEntry,
  rel: string,
  opts: InstallOptions,
): Promise<{ buf: Buffer; url: string }> {
  const normalizedRel = normalizeRegistryRelPath(rel);

  if (isDisallowedSkillFile(normalizedRel)) {
    throw new Error(`refusing to download disallowed skill archive: ${normalizedRel}`);
  }

  const expected = entry.sha256[rel] || entry.sha256[normalizedRel];
  if (!expected) {
    throw new Error(`no recorded hash for ${normalizedRel}`);
  }

  const fetchFile = opts.fetchImpl || fetch;
  const errors = [];
  for (const baseUrl of getRegistryRawBaseUrls(opts)) {
    const url = `${baseUrl}/${encodeRawPath(skillName, normalizedRel)}`;
    opts.onTrace?.(`GET ${url}`);
    const res = await fetchFile(url, {
      headers: githubDownloadHeaders(url),
    });
    if (!res.ok) {
      const resetAt = Number(res.headers.get("x-ratelimit-reset") || 0) * 1000;
      const resetSuffix = resetAt ? ` (resets ${new Date(resetAt).toISOString()})` : "";

View on GitHub (pinned to 0ec725320d)