diff --git a/core/src/skills/loader.ts b/core/src/skills/loader.ts index dacda6b5a..e9b7488b1 100644 --- a/core/src/skills/loader.ts +++ b/core/src/skills/loader.ts @@ -180,7 +180,11 @@ export function parseSkillMdContent(content: string): { /** * Checks whether a zip member name attempts to escape the extraction root (zip - * slip), mirroring adk-python's `_load_skill_from_zip_bytes`. This is a + * slip). adk-python's `_load_skill_from_zip_bytes` tests `startswith("/")`, + * `startswith("../")` and `"/../" in filename`, which miss a `..` component + * that no `/` follows (`scripts/..`) and miss backslash separators. adk-js is + * deliberately stricter: it splits on both separators and rejects any component + * that is exactly `..`, a strict superset of what Python rejects. This is a * name-shape check on archive metadata, not a sandbox: it says nothing about * symlinks. */ diff --git a/core/test/skills/loader_test.ts b/core/test/skills/loader_test.ts index b793dee0b..bc65cae14 100644 --- a/core/test/skills/loader_test.ts +++ b/core/test/skills/loader_test.ts @@ -618,12 +618,44 @@ Instruction body`; 'references/../../esc.txt', 'scripts/..', 'scripts\\..\\..\\pwned.txt', + '..', + '..\\evil.txt', ])('rejects the whole archive for the dangerous entry %s', (entryName) => { expect(() => loadSkillFromZipBuffer(createZipWithRawEntryName(entryName)), ).toThrow(`Dangerous zip entry ignored: ${entryName}`); }); + it('accepts a reference whose name only contains ".." as a substring', () => { + const zip = new AdmZip(); + zip.addFile('SKILL.md', Buffer.from(validSkillMd, 'utf-8')); + zip.addFile('references/..hidden.txt', Buffer.from('ref body', 'utf-8')); + + const skill = loadSkillFromZipBuffer(zip.toBuffer()); + + expect(skill.resources?.references?.['..hidden.txt']).toBe('ref body'); + }); + + it('accepts an asset whose name only contains ".." as a substring', () => { + const zip = new AdmZip(); + zip.addFile('SKILL.md', Buffer.from(validSkillMd, 'utf-8')); + zip.addFile('assets/v1..2.bin', Buffer.from('asset body', 'utf-8')); + + const skill = loadSkillFromZipBuffer(zip.toBuffer()); + + expect(skill.resources?.assets?.['v1..2.bin']).toBe('asset body'); + }); + + it('accepts a script whose name only contains ".." as a substring', () => { + const zip = new AdmZip(); + zip.addFile('SKILL.md', Buffer.from(validSkillMd, 'utf-8')); + zip.addFile('scripts/a..b.sh', Buffer.from('echo hello', 'utf-8')); + + const skill = loadSkillFromZipBuffer(zip.toBuffer()); + + expect(skill.resources?.scripts?.['a..b.sh']?.src).toBe('echo hello'); + }); + it('reports the dangerous entry even when SKILL.md is absent', () => { const zip = new AdmZip(); zip.addFile('placeholder.txt', Buffer.from('x', 'utf-8'));