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
- Inspect the registry entry and remove/replace any disallowed archive file references with the plain skill file paths the installer expects.
- Verify the registry manifest comes from a trusted source (check its hash/signature or re-sync it) in case it was tampered with.
- Fix the rel path you requested — it should point to a permitted skill file, not an archive.
- 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
- Never hand-edit the registry manifest to add archive files.
- Consume the registry only from the trusted upstream source and verify its integrity.
- Run a manifest lint step in CI that rejects disallowed file types before publishing.
- Keep the disallowed-file patterns in sync with your security policy.
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)