Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile

- name: Build skill-guard (workspace dependency)
run: pnpm --filter @skillbrain/skill-guard build

- name: Build storage (workspace dependency)
run: pnpm --filter @skillbrain/storage build

Expand All @@ -40,3 +43,6 @@ jobs:

- name: Test storage
run: pnpm --filter @skillbrain/storage test

- name: Test skill-guard
run: pnpm --filter @skillbrain/skill-guard test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Thumbs.db
# ─── Build artifacts ──────────────────────────────────────────────────────────
packages/codegraph/dist/
packages/storage/dist/
packages/skill-guard/dist/

# ─── Node (nei progetti dentro Progetti/) ─────────────────────────────────────
Progetti/*/node_modules/
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ Every skill must have:
- Description that includes **trigger keywords** so `skill_route` can find it via FTS
- Body in markdown — no fixed structure required, but the "Overview / When to use / Process / Examples" template (see `.claude/skill/skill-template-2.0/`) is recommended

All imported/added skills are security-scanned by `@skillbrain/skill-guard`; a `BLOCK` verdict forces `status='pending'` pending human review. This is defense-in-depth, not a sound filter — a single indicator (one `curl | bash`, one secret read) usually scores `CAUTION`/`SAFE` and still activates; admin content-edit, `SkillsStore.rollback()`, and review proposal-apply paths do not re-scan, so a verdict can go stale (see `packages/skill-guard/README.md#limitations`).

Skills with confidence ≤ 3 and ≥ 30 sessions stale are auto-deprecated by `skill_decay`. Skills routed but never loaded after 30 days are flagged by `skill_gc`.

---
Expand Down
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,22 @@ RUN apk add --no-cache python3 make g++
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/codegraph/package.json ./packages/codegraph/
COPY packages/storage/package.json ./packages/storage/
COPY packages/skill-guard/package.json ./packages/skill-guard/

RUN corepack enable && pnpm install --frozen-lockfile

# Copy sources
COPY packages/skill-guard/src/ ./packages/skill-guard/src/
COPY packages/skill-guard/tsconfig.json ./packages/skill-guard/

COPY packages/storage/src/ ./packages/storage/src/
COPY packages/storage/tsconfig.json ./packages/storage/

COPY packages/codegraph/src/ ./packages/codegraph/src/
COPY packages/codegraph/tsconfig.json ./packages/codegraph/

# Build storage first (codegraph depends on it)
# Build skill-guard first (storage depends on it), then storage (codegraph depends on it)
RUN pnpm --filter @skillbrain/skill-guard build
RUN pnpm --filter @skillbrain/storage build

# Build codegraph (package name is "codegraph", not scoped)
Expand Down
802 changes: 802 additions & 0 deletions docs/plans/2026-07-14-skill-guard.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/codegraph/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,13 @@ program
.action(async (targetPath: string, opts: { full?: boolean }) => {
try {
const { importSkills } = await import('@skillbrain/storage')
const result = importSkills(targetPath, { prune: !!opts.full })
const result = await importSkills(targetPath, { prune: !!opts.full })
console.log(`✅ Import complete:`)
console.log(` Skills: ${result.skills}`)
console.log(` Agents: ${result.agents}`)
console.log(` Commands: ${result.commands}`)
if (opts.full) console.log(` Pruned (deprecated): ${result.pruned}`)
if (result.blocked > 0) console.log(` ⚠️ Quarantined to pending (security gate BLOCK): ${result.blocked}`)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
console.error(`[codegraph] Import failed: ${msg}`)
Expand Down
4 changes: 2 additions & 2 deletions packages/codegraph/src/mcp/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ export async function startHttpServer(port: number, authToken?: string): Promise
}

const mcpUserId = (req as any).mcpUserId as string | undefined
const server = createMcpServer({ userId: mcpUserId })
const server = createMcpServer({ userId: mcpUserId, anthropicApiKey: ANTHROPIC_API_KEY })
await server.connect(transport)
} else {
res.status(400).json({ error: 'No valid session. Send an initialize request first.' })
Expand Down Expand Up @@ -531,7 +531,7 @@ export async function startHttpServer(port: number, authToken?: string): Promise
sseTransports.set(transport.sessionId, transport)
transport.onclose = () => { sseTransports.delete(transport.sessionId) }
const mcpUserId = (req as any).mcpUserId as string | undefined
const server = createMcpServer({ userId: mcpUserId })
const server = createMcpServer({ userId: mcpUserId, anthropicApiKey: ANTHROPIC_API_KEY })
await server.connect(transport)
// start() is invoked by connect(); nothing more to do here — the response stays open
})
Expand Down
7 changes: 6 additions & 1 deletion packages/codegraph/src/mcp/routes/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ export function createReviewRouter(ctx: RouteContext): Router {
const memories = db.prepare(
`SELECT id, type, context, solution, skill, tags, created_at FROM memories WHERE status = 'pending-review' ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(limit, offset)
// risk_score / risk_recommendation / risk_findings come from the
// @skillbrain/skill-guard security gate (migration 036) — a skill lands
// here with status='pending' either because it's awaiting normal human
// review, or because the gate verdict was BLOCK. Surfacing the three
// columns lets the dashboard render a risk badge without a second call.
const skills = db.prepare(
`SELECT name, category, description, type, updated_at FROM skills WHERE status = 'pending' ORDER BY updated_at DESC`
`SELECT name, category, description, type, updated_at, risk_score, risk_recommendation, risk_findings FROM skills WHERE status = 'pending' ORDER BY updated_at DESC`
).all()
const components = db.prepare(
`SELECT id, name, project, section_type, description, created_at FROM ui_components WHERE status = 'pending' ORDER BY created_at DESC`
Expand Down
4 changes: 3 additions & 1 deletion packages/codegraph/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface CreateMcpServerOptions {
* Required for user-scoped tools (e.g. user_env_*).
*/
userId?: string
/** Server-wide Anthropic API key fallback — see ToolContext.anthropicApiKey (tools/index.ts). */
anthropicApiKey?: string
}

export function createMcpServer(opts: CreateMcpServerOptions = {}): McpServer {
Expand All @@ -56,7 +58,7 @@ export function createMcpServer(opts: CreateMcpServerOptions = {}): McpServer {
})

// Register all domain tool modules
registerAllTools(server, { userId: opts.userId })
registerAllTools(server, { userId: opts.userId, anthropicApiKey: opts.anthropicApiKey })

// --- Resources ---
server.resource('codegraph://repos', 'codegraph://repos', async () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/codegraph/src/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export interface ToolContext {
* back to a default user.
*/
userId?: string
/**
* Server-wide Anthropic API key fallback (process.env.ANTHROPIC_API_KEY),
* mirroring RouteContext.anthropicApiKey (routes/index.ts). Used by
* `skill_scan`'s optional LLM judge layer (Task 8) when no per-user key is
* found via UsersEnvStore. Undefined in stdio mode.
*/
anthropicApiKey?: string
}

export type ToolRegistrar = (server: McpServer, ctx: ToolContext) => void
Expand Down
93 changes: 93 additions & 0 deletions packages/codegraph/src/mcp/tools/skill-scan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Synapse — The intelligence layer for AI workflows
* Copyright (c) 2026 Daniel De Vecchi
*
* Licensed under AGPL-3.0-or-later.
* See LICENSE for details.
*
* Commercial license: [email protected]
*/

/**
* Pure scan-and-format logic behind the `skill_scan` MCP tool (Task 8) —
* the on-demand counterpart to the ingestion gate (`applyGate`, Task 7).
*
* Split out of skills.ts so it can be unit-tested without spinning up an
* McpServer or a SkillsStore-backed DB (see tests/skill-scan-tool.test.ts).
* The actual server.tool('skill_scan', ...) handler in skills.ts is a thin
* wrapper: resolve the target content (resolveScanTarget), resolve an
* Anthropic key if `llm: true` was requested, then call runSkillScan().
*
* Unlike applyGate (write-path, static-only by design — see skill-gate.ts),
* this is the read-path/on-demand tool where the optional LLM judge layer
* built in Task 5 (@skillbrain/skill-guard's scanLlm) is actually reachable.
*/

import { scanSkill, type LlmClient, type ScanReport } from '@skillbrain/storage'

export interface ResolveScanTargetInput {
name?: string
content?: string
}

export type ScanTargetResult = { target: string } | { error: string }

/**
* Resolve what to scan: an existing skill's content (by name, via the
* caller-supplied loader) takes precedence, falling back to raw `content`.
* Returns a clear, user-facing error string when neither resolves.
*/
export function resolveScanTarget(
input: ResolveScanTargetInput,
loadSkillContent: (name: string) => string | undefined,
): ScanTargetResult {
if (input.name) {
const content = loadSkillContent(input.name)
if (content === undefined) {
return { error: `Skill "${input.name}" not found. Use skill_list to see available skills.` }
}
return { target: content }
}
if (input.content) return { target: input.content }
return { error: 'Provide either "name" (an existing skill) or "content" (raw text) to scan.' }
}

function verdictLine(report: ScanReport): string {
const icon = report.recommendation === 'BLOCK' ? '⛔' : report.recommendation === 'CAUTION' ? '⚠️' : '✅'
return `${icon} ${report.recommendation} (score ${report.score})`
}

export interface FormatScanReportOpts {
llmRequested: boolean
llmRan: boolean
}

/** Renders the ScanReport as JSON text plus a one-line human verdict + LLM-layer note. */
export function formatScanReport(report: ScanReport, opts: FormatScanReportOpts): string {
const layerNote = opts.llmRan
? 'LLM judge: ran'
: opts.llmRequested
? 'LLM judge: requested but skipped (no Anthropic API key resolved) — static-only result'
: 'LLM judge: not requested (static-only)'
return `${verdictLine(report)}\n${layerNote}\n\n${JSON.stringify(report, null, 2)}`
}

export interface RunSkillScanOpts {
/** LLM completion callback. If provided, the LLM judge layer runs. */
complete?: LlmClient['complete']
/** Whether the caller asked for `llm: true` — used only for the "skipped" note when `complete` is absent. */
llmRequested?: boolean
}

export interface SkillScanResult {
report: ScanReport
text: string
llmRan: boolean
}

/** Runs scanSkill() (LLM layer only when `complete` is given) and formats the response. */
export async function runSkillScan(content: string, opts: RunSkillScanOpts = {}): Promise<SkillScanResult> {
const llmRan = typeof opts.complete === 'function'
const report = await scanSkill(content, llmRan ? { llm: { complete: opts.complete! } } : {})
return { report, text: formatScanReport(report, { llmRequested: !!opts.llmRequested, llmRan }), llmRan }
}
Loading
Loading