feat(skill-market): security scan visibility, rescan action, pagination fix

This commit is contained in:
matevip 2026-04-24 06:55:19 +08:00
parent aa6e2b6afe
commit af8c2fe6a9
10 changed files with 404 additions and 19 deletions

View File

@ -42,8 +42,9 @@ public class SkillController {
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String skillType,
@RequestParam(required = false) Boolean enabled) {
return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled));
@RequestParam(required = false) Boolean enabled,
@RequestParam(required = false) String scanStatus) {
return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus));
}
@Operation(summary = "获取各类型技能计数tab 徽章用)")
@ -52,6 +53,12 @@ public class SkillController {
return R.ok(skillService.countByType());
}
@Operation(summary = "重新扫描单个技能RFC-042 §2.3.4")
@PostMapping("/{id}/rescan")
public R<SkillEntity> rescan(@PathVariable Long id) {
return R.ok(skillService.rescanSecurity(id));
}
@Operation(summary = "获取已启用技能列表")
@GetMapping("/enabled")
public R<List<SkillEntity>> listEnabled() {

View File

@ -84,6 +84,19 @@ public class SkillEntity {
*/
private String securityScanStatus;
/**
* RFC-042 §2.3 persisted JSON array of the last scan's findings
* ({@code [{ruleId,severity,category,title,description,filePath,
* lineNumber,snippet,remediation}]}). Populated by
* {@code SkillPackageResolver} after every scan so the admin UI can
* render "why blocked" without re-resolving.
*/
@TableField(value = "security_scan_result", updateStrategy = FieldStrategy.ALWAYS)
private String securityScanResult;
/** RFC-042 §2.3 — wall-clock time of the last scan write-back. */
private LocalDateTime securityScanTime;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -5,14 +5,17 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
@ -38,6 +41,7 @@ public class SkillPackageResolver {
private final SkillDependencyChecker dependencyChecker;
private final ObjectMapper objectMapper;
private final SkillWorkspaceManager workspaceManager;
private final SkillMapper skillMapper;
/**
* 解析技能实体为运行时技能包完整流程
@ -72,9 +76,75 @@ public class SkillPackageResolver {
// 4. 综合判定 runtimeAvailable
resolveRuntimeAvailability(resolved);
// 5. RFC-042 §2.3 persist the scan outcome so the admin UI can show
// findings after a restart (previously they lived only in memory).
persistScanOutcome(entity, resolved);
return resolved;
}
/**
* Write back the latest scan status / findings JSON / timestamp when
* they differ from what's already on the row. Keeps the DB in sync
* without re-writing on every idempotent refresh.
*
* <p>Diff-based so a fresh resolve loop across N enabled skills is
* effectively free when nothing has changed on disk. Errors here are
* non-fatal the scan result is already attached to {@code resolved},
* so the UI will still see it for this request.
*/
private void persistScanOutcome(SkillEntity entity, ResolvedSkill resolved) {
if (entity == null || entity.getId() == null) return;
String newStatus = deriveScanStatus(resolved);
String newJson = serializeFindings(resolved.getSecurityFindings());
boolean statusChanged = !Objects.equals(entity.getSecurityScanStatus(), newStatus);
boolean findingsChanged = !Objects.equals(entity.getSecurityScanResult(), newJson);
if (!statusChanged && !findingsChanged) {
return;
}
try {
SkillEntity update = new SkillEntity();
update.setId(entity.getId());
update.setSecurityScanStatus(newStatus);
update.setSecurityScanResult(newJson);
update.setSecurityScanTime(LocalDateTime.now());
skillMapper.updateById(update);
// Keep the in-memory entity coherent with the DB so the next
// resolve in the same tick doesn't redundantly write again.
entity.setSecurityScanStatus(newStatus);
entity.setSecurityScanResult(newJson);
entity.setSecurityScanTime(update.getSecurityScanTime());
} catch (Exception e) {
log.warn("Failed to persist scan outcome for skill '{}': {}", entity.getName(), e.getMessage());
}
}
/**
* Collapse the resolver's rich security state back into the {@code
* PASSED / FAILED / null} tri-state used on the row.
*/
private String deriveScanStatus(ResolvedSkill resolved) {
if (resolved.isSecurityBlocked()) return "FAILED";
List<ResolvedSkill.SecurityFinding> findings = resolved.getSecurityFindings();
if (findings != null && !findings.isEmpty()) return "PASSED"; // scanned and found non-blocking issues
// No block, no findings treat as scanned-clean (still PASSED so
// listEnabledSkills() doesn't treat it as never-scanned).
return "PASSED";
}
private String serializeFindings(List<ResolvedSkill.SecurityFinding> findings) {
if (findings == null || findings.isEmpty()) return null;
try {
return objectMapper.writeValueAsString(findings);
} catch (Exception e) {
log.debug("Failed to serialize findings: {}", e.getMessage());
return null;
}
}
// ==================== 阶段 1内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {

View File

@ -104,6 +104,21 @@ public class SkillRuntimeService {
.collect(Collectors.toList());
}
/**
* Rescan one skill on demand (RFC-042 §2.3.4) runs the full resolver
* pipeline (content + security + dependency), which writes the updated
* scan result to DB as a side-effect, and then invalidates the active
* skills cache so subsequent reads reflect the new status.
*/
public ResolvedSkill rescanSingle(SkillEntity skill) {
ResolvedSkill resolved = packageResolver.resolve(skill);
activeSkillsCache.invalidateAll();
log.info("Rescanned skill '{}' (id={}): status={}, blocked={}",
skill.getName(), skill.getId(),
skill.getSecurityScanStatus(), resolved.isSecurityBlocked());
return resolved;
}
/**
* 根据名称查找 active skill
*/

View File

@ -67,9 +67,15 @@ public class SkillService {
* <p>RFC-042 §2.1 replaces the unbounded {@code /skills} list. Filters
* are all optional; empty or {@code null} means "no filter". Keyword
* searches name / description / tags with LIKE.
*
* <p>{@code scanStatus} (RFC-042 §2.3.5) filters on {@code
* security_scan_status}: {@code "FAILED"} surfaces blocked skills so the
* admin can inspect findings and rescan, {@code "PASSED"} shows scanned
* clean rows, {@code null} / empty means no scan filter.
*/
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
String skillType, Boolean enabled) {
String skillType, Boolean enabled,
String scanStatus) {
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
@ -86,6 +92,9 @@ public class SkillService {
if (enabled != null) {
wrapper.eq(SkillEntity::getEnabled, enabled);
}
if (scanStatus != null && !scanStatus.isBlank()) {
wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase());
}
wrapper.orderByDesc(SkillEntity::getBuiltin)
.orderByDesc(SkillEntity::getCreateTime);
@ -93,6 +102,25 @@ public class SkillService {
return skillMapper.selectPage(pageParam, wrapper);
}
/**
* Manually re-run security + dependency resolution for a single skill
* (RFC-042 §2.3.4). Triggered from the admin UI after the user fixes
* flagged code and wants an immediate verdict instead of waiting for
* the next refresh event.
*
* <p>The resolver itself persists the outcome this method just kicks
* it and returns the reloaded row.
*/
public SkillEntity rescanSecurity(Long id) {
SkillEntity skill = getSkill(id); // throws MateClawException if missing
if (runtimeService == null) {
throw new MateClawException("err.skill.runtime_unavailable",
"Skill runtime not initialized yet; retry in a moment");
}
runtimeService.rescanSingle(skill);
return skillMapper.selectById(id);
}
/**
* Aggregate skill counts per {@code skill_type}, plus an {@code all}
* rollup. Feeds the SkillMarket tab badges without pulling every row.

View File

@ -144,16 +144,20 @@ export const conversationApi = {
// ==================== Skill ====================
export const skillApi = {
/** RFC-042 §2.1 — paginated skill listing with search/type/enabled filters */
/** RFC-042 §2.1 — paginated skill listing with search/type/enabled/scanStatus filters */
page: (params: {
page?: number
size?: number
keyword?: string
skillType?: string
enabled?: boolean
/** 'PASSED' / 'FAILED' — filters by security_scan_status (RFC-042 §2.3.5) */
scanStatus?: string
} = {}) => http.get('/skills', { params }),
/** Tab count aggregate — returns { all, builtin, mcp, dynamic } */
counts: () => http.get('/skills/counts'),
/** RFC-042 §2.3.4 — manually rescan a single skill's security */
rescan: (id: string | number) => http.post(`/skills/${id}/rescan`),
listEnabled: () => http.get('/skills/enabled'),
get: (id: string | number) => http.get(`/skills/${id}`),
create: (data: any) => http.post('/skills', data),

View File

@ -1613,6 +1613,19 @@ export default {
all: 'All statuses',
enabled: 'Enabled',
disabled: 'Disabled',
scanFailed: 'Scan failed',
},
security: {
scanned: 'Scanned',
scanFailed: 'Scan failed',
findingsTitle: 'Security scan failed',
rescan: 'Rescan',
rescanning: 'Scanning...',
rescanPassed: 'Rescan passed',
rescanStillFailed: 'Rescan still failed — see findings below',
rescanFailed: 'Rescan request failed',
noPersistedFindings: 'No persisted findings for this scan. Click "Rescan" to regenerate.',
fix: 'Suggested fix',
},
empty: 'No skills found',
emptyDesc: 'Add skills to enhance your agents\' capabilities',

View File

@ -1623,6 +1623,19 @@ export default {
all: '全部状态',
enabled: '已启用',
disabled: '已禁用',
scanFailed: '扫描失败',
},
security: {
scanned: '已扫描',
scanFailed: '扫描失败',
findingsTitle: '安全扫描失败',
rescan: '重新扫描',
rescanning: '扫描中...',
rescanPassed: '重新扫描通过',
rescanStillFailed: '重新扫描仍未通过,请查看详情',
rescanFailed: '重新扫描失败',
noPersistedFindings: '本次尚无持久化的扫描详情,点击"重新扫描"以重新生成。',
fix: '修复建议',
},
empty: '暂无技能',
emptyDesc: '添加技能以增强 Agent 的能力',

View File

@ -212,6 +212,10 @@ export interface Skill {
sourceConversationId?: string
/** RFC-023: 安全扫描状态 (PASSED / FAILED / null) */
securityScanStatus?: string
/** RFC-042 §2.3 — JSON-serialised SkillSecurityFinding[] from last scan */
securityScanResult?: string
/** RFC-042 §2.3 — wall-clock time of the last scan */
securityScanTime?: string
}
/** 运行时解析状态(来自 /runtime/status */

View File

@ -49,10 +49,11 @@
type="search"
:placeholder="t('skills.search.placeholder')"
/>
<select v-model="query.enabledFilter" class="skill-status-filter" @change="onFilterChange">
<select v-model="query.statusFilter" class="skill-status-filter" @change="onFilterChange">
<option value="">{{ t('skills.filter.all') }}</option>
<option value="true">{{ t('skills.filter.enabled') }}</option>
<option value="false">{{ t('skills.filter.disabled') }}</option>
<option value="enabled">{{ t('skills.filter.enabled') }}</option>
<option value="disabled">{{ t('skills.filter.disabled') }}</option>
<option value="scan_failed">{{ t('skills.filter.scanFailed') }}</option>
</select>
</div>
@ -89,12 +90,19 @@
<span v-if="skill.sourceConversationId" class="runtime-badge rt-synthesized" title="Auto-synthesized from conversation">
🤖 AI
</span>
<!-- Security Scan Status (RFC-023) -->
<span v-if="skill.securityScanStatus === 'FAILED'" class="runtime-badge rt-blocked">
🛡 Scan Failed
</span>
<!-- Security Scan Status (RFC-023, expandable per RFC-042 §2.3) -->
<button
v-if="skill.securityScanStatus === 'FAILED'"
type="button"
class="runtime-badge rt-blocked scan-badge-button"
:aria-expanded="expandedFindings[String(skill.id)] ? 'true' : 'false'"
@click="toggleFindings(skill)"
>
🛡 {{ t('skills.security.scanFailed') }}
<span class="scan-badge-chevron">{{ expandedFindings[String(skill.id)] ? '▾' : '▸' }}</span>
</button>
<span v-else-if="skill.securityScanStatus === 'PASSED'" class="runtime-badge rt-ready">
Scanned
{{ t('skills.security.scanned') }}
</span>
<!-- Security Badge (runtime) -->
<span v-if="getSecurityBadge(skill)" class="runtime-badge" :class="getSecurityBadge(skill)?.cls">
@ -117,6 +125,52 @@
</div>
<div v-if="getRuntimeError(skill)" class="runtime-error">{{ getRuntimeError(skill) }}</div>
<!-- RFC-042 §2.3 persisted findings panel + rescan control -->
<div
v-if="skill.securityScanStatus === 'FAILED' && expandedFindings[String(skill.id)]"
class="scan-findings-panel"
>
<div class="scan-findings-header">
<span class="scan-findings-title">
{{ t('skills.security.findingsTitle') }}
<span v-if="skill.securityScanTime" class="scan-findings-time">
· {{ formatScanTime(skill.securityScanTime) }}
</span>
</span>
<button
class="scan-rescan-btn"
:disabled="rescanning[String(skill.id)]"
@click="rescanSkill(skill)"
>
{{ rescanning[String(skill.id)] ? t('skills.security.rescanning') : t('skills.security.rescan') }}
</button>
</div>
<ul v-if="parsedFindings(skill).length > 0" class="scan-findings-list">
<li
v-for="(f, idx) in parsedFindings(skill)"
:key="`${skill.id}-f-${idx}`"
class="scan-finding-item"
:class="`sev-${(f.severity || 'info').toLowerCase()}`"
>
<div class="scan-finding-head">
<span class="scan-finding-sev">[{{ f.severity || 'INFO' }}]</span>
<span class="scan-finding-id">{{ f.ruleId || f.category || '—' }}</span>
<span v-if="f.filePath" class="scan-finding-loc">
{{ f.filePath }}<span v-if="f.lineNumber">:{{ f.lineNumber }}</span>
</span>
</div>
<div v-if="f.title" class="scan-finding-title">{{ f.title }}</div>
<div v-if="f.description" class="scan-finding-desc">{{ f.description }}</div>
<div v-if="f.remediation" class="scan-finding-fix">
{{ t('skills.security.fix') }}: {{ f.remediation }}
</div>
</li>
</ul>
<div v-else class="scan-findings-empty">
{{ t('skills.security.noPersistedFindings') }}
</div>
</div>
<div class="skill-tags" v-if="skill.tags">
<span v-for="tag in parseTags(skill.tags)" :key="tag" class="skill-tag">{{ tag }}</span>
</div>
@ -154,9 +208,10 @@
class="skill-pagination"
v-model:current-page="query.page"
v-model:page-size="query.size"
:page-sizes="[12, 24, 48]"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
:hide-on-single-page="false"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="onPageSizeChange"
@current-change="loadSkills"
@ -268,16 +323,20 @@ const editingSkill = ref<Skill | null>(null)
const refreshing = ref(false)
const showImportDialog = ref(false)
/** Paginated query state — RFC-042 §2.1 */
/** Paginated query state — RFC-042 §2.1 + §2.3.5 */
const query = reactive({
page: 1,
size: 24,
size: 10,
keyword: '',
skillType: 'all' as string,
/** '' = all, 'true' = enabled only, 'false' = disabled only (string to avoid tri-state checkbox quirks) */
enabledFilter: '' as string,
/** '' = all | 'enabled' | 'disabled' | 'scan_failed' (RFC-042 §2.3.5 unified status filter) */
statusFilter: '' as string,
})
/** Per-skill UI state for the RFC-042 §2.3 findings panel. */
const expandedFindings = ref<Record<string, boolean>>({})
const rescanning = ref<Record<string, boolean>>({})
const categoryTabs = computed(() => [
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
@ -349,7 +408,11 @@ async function loadSkills() {
const params: Record<string, unknown> = { page: query.page, size: query.size }
if (query.keyword) params.keyword = query.keyword.trim()
if (query.skillType && query.skillType !== 'all') params.skillType = query.skillType
if (query.enabledFilter !== '') params.enabled = query.enabledFilter === 'true'
// Map the single status filter onto the backend's two independent params:
// enabled (bool) and scanStatus (PASSED/FAILED). scan_failed implies any enabled state.
if (query.statusFilter === 'enabled') params.enabled = true
else if (query.statusFilter === 'disabled') params.enabled = false
else if (query.statusFilter === 'scan_failed') params.scanStatus = 'FAILED'
const res: any = await skillApi.page(params)
const data = res.data || {}
@ -467,6 +530,60 @@ async function handleRefreshRuntime() {
}
}
// ==================== RFC-042 §2.3 persisted scan findings ====================
/** Parse the DB-persisted JSON findings array. Returns [] on any parse error. */
function parsedFindings(skill: Skill): SkillSecurityFinding[] {
const raw = skill.securityScanResult
if (!raw) return []
try {
const arr = JSON.parse(raw)
return Array.isArray(arr) ? (arr as SkillSecurityFinding[]) : []
} catch {
return []
}
}
function toggleFindings(skill: Skill) {
const key = String(skill.id)
expandedFindings.value = { ...expandedFindings.value, [key]: !expandedFindings.value[key] }
}
async function rescanSkill(skill: Skill) {
const key = String(skill.id)
rescanning.value = { ...rescanning.value, [key]: true }
try {
const res: any = await skillApi.rescan(skill.id)
const updated: Skill | undefined = res?.data
if (updated) {
// Patch the row in-place so the panel updates without a full page reload.
const idx = skills.value.findIndex(s => s.id === skill.id)
if (idx >= 0) skills.value.splice(idx, 1, { ...skills.value[idx], ...updated })
ElMessage.success(
updated.securityScanStatus === 'FAILED'
? t('skills.security.rescanStillFailed')
: t('skills.security.rescanPassed')
)
}
// Refresh runtime status too so the in-memory badges stay in sync.
await loadRuntimeStatus()
} catch (e: any) {
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.security.rescanFailed'))
} finally {
rescanning.value = { ...rescanning.value, [key]: false }
}
}
function formatScanTime(iso: string): string {
try {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString()
} catch {
return iso
}
}
// ==================== Runtime Display Helpers ====================
function getRuntimeStatus(skill: Skill): SkillRuntimeStatus | null {
@ -699,6 +816,107 @@ html.dark .skill-pagination :deep(.el-pagination .el-select .el-input__wrapper)
margin-right: 12px;
}
/* RFC-042 §2.3 — security scan findings panel (frosted, non-EP) */
.scan-badge-button {
border: none;
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 8px;
}
.scan-badge-button:hover { filter: brightness(0.97); }
.scan-badge-chevron { font-size: 10px; opacity: 0.75; }
.scan-findings-panel {
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 95, 86, 0.08);
border: 1px solid rgba(255, 95, 86, 0.22);
backdrop-filter: blur(8px) saturate(1.05);
-webkit-backdrop-filter: blur(8px) saturate(1.05);
}
html.dark .scan-findings-panel {
background: rgba(255, 95, 86, 0.12);
border-color: rgba(255, 95, 86, 0.28);
}
.scan-findings-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.scan-findings-title {
font-size: 12px;
font-weight: 600;
color: var(--mc-text-primary);
}
.scan-findings-time {
font-weight: 400;
font-size: 11px;
color: var(--mc-text-tertiary);
}
.scan-rescan-btn {
height: 26px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.55);
color: var(--mc-text-primary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
html.dark .scan-rescan-btn {
background: rgba(255, 255, 255, 0.08);
}
.scan-rescan-btn:hover:not(:disabled) {
background: var(--mc-primary);
color: #fff;
}
.scan-rescan-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.scan-findings-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.scan-finding-item {
padding: 7px 9px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.45);
border-left: 3px solid var(--mc-border);
font-size: 12px;
line-height: 1.5;
}
html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
.scan-finding-item.sev-critical { border-left-color: #d32f2f; }
.scan-finding-item.sev-high { border-left-color: #f57c00; }
.scan-finding-item.sev-medium { border-left-color: #fbc02d; }
.scan-finding-item.sev-low { border-left-color: #689f38; }
.scan-finding-head {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
}
.scan-finding-sev { font-weight: 700; color: var(--mc-text-primary); }
.scan-finding-id { color: var(--mc-text-secondary); }
.scan-finding-loc { color: var(--mc-text-tertiary); }
.scan-finding-title { font-weight: 600; margin-top: 3px; color: var(--mc-text-primary); }
.scan-finding-desc { color: var(--mc-text-secondary); margin-top: 2px; }
.scan-finding-fix { color: var(--mc-text-secondary); margin-top: 4px; font-style: italic; }
.scan-findings-empty {
font-size: 12px;
color: var(--mc-text-tertiary);
font-style: italic;
}
/* 技能网格 */
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.skill-card { padding: 18px; transition: all 0.15s; display: flex; flex-direction: column; min-height: 280px; }