import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { mkdirSync, writeFileSync, symlinkSync, rmSync, mkdtempSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { withEnv } from './helpers/with-env.ts'; import type { OperationContext } from '../src/core/operations.ts'; import { OperationError } from '../src/core/operations.ts'; import { resolveSkillMdPath, getSkillDetail, resolveSkillsDir, assertPublishEnabled, } from '../src/core/skill-catalog.ts'; function ctx(remote: boolean, scopes?: string[]): OperationContext { return { remote, auth: scopes ? { token: 't', clientId: 'c', scopes } : undefined, config: {} as OperationContext['config'], engine: null as unknown as OperationContext['engine'], logger: { info() {}, warn() {}, error() {} }, dryRun: false, sourceId: 'default', } as OperationContext; } let root: string; let skillsDir: string; beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'skill-sec-')); skillsDir = join(root, 'skills'); mkdirSync(join(skillsDir, 'good'), { recursive: true }); writeFileSync(join(skillsDir, 'good', 'SKILL.md'), '---\nname: good\n---\n\nbody\n'); // An oversize skill (> 256KB default cap). mkdirSync(join(skillsDir, 'huge'), { recursive: true }); writeFileSync(join(skillsDir, 'huge', 'SKILL.md'), '---\nname: huge\n---\n' + 'x'.repeat(300 * 1024)); // A non-SKILL.md target reached via a poisoned manifest.json. writeFileSync(join(root, 'secret.txt'), 'TOP SECRET OUTSIDE SKILLS'); mkdirSync(join(skillsDir, 'notskill'), { recursive: true }); writeFileSync(join(skillsDir, 'notskill', 'data.md'), 'not a skill file'); }); afterAll(() => { try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ } }); describe('resolveSkillMdPath — name shape rejection (pre-FS)', () => { for (const bad of ['../etc/passwd', 'a/b', 'a\\b', 'xy', 'a..b', '']) { test(`rejects ${JSON.stringify(bad)}`, () => { expect(() => resolveSkillMdPath(skillsDir, bad)).toThrow(OperationError); }); } test('rejects an over-long name', () => { expect(() => resolveSkillMdPath(skillsDir, 'a'.repeat(200))).toThrow(/exceeds/); }); test('unknown name → page_not_found', () => { try { resolveSkillMdPath(skillsDir, 'nope'); throw new Error('should have thrown'); } catch (e) { expect(e).toBeInstanceOf(OperationError); expect((e as OperationError).code).toBe('page_not_found'); } }); }); describe('manifest path confinement (poisoned manifest.json)', () => { test('traversal path in manifest.json is blocked', () => { // manifest.json takes `path` verbatim — a traversal entry must be confined. writeFileSync( join(skillsDir, 'manifest.json'), JSON.stringify({ skills: [{ name: 'evil', path: '../secret.txt' }] }), ); try { resolveSkillMdPath(skillsDir, 'evil'); throw new Error('should have thrown'); } catch (e) { expect(e).toBeInstanceOf(OperationError); // Either escape (invalid_params) or non-SKILL.md (invalid_params). expect((e as OperationError).code).toBe('invalid_params'); } finally { rmSync(join(skillsDir, 'manifest.json'), { force: true }); } }); test('non-SKILL.md regular file is rejected', () => { writeFileSync( join(skillsDir, 'manifest.json'), JSON.stringify({ skills: [{ name: 'notskill', path: 'notskill/data.md' }] }), ); try { resolveSkillMdPath(skillsDir, 'notskill'); throw new Error('should have thrown'); } catch (e) { expect((e as OperationError).code).toBe('invalid_params'); } finally { rmSync(join(skillsDir, 'manifest.json'), { force: true }); } }); }); describe('symlink escape', () => { test('a SKILL.md symlinked outside the skills dir is rejected', () => { const linkDir = join(skillsDir, 'sneaky'); mkdirSync(linkDir, { recursive: true }); try { symlinkSync(join(root, 'secret.txt'), join(linkDir, 'SKILL.md')); } catch { return; // symlink unsupported on this platform — skip } try { resolveSkillMdPath(skillsDir, 'sneaky'); throw new Error('should have thrown'); } catch (e) { expect(e).toBeInstanceOf(OperationError); // realpath resolves outside root → escape; basename also != SKILL.md after deref expect((e as OperationError).code).toBe('invalid_params'); } finally { rmSync(linkDir, { recursive: true, force: true }); } }); }); describe('response-size cap', () => { test('oversize SKILL.md → payload_too_large', () => { try { getSkillDetail(ctx(true, ['read']), skillsDir, 'huge'); throw new Error('should have thrown'); } catch (e) { expect(e).toBeInstanceOf(OperationError); expect((e as OperationError).code).toBe('payload_too_large'); } }); }); describe('publish gate (pure)', () => { test('remote + disabled → permission_denied', () => { try { assertPublishEnabled(ctx(true, ['read']), false); throw new Error('should have thrown'); } catch (e) { expect((e as OperationError).code).toBe('permission_denied'); } }); test('remote + enabled → ok', () => { expect(() => assertPublishEnabled(ctx(true, ['read']), true)).not.toThrow(); }); test('local bypasses the gate even when disabled', () => { expect(() => assertPublishEnabled(ctx(false), false)).not.toThrow(); }); }); describe('resolveSkillsDir — remote never falls through to install_path', () => { test('remote caller with no config + no detectable dir → storage_error (NOT bundled skills)', async () => { // Drive autodetect into a dir tree with no skills/ anywhere up the chain, // no OpenClaw env, and HOME pointed at the empty tmp root. The remote path // uses autoDetectSkillsDir (no install_path tier), so it must throw rather // than serve gbrain's own bundled dev skills. const empty = mkdtempSync(join(tmpdir(), 'skill-empty-')); try { await withEnv( { GBRAIN_SKILLS_DIR: undefined, OPENCLAW_WORKSPACE: undefined, HOME: empty, }, () => { const prevCwd = process.cwd(); process.chdir(empty); try { expect(() => resolveSkillsDir(ctx(true), undefined)).toThrow(OperationError); } finally { process.chdir(prevCwd); } }, ); } finally { rmSync(empty, { recursive: true, force: true }); } }); test('explicit override that does not exist → storage_error', () => { expect(() => resolveSkillsDir(ctx(true), '/no/such/skills/dir')).toThrow(/does not exist/); }); });