Files
gbrain/test/skill-catalog-security.test.ts
T
ca13f40820 v0.41.36.0 feat(mcp): publish agent skills (list_skills / get_skill) for thin clients (#1661)
* feat(skill-catalog): host-repo skill catalog core + mcp config keys

New src/core/skill-catalog.ts resolves the agent repo's skills dir, builds a
flat catalog, fetches one skill's prose, and gates publishing. Path confinement
(manifest-vetted lookup + realpath + SKILL.md file-type check), 256KB response
cap, frontmatter allowlist, and D7 tool cross-reference live here. config.ts
gains the mcp.publish_skills / mcp.skills_dir keys (+ KNOWN_CONFIG entries).

* feat(mcp): list_skills + get_skill read ops for thin-client skill discovery

Two read-scope, non-localOnly ops (dynamic-import skill-catalog to avoid the
cycle) let Codex/Claude Code/Perplexity discover and follow the agent's skills
over gbrain serve. Descriptions + the instructional envelope constants are
pinned in operations-descriptions.ts.

* feat(mcp): default new installs to publish skills; consent prompt on upgrade

gbrain init writes mcp.publish_skills:true (file plane) so new installs publish
by default. gbrain upgrade prompts existing installs once (DB plane), strongly
recommending opt-in, showing the resolved skills dir + that SKILL.md contents
become readable by authorized remote MCP callers.

* test(skill-catalog): catalog, security-confinement, transport-gate, description pins

40 cases: buildSkillCatalog/getSkillDetail/D7 split (skill-catalog.test.ts),
path traversal + symlink + poisoned-manifest + oversize + non-SKILL.md +
gate (skill-catalog-security.test.ts), and real dispatchToolCall gate+scope+plane
coverage (skill-catalog-transports.test.ts). Plus list_skills/get_skill
description + envelope pins.

* chore: bump version and changelog (v0.41.36.0)

MCP skill catalog (list_skills / get_skill) — thin clients can discover and
follow the agent repo's skills over gbrain serve. PR2 (tarball/install) filed
in TODOS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document skill-catalog (list_skills/get_skill + mcp config keys) for v0.41.36.0

Add the src/core/skill-catalog.ts Key-files annotation to CLAUDE.md covering the
two new read-scope MCP ops, the mcp.publish_skills / mcp.skills_dir config keys,
the full trust-boundary mitigation stack, and the init/upgrade wiring.
Regenerate llms-full.txt to match (CI build-llms drift gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:11:25 -07:00

187 lines
6.6 KiB
TypeScript

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/);
});
});