From fd347338a4f780d0a59ed00128f1431dfbe4274a Mon Sep 17 00:00:00 2001 From: David Abutbul Date: Mon, 16 Feb 2026 13:11:35 +0200 Subject: [PATCH 1/4] feat(api): expose security evaluation results - Add security field to skill version API responses - Map llmAnalysis database field to public API format - Display security info in CLI inspect command - Enable security tools like clawsec-clawhub-checker to access internal security checks Security field includes: - status: clean|suspicious|malicious|pending|error - hasWarnings: boolean - checkedAt: timestamp - model: evaluation model name Backward compatible: optional field, no breaking changes. --- convex/httpApiV1/skillsV1.ts | 32 +++++++++++++++++++ packages/clawdhub/src/cli/commands/inspect.ts | 16 ++++++++++ packages/schema/src/schemas.ts | 8 +++++ 3 files changed, 56 insertions(+) diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 01ca9a1c9..ceea84a2d 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -365,6 +365,37 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) if (!version) return text('Version not found', 404, rate.headers) if (version.softDeletedAt) return text('Version not available', 410, rate.headers) + // Map llmAnalysis to security status + let security = undefined + if (version.llmAnalysis) { + const analysis = version.llmAnalysis + let status: "clean" | "suspicious" | "malicious" | "pending" | "error" + switch (analysis.verdict) { + case 'benign': + status = 'clean' + break + case 'suspicious': + status = 'suspicious' + break + case 'malicious': + status = 'malicious' + break + default: + status = analysis.status === 'error' ? 'error' : 'pending' + } + + const hasWarnings = analysis.verdict === 'suspicious' || + analysis.verdict === 'malicious' || + (analysis.dimensions?.some((d: any) => d.rating !== 'ok')) + + security = { + status, + hasWarnings, + checkedAt: analysis.checkedAt || null, + model: analysis.model || null, + } + } + return json( { skill: { slug: skill.slug, displayName: skill.displayName }, @@ -379,6 +410,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) sha256: file.sha256, contentType: file.contentType ?? null, })), + security, }, }, 200, diff --git a/packages/clawdhub/src/cli/commands/inspect.ts b/packages/clawdhub/src/cli/commands/inspect.ts index 5adf0968d..62caa2740 100644 --- a/packages/clawdhub/src/cli/commands/inspect.ts +++ b/packages/clawdhub/src/cli/commands/inspect.ts @@ -130,6 +130,22 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec if (shouldPrintMeta && versionResult?.version) { printVersionSummary(versionResult.version) + + // Display security info if available + const version = versionResult.version as { security?: any } + if (version.security) { + const sec = version.security + console.log(`\nSecurity: ${sec.status.toUpperCase()}`) + if (sec.hasWarnings) { + console.log(`⚠️ This skill has security warnings`) + } + if (sec.checkedAt) { + console.log(`Checked: ${new Date(sec.checkedAt).toLocaleDateString()}`) + } + if (sec.model) { + console.log(`Model: ${sec.model}`) + } + } } if (versionsList?.items && Array.isArray(versionsList.items)) { diff --git a/packages/schema/src/schemas.ts b/packages/schema/src/schemas.ts index a1e7469a4..3cf6b17ed 100644 --- a/packages/schema/src/schemas.ts +++ b/packages/schema/src/schemas.ts @@ -208,6 +208,13 @@ export const ApiV1SkillVersionListResponseSchema = type({ nextCursor: 'string|null', }) +export const SecurityStatusSchema = type({ + status: '"clean" | "suspicious" | "malicious" | "pending" | "error"', + hasWarnings: 'boolean', + checkedAt: 'number|null', + model: 'string|null', +}) + export const ApiV1SkillVersionResponseSchema = type({ version: type({ version: 'string', @@ -215,6 +222,7 @@ export const ApiV1SkillVersionResponseSchema = type({ changelog: 'string', changelogSource: '"auto"|"user"|null?', files: 'unknown?', + security: SecurityStatusSchema.optional(), }).or('null'), skill: type({ slug: 'string', From 09cb3edb33b18ad180437ff0d20c551dd8060da1 Mon Sep 17 00:00:00 2001 From: David Abutbul Date: Mon, 16 Feb 2026 19:55:14 +0200 Subject: [PATCH 2/4] fix: ensure hasWarnings is always boolean - Add ?? false to coerce undefined to false when dimensions is undefined - Fixes Greptile comment: hasWarnings can be undefined instead of boolean - Ensures SecurityStatusSchema validation passes on client side --- convex/httpApiV1/skillsV1.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index ceea84a2d..236c52709 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -386,7 +386,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) const hasWarnings = analysis.verdict === 'suspicious' || analysis.verdict === 'malicious' || - (analysis.dimensions?.some((d: any) => d.rating !== 'ok')) + (analysis.dimensions?.some((d: any) => d.rating !== 'ok') ?? false) security = { status, From ff3f6f55f40d51439c7af2e0b751e10a5b7ffb25 Mon Sep 17 00:00:00 2001 From: David Abutbul Date: Mon, 16 Feb 2026 20:00:18 +0200 Subject: [PATCH 3/4] Update convex/httpApiV1/skillsV1.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- convex/httpApiV1/skillsV1.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 236c52709..ba07b0f4b 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -391,7 +391,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) security = { status, hasWarnings, - checkedAt: analysis.checkedAt || null, + checkedAt: analysis.checkedAt ?? null, model: analysis.model || null, } } From 4c5c94e6903977c2612c1bb351db4d006a6d8314 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 13:01:10 +0100 Subject: [PATCH 4/4] fix(api-cli): harden security inspect output + tests (#362) (thanks @abutbul) --- CHANGELOG.md | 1 + convex/httpApiV1/skillsV1.ts | 18 +++-- .../clawdhub/src/cli/commands/inspect.test.ts | 39 +++++++++++ packages/clawdhub/src/cli/commands/inspect.ts | 68 ++++++++++++++----- 4 files changed, 104 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f5c1f20..6c4418943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - CLI tests: assert 5xx HTTP responses still perform retry attempts before surfacing final error (#457) (thanks @YonghaoZhao722). - GitHub import: improve storage/publish failure errors with actionable context; add regression tests for error formatting (#512) (thanks @vassiliylakhonin). - CLI: show manual URL guidance when automatic browser opening is unavailable; add regression tests for opener errors (#163) (thanks @aronchick). +- API/CLI: expose skill security status in version inspect output, with schema wiring and CLI regression coverage (#362) (thanks @abutbul). ## 0.6.1 - 2026-02-13 diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index ba07b0f4b..da4a3304c 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -369,7 +369,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) let security = undefined if (version.llmAnalysis) { const analysis = version.llmAnalysis - let status: "clean" | "suspicious" | "malicious" | "pending" | "error" + let status: 'clean' | 'suspicious' | 'malicious' | 'pending' | 'error' switch (analysis.verdict) { case 'benign': status = 'clean' @@ -383,11 +383,17 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) default: status = analysis.status === 'error' ? 'error' : 'pending' } - - const hasWarnings = analysis.verdict === 'suspicious' || - analysis.verdict === 'malicious' || - (analysis.dimensions?.some((d: any) => d.rating !== 'ok') ?? false) - + + const hasWarnings = + analysis.verdict === 'suspicious' || + analysis.verdict === 'malicious' || + (Array.isArray(analysis.dimensions) && + analysis.dimensions.some((dimension) => { + if (!dimension || typeof dimension !== 'object') return false + const rating = (dimension as { rating?: unknown }).rating + return typeof rating === 'string' && rating !== 'ok' + })) + security = { status, hasWarnings, diff --git a/packages/clawdhub/src/cli/commands/inspect.test.ts b/packages/clawdhub/src/cli/commands/inspect.test.ts index 04a73f931..5b7618c79 100644 --- a/packages/clawdhub/src/cli/commands/inspect.test.ts +++ b/packages/clawdhub/src/cli/commands/inspect.test.ts @@ -126,6 +126,45 @@ describe('cmdInspect', () => { expect(url.searchParams.get('version')).toBeNull() }) + it('prints security summary when version security metadata exists', async () => { + mockApiRequest + .mockResolvedValueOnce({ + skill: { + slug: 'demo', + displayName: 'Demo', + summary: null, + tags: { latest: '2.0.0' }, + stats: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init' }, + owner: null, + }) + .mockResolvedValueOnce({ + skill: { slug: 'demo', displayName: 'Demo' }, + version: { + version: '2.0.0', + createdAt: 3, + changelog: 'init', + files: [], + security: { + status: 'suspicious', + hasWarnings: true, + checkedAt: 1_700_000_000_000, + model: 'gpt-5.2', + }, + }, + }) + + await cmdInspect(makeOpts(), 'demo', { version: '2.0.0' }) + + expect(mockLog).toHaveBeenCalledWith('Security: SUSPICIOUS') + expect(mockLog).toHaveBeenCalledWith('Warnings: yes') + expect(mockLog).toHaveBeenCalledWith('Checked: 2023-11-14T22:13:20.000Z') + expect(mockLog).toHaveBeenCalledWith('Model: gpt-5.2') + }) + it('rejects when both version and tag are provided', async () => { await expect( cmdInspect(makeOpts(), 'demo', { version: '1.0.0', tag: 'latest' }), diff --git a/packages/clawdhub/src/cli/commands/inspect.ts b/packages/clawdhub/src/cli/commands/inspect.ts index 62caa2740..bafb843b6 100644 --- a/packages/clawdhub/src/cli/commands/inspect.ts +++ b/packages/clawdhub/src/cli/commands/inspect.ts @@ -27,6 +27,13 @@ type FileEntry = { contentType: string | null } +type SecurityStatus = { + status: 'clean' | 'suspicious' | 'malicious' | 'pending' | 'error' + hasWarnings: boolean + checkedAt: number | null + model: string | null +} + export async function cmdInspect(opts: GlobalOpts, slug: string, options: InspectOptions = {}) { const trimmed = slug.trim() if (!trimmed) fail('Slug required') @@ -130,22 +137,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec if (shouldPrintMeta && versionResult?.version) { printVersionSummary(versionResult.version) - - // Display security info if available - const version = versionResult.version as { security?: any } - if (version.security) { - const sec = version.security - console.log(`\nSecurity: ${sec.status.toUpperCase()}`) - if (sec.hasWarnings) { - console.log(`⚠️ This skill has security warnings`) - } - if (sec.checkedAt) { - console.log(`Checked: ${new Date(sec.checkedAt).toLocaleDateString()}`) - } - if (sec.model) { - console.log(`Model: ${sec.model}`) - } - } + printSecuritySummary(versionResult.version) } if (versionsList?.items && Array.isArray(versionsList.items)) { @@ -274,6 +266,50 @@ function formatVersionLine(item: unknown) { return `${version} ${createdAt}${snippet}` } +function printSecuritySummary(version: unknown) { + if (!version || typeof version !== 'object') return + const sec = normalizeSecurity((version as { security?: unknown }).security) + if (!sec) return + console.log(`Security: ${sec.status.toUpperCase()}`) + if (sec.hasWarnings) { + console.log('Warnings: yes') + } + if (typeof sec.checkedAt === 'number') { + console.log(`Checked: ${formatTimestamp(sec.checkedAt)}`) + } + if (sec.model) { + console.log(`Model: ${sec.model}`) + } +} + +function normalizeSecurity(security: unknown): SecurityStatus | null { + if (!security || typeof security !== 'object') return null + const value = security as { + status?: unknown + hasWarnings?: unknown + checkedAt?: unknown + model?: unknown + } + if ( + value.status !== 'clean' && + value.status !== 'suspicious' && + value.status !== 'malicious' && + value.status !== 'pending' && + value.status !== 'error' + ) { + return null + } + if (typeof value.hasWarnings !== 'boolean') return null + const checkedAt = typeof value.checkedAt === 'number' ? value.checkedAt : null + const model = typeof value.model === 'string' ? value.model : null + return { + status: value.status, + hasWarnings: value.hasWarnings, + checkedAt, + model, + } +} + function formatFileLine(file: FileEntry) { const size = file.size === null ? '?' : formatBytes(file.size) const sha = file.sha256 ?? '?'