Files
gbrain/test/skill-catalog-security.test.ts
Garry Tan 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
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/);
});
});