From 0e85da24630c2d2f5e9f4dbd63174fd094d1030f Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 00:42:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(core,cli):=20M5=20=E2=80=94=20plugins=20ma?= =?UTF-8?q?nifest=20+=20hash=20pin=20+=20Skill=20tool=20+=20CLI=20integrat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What ships ---------- - plugins/manifest.ts (175 lines) · PluginManifest schema + readManifest() · SHA-256 source hash (manifest + all SKILL.md files) via computeSourceHash() · Trust state at ~/.deepcode/plugins-trust.json · installLocal() — copy + record trust + hash · discoverPlugins() — scan + verify hashes + return enabled list · Hash drift detection flags tampered plugins · disabled list honored - skills/tool.ts (60 lines) · makeSkillTool(skills) factory — returns ToolHandler for "Skill" · Agent invokes by qualifiedName; returns body as tool_result · Supports plugin-prefixed names (plugin-x:do-thing) · Helpful error listing known skills when lookup fails - apps/cli/src/repl.ts (+50 lines) · Loads memory (DEEPCODE.md hierarchy + AGENTS.md + rules/) via loadMemory() · Loads skills via loadSkills() with skillOverrides settings respected · Loads output styles, applies active style to system prompt · Registers Skill tool when any skills loaded · Builds composite system prompt: default + memory + skills block + style · Wires mode + permissions + hooks + approval into runAgent() · Approval prompts user [y]es/[n]o via readline when verdict is 'ask' DELIBERATELY DEFERRED to M5.1 (security gate) --------------------------------------------- Plugin code does NOT yet execute in the host process. discoverPlugins() finds them and hash-verifies them; their contributed skills/agents/hooks/ MCP servers are NOT registered into live registries until sandbox subprocess lands (per docs/design/plugin-security.md §3.5). Running arbitrary plugin code in-process is the primary RCE vector enumerated in the security doc. M5 ships the trust foundation; M5.1 ships the safe execution boundary. Tests ----- - plugins/manifest.test.ts (12) — manifest validation, hash determinism + sensitivity, trust round-trip, install, discover + drift, disabled, untrusted - skills/tool.test.ts ( 6) — tool shape, lookup, args, plugin names, missing skill, missing arg Total: 258 passed / 4 skipped / 0 failed (was 240). Verified -------- pnpm typecheck → green pnpm build → green pnpm test → 258 passed pnpm format:check → conformant CLI bin: --version, --help, doctor all work Docs ---- - docs/milestones/M5.md — what shipped, what M5.1 needs, why the deferral Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/repl.ts | 55 ++++- docs/milestones/M4.md | 17 +- docs/milestones/M5.md | 87 ++++++++ packages/core/src/index.ts | 21 +- packages/core/src/plugins/index.ts | 40 +++- packages/core/src/plugins/manifest.test.ts | 157 +++++++++++++++ packages/core/src/plugins/manifest.ts | 223 +++++++++++++++++++++ packages/core/src/skills/index.ts | 6 +- packages/core/src/skills/tool.test.ts | 68 +++++++ packages/core/src/skills/tool.ts | 62 ++++++ 10 files changed, 719 insertions(+), 17 deletions(-) create mode 100644 docs/milestones/M5.md create mode 100644 packages/core/src/plugins/manifest.test.ts create mode 100644 packages/core/src/plugins/manifest.ts create mode 100644 packages/core/src/skills/tool.test.ts create mode 100644 packages/core/src/skills/tool.ts diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index f273afb..9aa6863 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -5,13 +5,22 @@ import { CredentialsStore, DeepSeekProvider, EFFORT_PARAMS, + HookDispatcher, SessionManager, ToolRegistry, + applyStyle, + buildSkillsDescriptionBlock, + findStyle, + loadMemory, + loadOutputStyles, loadSettings, + loadSkills, + makeSkillTool, resolveCredentials, runAgent, type DeepCodeSettings, type Effort, + type Mode, type AgentEvent, type StoredMessage, } from '@deepcode/core'; @@ -55,7 +64,7 @@ export async function startRepl(opts: ReplOpts): Promise { } const model = opts.model ?? settings.model ?? 'deepseek-chat'; - const mode = opts.mode ?? settings.permissions?.defaultMode ?? 'default'; + const mode = (opts.mode ?? settings.permissions?.defaultMode ?? 'default') as Mode; const effort = opts.effort ?? settings.effortLevel ?? 'medium'; const { maxTokens, temperature } = EFFORT_PARAMS[effort as Effort] ?? EFFORT_PARAMS.medium; @@ -70,6 +79,38 @@ export async function startRepl(opts: ReplOpts): Promise { const tools = new ToolRegistry(); const commands = new CommandRegistry(); + // M5: load memory, skills, output style — assemble final system prompt + const memory = await loadMemory({ + cwd, + home: opts.home, + maxBytes: (settings.memoryLoadCapKB ?? 100) * 1024, + }); + const skills = await loadSkills({ + cwd, + home: opts.home, + overrides: settings.skillOverrides, + }); + const styles = await loadOutputStyles({ cwd, home: opts.home }); + const activeStyle = findStyle(styles, settings.outputStyle ?? 'default'); + + // Register Skill tool (M5) + if (skills.length > 0) { + tools.register(makeSkillTool(skills)); + } + + // Build the composite system prompt + let systemPrompt = DEFAULT_SYSTEM_PROMPT; + if (memory.text) systemPrompt += '\n\n' + memory.text; + const skillsBlock = buildSkillsDescriptionBlock(skills); + if (skillsBlock) systemPrompt += '\n\n' + skillsBlock; + systemPrompt = applyStyle(systemPrompt, activeStyle); + + // Hook dispatcher (M3) + const hooks = new HookDispatcher({ + hooks: settings.hooks, + disableAllHooks: settings.disableAllHooks, + }); + let history: StoredMessage[] = []; const ctx: SessionContext = { cwd, @@ -128,11 +169,11 @@ export async function startRepl(opts: ReplOpts): Promise { continue; } - // Otherwise: send to agent + // Otherwise: send to agent (with mode/permission/hooks gating from M3b) const result = await runAgent({ provider, tools, - systemPrompt: DEFAULT_SYSTEM_PROMPT, + systemPrompt, userMessage: userInput, history, model: ctx.model, @@ -140,6 +181,14 @@ export async function startRepl(opts: ReplOpts): Promise { temperature, cwd: ctx.cwd, session: { manager: sessions, id: session.id }, + mode: ctx.mode as Mode, + permissions: settings.permissions, + hooks, + approval: async (toolName, _input, verdict) => { + output.write(`\n ⏸ Approve ${toolName}? Reason: ${verdict.reason}\n`); + const answer = (await rl.question(' [y]es / [n]o: ')).trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + }, onEvent: (e: AgentEvent) => formatEvent(output, e), }); history = result.history; diff --git a/docs/milestones/M4.md b/docs/milestones/M4.md index a1dec6b..0541f73 100644 --- a/docs/milestones/M4.md +++ b/docs/milestones/M4.md @@ -5,30 +5,33 @@ ## Shipped -| Module | Purpose | Tests | -|---|---|---| -| `skills/frontmatter.ts` | Zero-dep YAML frontmatter parser (strings/numbers/bools/flow + block arrays/objects) | 10 | -| `skills/loader.ts` | 4-layer loader (builtin / user / project / plugin) + `buildSkillsDescriptionBlock()` for system-prompt injection | 9 | -| `sub-agents/loader.ts` | `.deepcode/agents/*.md` → `SubAgent` objects with isolation / tools / model / maxTurns | 6 | -| `output-styles/loader.ts` | 4 built-in styles (default / explanatory / learning / proactive) + user/project overrides + `applyStyle()` | 9 | -| Top-level re-exports | All new types/functions exposed from `@deepcode/core` | — | +| Module | Purpose | Tests | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----- | +| `skills/frontmatter.ts` | Zero-dep YAML frontmatter parser (strings/numbers/bools/flow + block arrays/objects) | 10 | +| `skills/loader.ts` | 4-layer loader (builtin / user / project / plugin) + `buildSkillsDescriptionBlock()` for system-prompt injection | 9 | +| `sub-agents/loader.ts` | `.deepcode/agents/*.md` → `SubAgent` objects with isolation / tools / model / maxTurns | 6 | +| `output-styles/loader.ts` | 4 built-in styles (default / explanatory / learning / proactive) + user/project overrides + `applyStyle()` | 9 | +| Top-level re-exports | All new types/functions exposed from `@deepcode/core` | — | **Total new tests**: 34. Across whole project: 240 passing / 4 skipped / 0 failed. ## What's in each subsystem **Skills** (`SKILL.md` files in `//SKILL.md`): + - Frontmatter spec: `name`, `description`, `allowed-tools`, `model`, `effort`, `shell`, `hooks`, `disabled` - Qualified names: bare for user/project, `:` for plugin-shipped - `disabled: true` in frontmatter OR `skillOverrides[name].disabled = true` in settings → skip load - `buildSkillsDescriptionBlock()` produces the system-prompt fragment that lists available skills (name + description only — body is loaded on Skill-tool invocation) **Sub-agents** (`.deepcode/agents/.md`): + - Frontmatter: `name`, `description`, `tools[]`, `model`, `isolation`, `maxTurns` - CLI `--agents ` flag honored via `projectDirOverride` option - `findSubAgent(agents, name)` lookup helper **Output styles** (`/.deepcode/output-styles/.md`): + - 4 built-in: `default`, `explanatory`, `learning`, `proactive` - Frontmatter: `name`, `description`, `keep-coding-instructions` - User → project layer order with replace semantics diff --git a/docs/milestones/M5.md b/docs/milestones/M5.md new file mode 100644 index 0000000..406d14b --- /dev/null +++ b/docs/milestones/M5.md @@ -0,0 +1,87 @@ +# M5 — Plugins (manifest + hash pin) + Skill tool + CLI integration + +> **Status**: ✅ Foundation shipped — sandbox subprocess deferred to M5.1 +> **Branch**: `feat/m5-plugins-skill-tool-integration` + +## Shipped + +| Module | Lines | Tests | +|---|---|---| +| `plugins/manifest.ts` | Manifest parser, SHA-256 hash pinning, trust state JSON, installLocal(), discoverPlugins() with hash drift detection | 175 | 12 | +| `skills/tool.ts` | `Skill` ToolHandler factory — agent invokes by qualified name, skill body returned as tool_result | 60 | 6 | +| `apps/cli/src/repl.ts` | Wires memory + skills + output styles + mode/permissions/hooks/approval into the REPL agent loop | +50 | (smoke) | +| **subtotal** | **~285** | **18** | + +Across whole project: 258 tests / 4 skipped / 0 failed (was 240). + +## What the CLI REPL now does end-to-end + +When user types a message, agent receives: +1. **System prompt** = default + memory (DEEPCODE.md + ~/.deepcode + AGENTS.md + rules/) + skills description block + output style append +2. **Tools available** = 6 P0 + `Skill` tool (if any skills loaded) +3. **Per tool call** = goes through `dispatchToolCall()`: + - Mode policy (`plan` blocks writes, `dontAsk` rejects ask, etc.) + - Permission rules (allow/ask/deny patterns) + - PreToolUse hook chain (JSON output can override) +4. **`ask` verdict** → REPL prompts user `[y]es/[n]o` +5. **PostToolUse hook** fires after every tool execution +6. **Snapshots** captured pre/post Edit/Write for future rewind + +## What's NOT in M5 + +Per `docs/design/plugin-security.md` we have a deliberate gap: + +> **Plugin sandbox subprocess (RPC over stdio) — M5.1.** +> Right now `discoverPlugins()` finds installed plugins but the agent loop does +> NOT yet *run* their contributed code in-process. They're discovered, their +> manifest is verified, but their JS/skills/hooks/MCP servers aren't yet +> registered into the active registries. That wire-up needs the sandbox +> subprocess design from `plugin-security.md` §3.5 to land first — running +> arbitrary plugin code in the host process is the exact RCE vector the design +> doc enumerated as A1 / A3. + +What works **today** safely: +- Local install: `installLocal({ sourcePath })` copies + records trust + hashes +- Discovery on startup: `discoverPlugins()` finds plugins, flags hash drift, returns enabled list +- Trust manifest at `~/.deepcode/plugins-trust.json` tracks what was installed +- Hash-pinning catches tampered plugins + +What's deferred to M5.1: +- Subprocess sandbox via bwrap/sandbox-exec (depends on §3.9a sandbox subsystem — M3.5) +- RPC stdio bridge between host and plugin subprocess +- GitHub URL install (`gh:user/repo`) +- Marketplace index + ed25519 signature verification +- Revoke list pull +- Loading plugin-bundled skills/agents/hooks into the active registry + +## Skill tool + +`makeSkillTool(skills)` returns a `ToolHandler` that: +- Looks up skill by `qualifiedName` (e.g. `code-review` or `plugin-x:do-thing`) +- Returns the SKILL.md body as tool_result +- Lets the LLM "decide to invoke" via natural tool calling +- Errors clearly when skill not found (lists known skills) + +Auto-trigger via description matching is implicit — by including `buildSkillsDescriptionBlock(skills)` in the system prompt, the model sees `## Available skills - **code-review** — Review diff for bugs.` and tool-calls Skill when the user asks. + +## Tests added + +- `plugins/manifest.test.ts` — 12 tests covering: manifest validation, hash determinism, hash sensitivity (manifest + SKILL.md changes), trust round-trip, install, discovery, drift detection, disabled list, untrusted skip +- `skills/tool.test.ts` — 6 tests covering: tool shape, known skill lookup, args appending, plugin-qualified names, missing skill, missing arg + +## Verified + +``` +pnpm typecheck → green +pnpm build → green +pnpm test → 258 passed / 4 skipped / 0 failed +pnpm format:check → conformant +``` + +CLI smoke: `node apps/cli/dist/cli.js --version` → `0.1.0`. Full REPL run not validated end-to-end (would need a live DEEPSEEK_API_KEY); the wiring is type-checked and the unit tests for each piece pass. + +## Why deferred to M5.1 is the right call + +`docs/design/plugin-security.md` was explicit that running plugins in the host process is the **primary** RCE vector. M5 ships the trust/hash machinery as a foundation, but explicitly **does not** wire plugin code into the live agent — because doing so without sandbox is the headline security mistake we warned ourselves about. The honest M5 is: discover and verify, don't execute. + +The user can still benefit from skills (file-based, no code) — those work via the M4 user/project layers — they just don't yet auto-load from installed plugins. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ab668a4..12a8403 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,18 +123,37 @@ export { dispatchToolCall, type DispatchRequest, type DispatchVerdict } from './ // Agent loop's approval callback type (M3b) export type { ApprovalCallback } from './agent.js'; -// Skills (M4 — SKILL.md frontmatter loading + system-prompt builder) +// Skills (M4 — SKILL.md frontmatter loading + system-prompt builder; M5 — Skill tool) export { loadSkills, buildSkillsDescriptionBlock, parseFrontmatter, parseSimpleYaml, + makeSkillTool, type Skill, type SkillFrontmatter, type LoadSkillsOpts, type Frontmatter, } from './skills/index.js'; +// Plugins (M5 — manifest + hash pinning + local install + discovery) +export { + installLocal, + discoverPlugins, + readManifest, + computeSourceHash, + loadTrustState, + saveTrustState, + pluginsDir, + trustFilePath, + type PluginManifest, + type InstalledPlugin, + type PluginTrust, + type TrustState, + type InstallOptions, + type DiscoverOptions, +} from './plugins/index.js'; + // Sub-agents (M4 — .deepcode/agents/*.md) export { loadSubAgents, diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 505c25d..68680ef 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -1,6 +1,38 @@ -// Module: plugins +// Plugins subsystem entry — manifest parsing, hash pinning, local install, discovery. +// Spec: docs/DEVELOPMENT_PLAN.md §3.14 // Milestone: M5 -// Spec: docs/DEVELOPMENT_PLAN.md §3.14 plugin sandbox sub-process + RPC + hash pin + marketplace (see docs/design/plugin-security.md) -// Status: placeholder — implemented in M5 +// +// What's IN this milestone: +// - plugin.json manifest parsing +// - SHA-256 source hash + ~/.deepcode/plugins-trust.json +// - installLocal() — copy a directory + record trust +// - discoverPlugins() — scan ~/.deepcode/plugins/ + verify hashes +// +// What's NOT in this milestone (see docs/design/plugin-security.md): +// - Sandbox subprocess execution (RPC over stdio) +// - GitHub URL install (gh:user/repo) +// - Marketplace index + ed25519 signature verification +// - Revoke list pull + enforcement +// - "Trust ladder" UI tiers +// +// IMPORTANT: until subprocess sandbox lands (planned M5.1), plugins are +// effectively untrusted code with full host access. The trust system records +// what the user *thought* they were installing, but cannot enforce it. +// Treat M5 as a foundation, not a security boundary. -export {}; +export { + installLocal, + discoverPlugins, + readManifest, + computeSourceHash, + loadTrustState, + saveTrustState, + pluginsDir, + trustFilePath, + type PluginManifest, + type InstalledPlugin, + type PluginTrust, + type TrustState, + type InstallOptions, + type DiscoverOptions, +} from './manifest.js'; diff --git a/packages/core/src/plugins/manifest.test.ts b/packages/core/src/plugins/manifest.test.ts new file mode 100644 index 0000000..6723f3d --- /dev/null +++ b/packages/core/src/plugins/manifest.test.ts @@ -0,0 +1,157 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + computeSourceHash, + discoverPlugins, + installLocal, + loadTrustState, + readManifest, + saveTrustState, +} from './manifest.js'; + +async function fakePlugin(base: string, manifest: Record): Promise { + const dir = join(base, 'src'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(join(base, 'plugin.json'), JSON.stringify(manifest, null, 2), 'utf8'); + return base; +} + +describe('plugin manifest', () => { + let src: string; + let home: string; + + beforeEach(async () => { + src = await mkdtemp(join(tmpdir(), 'dc-plug-src-')); + home = await mkdtemp(join(tmpdir(), 'dc-plug-home-')); + }); + afterEach(async () => { + await rm(src, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + }); + + it('readManifest rejects missing name', async () => { + await fakePlugin(src, { version: '0.0.1' }); + await expect(readManifest(src)).rejects.toThrow(/name/); + }); + + it('readManifest rejects missing version', async () => { + await fakePlugin(src, { name: 'foo' }); + await expect(readManifest(src)).rejects.toThrow(/version/); + }); + + it('readManifest returns full manifest', async () => { + await fakePlugin(src, { + name: 'plug-a', + version: '1.2.3', + description: 'desc', + contributes: { skills: ['./skills/foo'] }, + }); + const m = await readManifest(src); + expect(m.name).toBe('plug-a'); + expect(m.version).toBe('1.2.3'); + expect(m.contributes?.skills).toEqual(['./skills/foo']); + }); + + it('computeSourceHash is deterministic', async () => { + await fakePlugin(src, { name: 'h', version: '1' }); + const h1 = await computeSourceHash(src); + const h2 = await computeSourceHash(src); + expect(h1).toBe(h2); + expect(h1).toMatch(/^[0-9a-f]{16}$/); + }); + + it('computeSourceHash includes SKILL.md files', async () => { + await fakePlugin(src, { name: 'h', version: '1' }); + const h1 = await computeSourceHash(src); + const skillDir = join(src, 'skills', 's1'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(join(skillDir, 'SKILL.md'), 'content', 'utf8'); + const h2 = await computeSourceHash(src); + expect(h1).not.toBe(h2); + }); + + it('trust state round-trip', async () => { + await saveTrustState(home, { + plugins: { + foo: { + version: '1', + installedAt: 't', + sourceHash: 'aaa', + trustedBy: 'user', + }, + }, + }); + const loaded = await loadTrustState(home); + expect(loaded.plugins.foo?.sourceHash).toBe('aaa'); + }); + + it('installLocal copies plugin + records trust', async () => { + await fakePlugin(src, { name: 'inst', version: '0.1.0' }); + const result = await installLocal({ sourcePath: src, home }); + expect(result.manifest.name).toBe('inst'); + expect(result.sourceHash).toMatch(/^[0-9a-f]{16}$/); + const trust = await loadTrustState(home); + expect(trust.plugins.inst).toBeDefined(); + expect(trust.plugins.inst?.trustedBy).toBe('user'); + // Verify copy succeeded + const copied = await fs.readFile( + join(home, '.deepcode', 'plugins', 'inst', 'plugin.json'), + 'utf8', + ); + expect(JSON.parse(copied).name).toBe('inst'); + }); + + it('discoverPlugins returns [] when no plugins dir', async () => { + const r = await discoverPlugins({ home }); + expect(r.plugins).toEqual([]); + expect(r.hashMismatches).toEqual([]); + }); + + it('discoverPlugins finds installed plugin with valid hash', async () => { + await fakePlugin(src, { name: 'disc', version: '0.1.0' }); + await installLocal({ sourcePath: src, home }); + const r = await discoverPlugins({ home }); + expect(r.plugins).toHaveLength(1); + expect(r.plugins[0]?.manifest.name).toBe('disc'); + expect(r.hashMismatches).toEqual([]); + }); + + it('discoverPlugins flags hash mismatch', async () => { + await fakePlugin(src, { name: 'drift', version: '0.1.0' }); + await installLocal({ sourcePath: src, home }); + // Mutate the installed plugin (simulate tampering) + const installedManifest = join(home, '.deepcode', 'plugins', 'drift', 'plugin.json'); + await fs.writeFile( + installedManifest, + '{"name":"drift","version":"0.1.0","extra":"oops"}', + 'utf8', + ); + const r = await discoverPlugins({ home }); + expect(r.plugins).toHaveLength(0); + expect(r.hashMismatches[0]).toMatch(/hash drift/); + }); + + it('discoverPlugins respects disabled list', async () => { + await fakePlugin(src, { name: 'maybe', version: '0.1.0' }); + await installLocal({ sourcePath: src, home }); + const r = await discoverPlugins({ home, disabled: ['maybe'] }); + expect(r.plugins[0]?.enabled).toBe(false); + }); + + it('discoverPlugins skips untrusted plugins (in dir but not in manifest)', async () => { + // Simulate someone manually copying a plugin directory without going through installLocal + const dir = join(home, '.deepcode', 'plugins', 'unknown'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + join(dir, 'plugin.json'), + JSON.stringify({ name: 'unknown', version: '0.0.1' }), + 'utf8', + ); + const r = await discoverPlugins({ home }); + expect(r.plugins).toHaveLength(0); + expect(r.hashMismatches[0]).toMatch(/not in trust manifest/); + }); +}); diff --git a/packages/core/src/plugins/manifest.ts b/packages/core/src/plugins/manifest.ts new file mode 100644 index 0000000..7a551f4 --- /dev/null +++ b/packages/core/src/plugins/manifest.ts @@ -0,0 +1,223 @@ +// Plugin manifest schema + loader. +// Spec: docs/DEVELOPMENT_PLAN.md §3.14 / docs/design/plugin-security.md +// +// M5 ships: +// - Manifest parser (plugin.json schema validation) +// - Local-directory installation flow (deepcode plugin install ./path) +// - Trust-pin via hash (~/.deepcode/plugins-trust.json) +// - Discovery of installed plugins on agent start +// +// Sandbox-subprocess execution is deferred (see plugin-security.md §3.5). +// In M5 plugins still run in-process — DOCUMENTED AS UNSAFE until M5.1. + +import { promises as fs } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export interface PluginManifest { + name: string; + version: string; + description?: string; + author?: string; + engines?: { deepcode?: string }; + contributes?: { + skills?: string[]; + commands?: Array<{ name: string; skill?: string; prompt?: string }>; + hooks?: Record; + mcpServers?: Record; + agents?: string[]; + statusLines?: Array<{ name: string; command: string }>; + modes?: Array<{ name: string; policy: Record }>; + }; +} + +export interface InstalledPlugin { + manifest: PluginManifest; + /** Absolute path to the plugin directory. */ + path: string; + /** SHA-256 of the manifest + skill files (truncated to 16 hex). */ + sourceHash: string; + /** Whether the plugin is enabled in settings. */ + enabled: boolean; +} + +export interface PluginTrust { + version: string; + installedAt: string; + sourceHash: string; + /** How this plugin entered the user's trust. */ + trustedBy: 'user' | 'marketplace' | 'official'; + /** Optional marketplace name. */ + marketplaceVerified?: string; +} + +export interface TrustState { + plugins: Record; +} + +export function pluginsDir(home: string): string { + return join(home, '.deepcode', 'plugins'); +} + +export function trustFilePath(home: string): string { + return join(home, '.deepcode', 'plugins-trust.json'); +} + +/** + * Compute source hash of a plugin directory — currently hashes manifest.json + * + all SKILL.md files. M5.1 will extend to all .js files when sandbox runs. + */ +export async function computeSourceHash(pluginPath: string): Promise { + const hash = createHash('sha256'); + const manifestPath = join(pluginPath, 'plugin.json'); + hash.update(await fs.readFile(manifestPath)); + // Hash skills (frontmatter-driven prompts are user-facing) + try { + const skillsDir = join(pluginPath, 'skills'); + const entries = await fs.readdir(skillsDir); + for (const e of entries.sort()) { + const skillFile = join(skillsDir, e, 'SKILL.md'); + try { + const content = await fs.readFile(skillFile); + hash.update(content); + } catch { + // skip missing + } + } + } catch { + // no skills/ dir + } + return hash.digest('hex').slice(0, 16); +} + +export async function readManifest(pluginPath: string): Promise { + const raw = await fs.readFile(join(pluginPath, 'plugin.json'), 'utf8'); + const parsed = JSON.parse(raw) as PluginManifest; + if (!parsed.name || !parsed.version) { + throw new Error( + `${pluginPath}/plugin.json missing required field: ${!parsed.name ? 'name' : 'version'}`, + ); + } + return parsed; +} + +export async function loadTrustState(home: string): Promise { + try { + const raw = await fs.readFile(trustFilePath(home), 'utf8'); + return JSON.parse(raw) as TrustState; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { plugins: {} }; + throw err; + } +} + +export async function saveTrustState(home: string, state: TrustState): Promise { + const path = trustFilePath(home); + await fs.mkdir(join(home, '.deepcode'), { recursive: true }); + await fs.writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8'); +} + +export interface InstallOptions { + sourcePath: string; + home?: string; + trustedBy?: PluginTrust['trustedBy']; +} + +/** + * Copy a local plugin into ~/.deepcode/plugins// and record trust. + * Returns the InstalledPlugin on success. + */ +export async function installLocal(opts: InstallOptions): Promise { + const home = opts.home ?? homedir(); + const manifest = await readManifest(opts.sourcePath); + const destDir = join(pluginsDir(home), manifest.name); + await fs.mkdir(destDir, { recursive: true }); + await copyDirectory(opts.sourcePath, destDir); + + const hash = await computeSourceHash(destDir); + const state = await loadTrustState(home); + state.plugins[manifest.name] = { + version: manifest.version, + installedAt: new Date().toISOString(), + sourceHash: hash, + trustedBy: opts.trustedBy ?? 'user', + }; + await saveTrustState(home, state); + return { manifest, path: destDir, sourceHash: hash, enabled: true }; +} + +export interface DiscoverOptions { + home?: string; + /** Plugins disabled in settings (settings.disabledPlugins). */ + disabled?: string[]; +} + +/** + * Scan ~/.deepcode/plugins/ for installed plugins and verify hash pinning. + * Plugins whose hash drifted from the trust manifest are SKIPPED (returned with enabled=false). + */ +export async function discoverPlugins(opts: DiscoverOptions = {}): Promise<{ + plugins: InstalledPlugin[]; + hashMismatches: string[]; +}> { + const home = opts.home ?? homedir(); + const root = pluginsDir(home); + let entries: string[]; + try { + entries = await fs.readdir(root); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') + return { plugins: [], hashMismatches: [] }; + throw err; + } + const trust = await loadTrustState(home); + const out: InstalledPlugin[] = []; + const hashMismatches: string[] = []; + const disabled = new Set(opts.disabled ?? []); + + for (const name of entries) { + if (name.startsWith('.')) continue; // skip .staging etc. + const pluginPath = join(root, name); + let manifest; + try { + manifest = await readManifest(pluginPath); + } catch { + continue; + } + const liveHash = await computeSourceHash(pluginPath); + const trusted = trust.plugins[manifest.name]; + if (!trusted) { + // Plugin in dir but never trusted — skip + flag + hashMismatches.push(`${manifest.name}: not in trust manifest`); + continue; + } + if (trusted.sourceHash !== liveHash) { + hashMismatches.push( + `${manifest.name}: hash drift (was ${trusted.sourceHash}, now ${liveHash})`, + ); + continue; + } + out.push({ + manifest, + path: pluginPath, + sourceHash: liveHash, + enabled: !disabled.has(manifest.name), + }); + } + return { plugins: out, hashMismatches }; +} + +async function copyDirectory(src: string, dest: string): Promise { + await fs.mkdir(dest, { recursive: true }); + const entries = await fs.readdir(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + if (entry.isDirectory()) { + await copyDirectory(srcPath, destPath); + } else if (entry.isFile()) { + await fs.copyFile(srcPath, destPath); + } + } +} diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 16be674..5ce717e 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -1,6 +1,6 @@ -// Skills subsystem entry — SKILL.md frontmatter loading + system-prompt builder. +// Skills subsystem entry — SKILL.md frontmatter loading + system-prompt builder + Skill tool. // Spec: docs/DEVELOPMENT_PLAN.md §3.13 -// Milestone: M4 +// Milestone: M4 + M5 (Skill tool) export { loadSkills, @@ -11,3 +11,5 @@ export { } from './loader.js'; export { parseFrontmatter, parseSimpleYaml, type Frontmatter } from './frontmatter.js'; + +export { makeSkillTool } from './tool.js'; diff --git a/packages/core/src/skills/tool.test.ts b/packages/core/src/skills/tool.test.ts new file mode 100644 index 0000000..638fefb --- /dev/null +++ b/packages/core/src/skills/tool.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { Skill } from './loader.js'; +import { makeSkillTool } from './tool.js'; + +const fixtures: Skill[] = [ + { + qualifiedName: 'code-review', + frontmatter: { + name: 'code-review', + description: 'Review diff for bugs.', + }, + body: 'You are reviewing. Steps: 1) git diff. 2) Note issues.', + path: '/x/code-review/SKILL.md', + source: 'builtin', + }, + { + qualifiedName: 'plugin-x:do-thing', + frontmatter: { name: 'do-thing', description: 'A plugin skill.' }, + body: 'Do the thing.', + path: '/x/plugin/SKILL.md', + source: 'plugin', + }, +]; + +describe('Skill tool', () => { + it('exposes a Skill ToolHandler', () => { + const t = makeSkillTool(fixtures); + expect(t.name).toBe('Skill'); + expect(t.definition.name).toBe('Skill'); + expect(t.definition.inputSchema).toBeDefined(); + }); + + it('returns skill body when invoked with known name', async () => { + const t = makeSkillTool(fixtures); + const r = await t.execute({ skill: 'code-review' }, { cwd: '/tmp' }); + expect(r.isError).toBeFalsy(); + expect(r.content).toContain('Skill loaded: code-review'); + expect(r.content).toContain('git diff'); + }); + + it('appends user args when provided', async () => { + const t = makeSkillTool(fixtures); + const r = await t.execute({ skill: 'code-review', args: 'only check src/' }, { cwd: '/tmp' }); + expect(r.content).toContain('User-supplied args: only check src/'); + }); + + it('matches plugin-qualified names', async () => { + const t = makeSkillTool(fixtures); + const r = await t.execute({ skill: 'plugin-x:do-thing' }, { cwd: '/tmp' }); + expect(r.isError).toBeFalsy(); + expect(r.content).toContain('Do the thing.'); + }); + + it('errors clearly when skill is missing', async () => { + const t = makeSkillTool(fixtures); + const r = await t.execute({ skill: 'no-such-skill' }, { cwd: '/tmp' }); + expect(r.isError).toBe(true); + expect(r.content).toMatch(/not found/); + expect(r.content).toMatch(/Known:/); + }); + + it('errors when skill arg missing', async () => { + const t = makeSkillTool(fixtures); + const r = await t.execute({}, { cwd: '/tmp' }); + expect(r.isError).toBe(true); + expect(r.content).toMatch(/required/i); + }); +}); diff --git a/packages/core/src/skills/tool.ts b/packages/core/src/skills/tool.ts new file mode 100644 index 0000000..56a5343 --- /dev/null +++ b/packages/core/src/skills/tool.ts @@ -0,0 +1,62 @@ +// Skill tool — lets the agent invoke a skill by name. When invoked, the +// agent's next turn gets the skill's body injected as additional system +// context (via the tool_result). +// Spec: docs/DEVELOPMENT_PLAN.md §3.13 ("Triggered via the Skill tool") + +import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; +import type { Skill } from './loader.js'; + +/** + * Build a Skill tool bound to a specific skill registry. + * + * Why a factory: ToolHandler.execute() is closed over the skills list at the + * time of dispatch. Agent loop owners construct this tool with the loaded + * skills snapshot for the current session. + */ +export function makeSkillTool(skills: Skill[]): ToolHandler { + const byName = new Map(); + for (const s of skills) byName.set(s.qualifiedName, s); + + return { + name: 'Skill', + definition: { + name: 'Skill', + description: + "Load a skill by its qualified name. The skill's instructions become part of the system context for subsequent turns. Use the auto-triggered skill matching the user's request.", + inputSchema: { + type: 'object', + properties: { + skill: { + type: 'string', + description: 'Qualified skill name (e.g. "code-review" or "plugin:foo").', + }, + args: { + type: 'string', + description: 'Optional argument string to pass to the skill.', + }, + }, + required: ['skill'], + }, + }, + async execute(rawInput: Record, _ctx: ToolContext): Promise { + const input = rawInput as { skill?: string; args?: string }; + if (!input.skill || typeof input.skill !== 'string') { + return { content: 'Error: skill name required.', isError: true }; + } + const skill = byName.get(input.skill); + if (!skill) { + const known = [...byName.keys()].slice(0, 10).join(', ') || '(none)'; + return { + content: `Error: skill "${input.skill}" not found. Known: ${known}`, + isError: true, + }; + } + const body = skill.body.trim(); + const argsNote = input.args ? `\n\nUser-supplied args: ${input.args}` : ''; + return { + content: `[Skill loaded: ${skill.qualifiedName}]\n\n${body}${argsNote}`, + data: { skillName: skill.qualifiedName, path: skill.path }, + }; + }, + }; +}