garrytan/gstack · error · Error
Invalid file path in stageSkill: "${relPath}".
Error message
Invalid file path in stageSkill: "${relPath}". What it means
Thrown by stageSkill inside the per-file loop when a relPath starts with '/' or contains '..'. This is defense in depth: validateSkillName bounds the leaf directory, but the files map keys are arbitrary relative paths and a malicious or buggy one could otherwise write outside the staged dir (e.g. '../../etc/cron.d/evil'). The check is purely lexical — it does not resolve symlinks.
Source
Thrown at browse/src/browser-skill-write.ts:85
export function stageSkill(opts: StageSkillOptions): string {
validateSkillName(opts.name);
if (opts.files.size === 0) {
throw new Error('stageSkill: files map is empty.');
}
const spawnId = opts.spawnId ?? generateSpawnId();
const tmpRoot = opts.tmpRoot ?? path.join(os.homedir(), '.gstack', '.tmp');
const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);
const stagedDir = path.join(wrapperDir, opts.name);
mkdirSecure(wrapperDir);
mkdirSecure(stagedDir);
for (const [relPath, contents] of opts.files) {
if (relPath.startsWith('/') || relPath.includes('..')) {
// Defense in depth: validateSkillName above bounds the leaf, but a
// bad relPath in files could still write outside the staged dir.
throw new Error(`Invalid file path in stageSkill: "${relPath}".`);
}
const filePath = path.join(stagedDir, relPath);
const fileDir = path.dirname(filePath);
fs.mkdirSync(fileDir, { recursive: true });
fs.writeFileSync(filePath, contents);
}
return stagedDir;
}
// ─── Commit (atomic rename) ─────────────────────────────────────
export interface CommitSkillOptions {
name: string;
tier: 'project' | 'global';
stagedDir: string;
/** Optional override (tests pass synthetic tier paths). */
tiers?: TierPaths;View on GitHub (pinned to 94993f7401)
Solutions
- Use relative paths only: 'SKILL.md', 'script.ts', '_lib/client.ts', 'fixtures/data.json'.
- Strip leading slashes and reject '..' segments when constructing paths from external input.
- For paths derived from URLs or filenames, normalize with path.posix.normalize and re-check the result starts inside the staged root.
Example fix
// before
stageSkill({ name: 'foo', files: new Map([['/SKILL.md', '...'], ['../script.ts', '...']]) });
// after
stageSkill({ name: 'foo', files: new Map([['SKILL.md', '...'], ['script.ts', '...']]) }); Defensive patterns
Strategy: validation
Validate before calling
import * as path from 'fs/promises';
function sanitizeRelPath(relPath: string): string {
if (relPath.startsWith('/') || relPath.includes('..')) {
throw new Error(`Invalid file path: "${relPath}"`);
}
// optional: reject backslash, NUL, etc.
if (/[\\\x00]/.test(relPath)) {
throw new Error(`Invalid file path: "${relPath}"`);
}
return relPath;
}
// before stageSkill, normalize every key:
const sanitized = new Map([...files].map(([k, v]) => [sanitizeRelPath(k), v])); Prevention
- Build relPaths from trusted constants ('SKILL.md', 'script.ts') where possible.
- When constructing paths from external input, normalize and re-validate that the resolved path stays inside the staged root.
- Never accept absolute paths in the files map keys.
When it happens
Trigger: stageSkill with a files map containing keys like '/etc/passwd' (absolute), '../escape.ts' (parent traversal), 'foo/../../bar.ts' (nested traversal), or any key that happens to contain the substring '..'.
Common situations: Agent-generated paths that prefixed a slash by mistake; templating that joined an absolute output dir into the relPath; a malicious or prompt-injected agent trying to escape the staging tree; relPath built from user input that contained '..'.
Related errors
- Invalid skill name "${name}". Must be lowercase letters/digi
- commitSkill: staged dir "${opts.stagedDir}" is a symlink — r
- commitSkill: staged path "${opts.stagedDir}" is not a direct
- commitSkill: destination "${dest}" escapes tier root.
- Skill "${name}" not found in any tier.
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/6c4a4c14cb9c1992.
Report an issue: GitHub.