mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* fix(security): scope cross-source reads to the caller grant; close get_page exact-path leak One shared resolveRequestedScope() routes every source-scoped read op (query, code_callers/callees, search_by_image, code_blast/flow, get_page) through a single fail-closed trust+grant ladder: a remote caller's __all__ collapses to its granted sources (never the whole brain) and an explicit out-of-grant source_id is rejected. get_page's exact-match path now honors a federated grant via getPage(sourceIds[]) in both engines. Legacy bearer tokens carry their stored permissions.source_id grant (bounded, never widened). Also retries getConfig on transient connection loss. Closes #1924, #1371, #1393, #1336, #1603. * fix(ingest): non-string frontmatter no longer aborts lint/sync; embed/hook/catalog papercuts Parser coerces a non-string title to a string and falls back to inference for slug/type (never fabricating a "123" slug), with a lint NON_STRING_FIELD finding surfacing the malformed frontmatter; a defensive guard in content-sanity stops a non-string title from crashing the whole lint/sync run brain-wide. Plus: embed --catch-up no longer arms the overflowed 32-bit budget timer (and surfaces unembeddable chunks); the frontmatter pre-commit hook ships a correct .md/.mdx regex; and the skill catalog parses YAML block-scalar descriptions. Closes #1883, #1658, #1556, #1948, #1946, #1840, #1711. * v0.42.37.0 fix(security,ingest): source-isolation grant enforcement + non-string frontmatter guard + papercuts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add NON_STRING_FIELD frontmatter validation class to docs for v0.42.37.0 The v0.42.37.0 non-string-frontmatter fix added an eighth validation class (NON_STRING_FIELD / lint code frontmatter-non-string-field). Update the two current-state docs that enumerate the validation classes: - skills/frontmatter-guard/SKILL.md (seven->eight + table row) - docs/integrations/pre-commit.md (seven->eight + table row) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
576 lines
21 KiB
TypeScript
576 lines
21 KiB
TypeScript
/**
|
||
* skill-catalog.ts — host-repo skill catalog for the MCP `list_skills` /
|
||
* `get_skill` ops (PR1).
|
||
*
|
||
* WHAT THIS IS: the agent repo (OpenClaw/Hermes) ships fat-markdown "skills" —
|
||
* prose instruction sets, NOT executable code. This module reads them off the
|
||
* server host's filesystem and shapes them for a thin MCP client (Codex desktop,
|
||
* Claude Code, Perplexity) to discover and FOLLOW. "Using" a skill = fetching its
|
||
* prose and then calling the gbrain MCP tools the same server already exposes.
|
||
*
|
||
* TRUST-BOUNDARY MEMO (read before changing scope/localOnly on the ops):
|
||
* These ops read the server-host filesystem and return file CONTENTS over HTTP.
|
||
* gbrain treats `file_list`/`file_url` as `admin + localOnly` for exactly this
|
||
* reason. `read` + non-localOnly is defensible HERE, but ONLY because of the full
|
||
* mitigation stack — do not weaken one piece without re-reading the rest:
|
||
* 1. Explicit owner opt-in — the publish gate (`mcp.publish_skills`) defaults
|
||
* OFF at runtime; new installs turn it on at `gbrain init`, existing installs
|
||
* via a consenting migration. Unlike `file_list`, this is content the owner
|
||
* deliberately published.
|
||
* 2. Confinement — only the resolved skills dir is reachable. The client `name`
|
||
* is a manifest LOOKUP KEY, never a raw path segment; the manifest-derived
|
||
* path is realpath + relative-contained on every call (defeats symlink/`..`
|
||
* escape, including a poisoned manifest.json `path`); resolved path must be a
|
||
* regular file named `SKILL.md`.
|
||
* 3. Field allowlist — frontmatter is projected to a safe subset; `writes_to`
|
||
* (private brain taxonomy) and `sources` (absolute paths) are dropped.
|
||
* 4. Prose-only + size-capped — no source code (PR2 ships tarballs); `get_skill`
|
||
* caps the file size so a huge/binary file can't OOM the server.
|
||
* 5. No install_path serve for remote — a hosted gbrain with no agent repo
|
||
* returns `storage_error`, never gbrain's own bundled dev skills.
|
||
* 6. Bounded — the MCP rate limiter caps call rate; per-call memory is bounded
|
||
* by the size cap.
|
||
*
|
||
* Skills live on the HOST FILESYSTEM and are repo-global: `sourceScopeOpts(ctx)` /
|
||
* `ctx.sourceId` (in-DB tenancy) and `ctx.brainId` (mounts) deliberately do NOT
|
||
* apply. One server host = one skills dir = one catalog for all callers.
|
||
*/
|
||
|
||
import { existsSync, readFileSync, realpathSync, statSync } from 'fs';
|
||
import { basename, join, relative, resolve } from 'path';
|
||
import {
|
||
autoDetectSkillsDir,
|
||
autoDetectSkillsDirReadOnly,
|
||
AUTO_DETECT_HINT,
|
||
AUTO_DETECT_HINT_READ_ONLY,
|
||
type SkillsDirSource,
|
||
} from './repo-root.ts';
|
||
import { loadOrDeriveManifest, type ManifestEntry } from './skill-manifest.ts';
|
||
import { loadSkillTriggerIndex, FRONTMATTER_SECTION } from './skill-trigger-index.ts';
|
||
import { parseSkillFrontmatter } from './skill-frontmatter.ts';
|
||
import { hasScope } from './scope.ts';
|
||
import { operations, OperationError, type Operation, type OperationContext } from './operations.ts';
|
||
import {
|
||
SKILL_CATALOG_INSTRUCTIONS,
|
||
SKILL_CLIENT_GUIDANCE,
|
||
} from './operations-descriptions.ts';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Max SKILL.md size `get_skill` will read/return. Env-overridable. Default
|
||
* 256 KB — generous for prose, small enough that a stray binary/generated file
|
||
* can't balloon the server's RSS (codex P1-c). */
|
||
export const MAX_SKILL_MD_BYTES = (() => {
|
||
const raw = process.env.GBRAIN_MAX_SKILL_MD_BYTES;
|
||
if (raw) {
|
||
const n = parseInt(raw, 10);
|
||
if (Number.isFinite(n) && n > 0) return n;
|
||
}
|
||
return 256 * 1024;
|
||
})();
|
||
|
||
/** Max client-supplied skill name length before we even hit the manifest. */
|
||
const MAX_SKILL_NAME_LEN = 128;
|
||
|
||
/** Where auto-detect found the dir, plus the explicit-config variant. */
|
||
export type ResolvedSkillsDirSource = SkillsDirSource | 'config';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface SkillCatalogEntry {
|
||
name: string;
|
||
description: string;
|
||
section: string;
|
||
triggers: string[];
|
||
tools: string[];
|
||
/** declared tools ∩ (exposed by this server AND permitted to this caller). */
|
||
usable_tools: string[];
|
||
/** declared tools − usable (not on this server, or beyond caller scope). */
|
||
unavailable_tools: string[];
|
||
writes_pages: boolean;
|
||
mutating: boolean;
|
||
}
|
||
|
||
export interface ListSkillsResult {
|
||
schema_version: 1;
|
||
skills_dir_source: ResolvedSkillsDirSource;
|
||
count: number;
|
||
skills: SkillCatalogEntry[];
|
||
instructions: {
|
||
summary: string;
|
||
how_to_use: string[];
|
||
available_brain_tools: string[];
|
||
fetch_op: 'get_skill';
|
||
};
|
||
}
|
||
|
||
export interface GetSkillResult {
|
||
schema_version: 1;
|
||
name: string;
|
||
/** Allowlisted projection — never the raw frontmatter object. */
|
||
frontmatter: {
|
||
name?: string;
|
||
description?: string;
|
||
triggers?: string[];
|
||
tools?: string[];
|
||
writes_pages?: boolean;
|
||
mutating?: boolean;
|
||
};
|
||
body: string;
|
||
usable_tools: string[];
|
||
unavailable_tools: string[];
|
||
client_guidance: {
|
||
nature: string;
|
||
protocol: string[];
|
||
available_brain_tools: string[];
|
||
mutating: boolean;
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Skills-dir resolution
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Read `mcp.publish_skills` honoring BOTH config planes. `gbrain config set`
|
||
* writes the DB plane (engine.setConfig) — so the DB value wins; `gbrain init`
|
||
* also writes the DB plane. The file plane (`config.json` → `ctx.config.mcp`) is
|
||
* a fallback for hand-edited configs. Absent in both → false (fail-safe).
|
||
*/
|
||
export async function readMcpPublishSkills(ctx: OperationContext): Promise<boolean> {
|
||
let dbVal: string | null = null;
|
||
try {
|
||
dbVal = await ctx.engine.getConfig('mcp.publish_skills');
|
||
} catch {
|
||
// Engine without a config table / transient error → fall back to file plane.
|
||
}
|
||
if (dbVal != null) return dbVal === 'true';
|
||
return ctx.config?.mcp?.publish_skills === true;
|
||
}
|
||
|
||
/**
|
||
* Read `mcp.skills_dir` from the DB plane (what `gbrain config set` writes),
|
||
* falling back to the file plane. Returns undefined when unset on both.
|
||
*/
|
||
export async function readMcpSkillsDir(ctx: OperationContext): Promise<string | undefined> {
|
||
let dbVal: string | null = null;
|
||
try {
|
||
dbVal = await ctx.engine.getConfig('mcp.skills_dir');
|
||
} catch {
|
||
// ignore — fall through to file plane
|
||
}
|
||
if (dbVal && dbVal.trim().length > 0) return dbVal;
|
||
const fileVal = ctx.config?.mcp?.skills_dir;
|
||
return fileVal && fileVal.trim().length > 0 ? fileVal : undefined;
|
||
}
|
||
|
||
/**
|
||
* Resolve the skills dir for this call. An explicit `mcp.skills_dir` override
|
||
* (resolved from either config plane by the caller) wins; otherwise autodetect.
|
||
* REMOTE callers use `autoDetectSkillsDir` (no install_path tier — a hosted
|
||
* gbrain must never serve its own bundled dev skills); LOCAL callers
|
||
* (`ctx.remote === false`) use the read-only variant with the install_path
|
||
* fallback. Throws `storage_error` (with the search-path hint) when nothing
|
||
* resolves. Pure modulo filesystem state — `skillsDirOverride` keeps it testable
|
||
* without an engine.
|
||
*/
|
||
export function resolveSkillsDir(
|
||
ctx: OperationContext,
|
||
skillsDirOverride?: string,
|
||
): {
|
||
dir: string;
|
||
source: ResolvedSkillsDirSource;
|
||
} {
|
||
if (skillsDirOverride && skillsDirOverride.trim().length > 0) {
|
||
if (!existsSync(skillsDirOverride)) {
|
||
throw new OperationError(
|
||
'storage_error',
|
||
`Configured mcp.skills_dir does not exist: ${skillsDirOverride}`,
|
||
'Fix it with `gbrain config set mcp.skills_dir <path>` or unset it to autodetect.',
|
||
);
|
||
}
|
||
return { dir: skillsDirOverride, source: 'config' };
|
||
}
|
||
|
||
const det =
|
||
ctx.remote === false ? autoDetectSkillsDirReadOnly() : autoDetectSkillsDir();
|
||
if (!det.dir || !det.source) {
|
||
const hint = ctx.remote === false ? AUTO_DETECT_HINT_READ_ONLY : AUTO_DETECT_HINT;
|
||
throw new OperationError(
|
||
'storage_error',
|
||
'No skills directory found on the server host.',
|
||
`Set it explicitly: \`gbrain config set mcp.skills_dir <path>\` (or $GBRAIN_SKILLS_DIR). Search order:\n${hint}`,
|
||
);
|
||
}
|
||
return { dir: det.dir, source: det.source };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Path confinement (the security boundary)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Validate the client-supplied skill name BEFORE any filesystem access. */
|
||
function assertSkillNameShape(name: unknown): asserts name is string {
|
||
if (typeof name !== 'string' || name.length === 0) {
|
||
throw new OperationError('invalid_params', 'skill name must be a non-empty string');
|
||
}
|
||
if (name.length > MAX_SKILL_NAME_LEN) {
|
||
throw new OperationError('invalid_params', `skill name exceeds ${MAX_SKILL_NAME_LEN} characters`);
|
||
}
|
||
// No path separators, no traversal, no null byte. The name is a manifest
|
||
// LOOKUP KEY — it must never look like a path component.
|
||
if (/[/\\]|\.\.| |