mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
190 lines
8.1 KiB
TypeScript
190 lines
8.1 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import {
|
|
GET_RECENT_SALIENCE_DESCRIPTION,
|
|
FIND_ANOMALIES_DESCRIPTION,
|
|
GET_RECENT_TRANSCRIPTS_DESCRIPTION,
|
|
LIST_PAGES_DESCRIPTION,
|
|
QUERY_DESCRIPTION,
|
|
SEARCH_DESCRIPTION,
|
|
LIST_SKILLS_DESCRIPTION,
|
|
GET_SKILL_DESCRIPTION,
|
|
SKILL_CATALOG_INSTRUCTIONS,
|
|
SKILL_CLIENT_GUIDANCE,
|
|
} from '../src/core/operations-descriptions.ts';
|
|
import { operations, operationsByName } from '../src/core/operations.ts';
|
|
import { BRAIN_TOOL_ALLOWLIST } from '../src/core/minions/tools/brain-allowlist.ts';
|
|
|
|
/**
|
|
* Tool descriptions are LLM-facing strings that drive routing. v0.29 adds
|
|
* three new ops + redirects on three existing ones. These tests pin the
|
|
* key phrases that the routing decision depends on so accidental edits
|
|
* (description rewrites, AI cleanup, voice changes) fail CI.
|
|
*/
|
|
|
|
describe('v0.29 — get_recent_salience description', () => {
|
|
test('matches the operation registration', () => {
|
|
expect(operationsByName['get_recent_salience'].description).toBe(GET_RECENT_SALIENCE_DESCRIPTION);
|
|
});
|
|
|
|
test('contains the explicit "Use this when" trigger phrase', () => {
|
|
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("Use this when the user asks");
|
|
});
|
|
|
|
test('lists the personal-query trigger keywords', () => {
|
|
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("what's been going on");
|
|
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("anything crazy happening");
|
|
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("notable");
|
|
});
|
|
|
|
test('explicitly bans semantic search for the same intent', () => {
|
|
expect(GET_RECENT_SALIENCE_DESCRIPTION).toContain("Do NOT run a semantic search for these");
|
|
});
|
|
});
|
|
|
|
describe('v0.29 — find_anomalies description', () => {
|
|
test('matches the operation registration', () => {
|
|
expect(operationsByName['find_anomalies'].description).toBe(FIND_ANOMALIES_DESCRIPTION);
|
|
});
|
|
|
|
test('mentions the cohort framing', () => {
|
|
expect(FIND_ANOMALIES_DESCRIPTION).toContain("grouped by cohort");
|
|
expect(FIND_ANOMALIES_DESCRIPTION).toContain("(tag or type)");
|
|
});
|
|
|
|
test('lists the unusual / what-stood-out trigger phrases', () => {
|
|
expect(FIND_ANOMALIES_DESCRIPTION).toContain("stood out");
|
|
expect(FIND_ANOMALIES_DESCRIPTION).toContain("unusual");
|
|
});
|
|
|
|
test('does not promise year-cohort behavior (deferred to v0.30)', () => {
|
|
// v0.29 ships tag + type only. The phrase below confirms the description
|
|
// does not lie about coverage — surfacing year would route the LLM to
|
|
// call the op for date-bucket questions it can't actually serve.
|
|
expect(FIND_ANOMALIES_DESCRIPTION).toContain("Cohort kinds: tag, type");
|
|
});
|
|
});
|
|
|
|
describe('v0.29 — get_recent_transcripts description', () => {
|
|
test('matches the operation registration', () => {
|
|
expect(operationsByName['get_recent_transcripts'].description).toBe(GET_RECENT_TRANSCRIPTS_DESCRIPTION);
|
|
});
|
|
|
|
test('mandates priority over query/search for personal questions', () => {
|
|
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("FIRST");
|
|
});
|
|
|
|
test('explains raw vs polished distinction', () => {
|
|
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("NOT polished");
|
|
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("canonical source");
|
|
});
|
|
|
|
test('discloses the local-only constraint to the LLM', () => {
|
|
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("Local-only");
|
|
expect(GET_RECENT_TRANSCRIPTS_DESCRIPTION).toContain("permission_denied");
|
|
});
|
|
});
|
|
|
|
describe('v0.29 — redirect hints on existing ops', () => {
|
|
test('list_pages mentions sort=updated_desc as the recency-question answer', () => {
|
|
expect(operationsByName['list_pages'].description).toBe(LIST_PAGES_DESCRIPTION);
|
|
expect(LIST_PAGES_DESCRIPTION).toContain("sort=updated_desc");
|
|
expect(LIST_PAGES_DESCRIPTION).toContain("what did I touch this week");
|
|
});
|
|
|
|
test('query redirects personal/emotional queries to the v0.29 ops', () => {
|
|
expect(operationsByName['query'].description).toBe(QUERY_DESCRIPTION);
|
|
expect(QUERY_DESCRIPTION).toContain("get_recent_salience");
|
|
expect(QUERY_DESCRIPTION).toContain("find_anomalies");
|
|
expect(QUERY_DESCRIPTION).toContain("get_recent_transcripts");
|
|
});
|
|
|
|
test('query warns the LLM not to assume "crazy" means impressive', () => {
|
|
expect(QUERY_DESCRIPTION).toContain("Do NOT assume");
|
|
expect(QUERY_DESCRIPTION).toContain("difficult or emotionally charged");
|
|
});
|
|
|
|
test('search has the shorter redirect hint', () => {
|
|
expect(operationsByName['search'].description).toBe(SEARCH_DESCRIPTION);
|
|
expect(SEARCH_DESCRIPTION).toContain("get_recent_salience");
|
|
});
|
|
});
|
|
|
|
describe('v0.29 — subagent allow-list', () => {
|
|
test('includes get_recent_salience and find_anomalies', () => {
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_salience')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('find_anomalies')).toBe(true);
|
|
});
|
|
|
|
test('excludes get_recent_transcripts (codex C3 — would be a remote=true footgun)', () => {
|
|
// The op throws permission_denied for remote=true callers, and all subagent
|
|
// calls run with remote=true. Including it in the allow-list would mean
|
|
// every subagent call to it returns an error — looks like a bug.
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_transcripts')).toBe(false);
|
|
});
|
|
|
|
test('all v0.29 ops in the allow-list resolve to a registered Operation', () => {
|
|
// brain-allowlist invariant: every name maps to an entry in operations.ts
|
|
// OPERATIONS array. This guard catches rename drift.
|
|
for (const name of ['get_recent_salience', 'find_anomalies']) {
|
|
expect(operationsByName[name]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('list_pages has new sort + updated_after params surfaced to MCP', () => {
|
|
const op = operationsByName['list_pages'];
|
|
expect(op.params.sort).toBeDefined();
|
|
expect(op.params.updated_after).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('v0.29 — operations array carries the three new ops', () => {
|
|
test('all three are registered (one allow-listed pair + one local-only)', () => {
|
|
const names = operations.map(o => o.name);
|
|
expect(names).toContain('get_recent_salience');
|
|
expect(names).toContain('find_anomalies');
|
|
expect(names).toContain('get_recent_transcripts');
|
|
});
|
|
});
|
|
|
|
describe('PR1 — skill catalog descriptions', () => {
|
|
test('list_skills / get_skill match the operation registration', () => {
|
|
expect(operationsByName['list_skills'].description).toBe(LIST_SKILLS_DESCRIPTION);
|
|
expect(operationsByName['get_skill'].description).toBe(GET_SKILL_DESCRIPTION);
|
|
});
|
|
|
|
test('descriptions teach "prose, not executable code" + the follow-the-prose protocol', () => {
|
|
expect(LIST_SKILLS_DESCRIPTION).toContain('NOT executable code');
|
|
expect(LIST_SKILLS_DESCRIPTION).toContain('get_skill');
|
|
expect(GET_SKILL_DESCRIPTION).toContain('nothing to');
|
|
expect(GET_SKILL_DESCRIPTION).toContain('same-named MCP tool');
|
|
});
|
|
|
|
test('descriptions surface usable/unavailable tool honesty', () => {
|
|
expect(LIST_SKILLS_DESCRIPTION).toContain('usable_tools');
|
|
expect(LIST_SKILLS_DESCRIPTION).toContain('unavailable_tools');
|
|
expect(GET_SKILL_DESCRIPTION).toContain('unavailable_tools');
|
|
});
|
|
|
|
test('both ops are read-scope, non-localOnly (thin clients reach them over HTTP)', () => {
|
|
for (const n of ['list_skills', 'get_skill']) {
|
|
expect(operationsByName[n].scope).toBe('read');
|
|
expect(operationsByName[n].localOnly).toBeFalsy();
|
|
expect(operationsByName[n].mutating).toBeFalsy();
|
|
}
|
|
});
|
|
|
|
test('instructions envelope is shaped + load-bearing', () => {
|
|
expect(SKILL_CATALOG_INSTRUCTIONS.summary).toContain('not executable tools');
|
|
expect(SKILL_CATALOG_INSTRUCTIONS.how_to_use.length).toBeGreaterThanOrEqual(3);
|
|
expect(SKILL_CATALOG_INSTRUCTIONS.how_to_use.join(' ')).toContain('get_skill');
|
|
expect(SKILL_CLIENT_GUIDANCE.nature).toContain('not code to execute');
|
|
expect(SKILL_CLIENT_GUIDANCE.protocol.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
test('operations array carries both new ops', () => {
|
|
const names = operations.map(o => o.name);
|
|
expect(names).toContain('list_skills');
|
|
expect(names).toContain('get_skill');
|
|
});
|
|
});
|