diff --git a/crates/desktop/src/components/deep-link-import-modal.tsx b/crates/desktop/src/components/deep-link-import-modal.tsx index 08259786..815c5855 100644 --- a/crates/desktop/src/components/deep-link-import-modal.tsx +++ b/crates/desktop/src/components/deep-link-import-modal.tsx @@ -142,6 +142,8 @@ export function DeepLinkImportModal({ scope: variables.installToProject ? "project" : "global", project_path: variables.selectedProject?.path ?? null, install_all: false, + force_unsafe: false, + session_id: null, }); return pendingResults.map((result) => ({ @@ -151,7 +153,9 @@ export function DeepLinkImportModal({ | "error", error: response.success ? undefined - : t("skillInstallFailed"), + : ((response.audit_blocked + ? response.audit?.summary + : undefined) ?? t("skillInstallFailed")), })); } diff --git a/crates/desktop/src/components/import-github-skill-panel.tsx b/crates/desktop/src/components/import-github-skill-panel.tsx index 641ec3fe..45a37d06 100644 --- a/crates/desktop/src/components/import-github-skill-panel.tsx +++ b/crates/desktop/src/components/import-github-skill-panel.tsx @@ -39,6 +39,7 @@ import { CreateCredentialDialog } from "../pages/settings/components/create-cred import { credentialsListQueryOptions } from "../requests/credentials"; import { gitInstallSkillsMutationOptions } from "../requests/skills"; import { AgentSelector } from "./agent-selector"; +import { SkillAudit } from "./skill-audit"; interface ImportGithubSkillPanelProps { onDone: () => void; @@ -721,93 +722,115 @@ export function ImportGithubSkillPanel({ ) : (
{scannedSkills.map((skill) => ( - -
- + + + + {skill.audit && ( + + )} + ))} )} diff --git a/crates/desktop/src/components/import-skill-panel.tsx b/crates/desktop/src/components/import-skill-panel.tsx index 477eec6b..0ed6ad45 100644 --- a/crates/desktop/src/components/import-skill-panel.tsx +++ b/crates/desktop/src/components/import-skill-panel.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Card, + Checkbox, FieldError, Fieldset, Form, @@ -10,18 +11,22 @@ import { Label, TextField, } from "@heroui/react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { open } from "@tauri-apps/plugin-dialog"; import { useMemo, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import type { ImportSkillRequest } from "../generated/dto"; import { useAgentAvailability } from "../hooks/use-agent-availability"; import { useApi } from "../hooks/use-api"; import { supportsSkillMutation } from "../lib/agent-capabilities"; -import { importSkillMutationOptions } from "../requests/skills"; +import { + importSkillMutationOptions, + skillAuditQueryOptions, +} from "../requests/skills"; import { capture } from "../lib/analytics"; import { AgentSelector } from "./agent-selector"; +import { SkillAudit } from "./skill-audit"; interface ImportSkillPanelProps { onDone: () => void; @@ -56,6 +61,7 @@ export function ImportSkillPanel({ ); const [error, setError] = useState(null); + const [forceUnsafe, setForceUnsafe] = useState(false); const { control, @@ -83,7 +89,26 @@ export function ImportSkillPanel({ }, }); + const importPath = useWatch({ control, name: "importPath" }); + const auditPath = importPath?.trim() || undefined; + const { data: skillAudit, error: auditError } = useQuery({ + ...skillAuditQueryOptions({ api, path: auditPath }), + }); + + // Fail closed: once a path is chosen the audit must return a non-malicious + // verdict before import is allowed. import_skill does not re-audit, so a + // still-running or failed audit has to block submission — otherwise a + // malicious skill slips through the pending/error window. + const auditPending = + Boolean(auditPath) && skillAudit === undefined && auditError == null; + const auditFailed = Boolean(auditPath) && auditError != null; + const canForceUnsafe = skillAudit?.verdict === "malicious" || auditFailed; + const auditBlocked = auditPending || (canForceUnsafe && !forceUnsafe); + const handleImportClick = async (values: ImportSkillFormValues) => { + if (auditBlocked) { + return; + } const body: ImportSkillRequest = { path: values.importPath.trim(), }; @@ -125,6 +150,7 @@ export function ImportSkillPanel({ shouldDirty: true, shouldValidate: true, }); + setForceUnsafe(false); } }; @@ -135,6 +161,7 @@ export function ImportSkillPanel({ shouldDirty: true, shouldValidate: true, }); + setForceUnsafe(false); } }; @@ -238,6 +265,19 @@ export function ImportSkillPanel({ + {skillAudit && } + + {auditFailed && ( + + + + + {t("auditFailed")} + + + + )} +
+ {canForceUnsafe && ( + + {t("installAnyway")} + + )} +
diff --git a/crates/desktop/src/components/skill-audit.tsx b/crates/desktop/src/components/skill-audit.tsx new file mode 100644 index 00000000..841d5f1f --- /dev/null +++ b/crates/desktop/src/components/skill-audit.tsx @@ -0,0 +1,168 @@ +import { + ChevronDownIcon, + ChevronUpIcon, + ShieldCheckIcon, +} from "@heroicons/react/24/solid"; +import { Card, Chip } from "@heroui/react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + AuditReportDto, + CategoryDto, + SeverityDto, + VerdictDto, +} from "../generated/dto"; +import { cn } from "../lib/utils"; + +type ChipColor = "success" | "warning" | "danger" | "default"; + +const VERDICT_COLOR: Record = { + benign: "success", + suspicious: "warning", + malicious: "danger", +}; + +const VERDICT_LABEL_KEY: Record = { + benign: "auditVerdictBenign", + suspicious: "auditVerdictSuspicious", + malicious: "auditVerdictMalicious", +}; + +const VERDICT_SUMMARY_KEY: Record = { + benign: "auditSummaryBenign", + suspicious: "auditSummarySuspicious", + malicious: "auditSummaryMalicious", +}; + +const SEVERITY_COLOR: Record = { + info: "default", + low: "default", + medium: "warning", + high: "danger", + critical: "danger", +}; + +const SEVERITY_LABEL_KEY: Record = { + info: "auditSeverityInfo", + low: "auditSeverityLow", + medium: "auditSeverityMedium", + high: "auditSeverityHigh", + critical: "auditSeverityCritical", +}; + +const CATEGORY_LABEL_KEY: Record = { + credential_exfil: "auditCategoryCredentialExfil", + data_exfil: "auditCategoryDataExfil", + command_injection: "auditCategoryCommandInjection", + prompt_injection: "auditCategoryPromptInjection", + tool_chaining: "auditCategoryToolChaining", + persistence: "auditCategoryPersistence", + host_tamper: "auditCategoryHostTamper", + obfuscation: "auditCategoryObfuscation", + other: "auditCategoryOther", +}; + +interface SkillAuditProps { + report: AuditReportDto; + className?: string; +} + +/** + * Security-audit result for a skill. The verdict badge + a plain-language + * one-line summary are always visible; the per-finding detail collapses, + * defaulting open for Suspicious/Malicious so a real risk is never hidden, + * and closed for Benign. + * + * Each finding leads with a human-readable risk description (i18n, falling back + * to the backend's English `evidence`); the rule category and file location are + * secondary. The internal rule id is intentionally not shown. + */ +export function SkillAudit({ report, className }: SkillAuditProps) { + const { t } = useTranslation(); + const findingCount = report.findings.length; + const hasFindings = findingCount > 0; + const [expanded, setExpanded] = useState(report.verdict !== "benign"); + + return ( + + + + + {expanded && hasFindings && ( +
    + {report.findings.map((f) => ( +
  • + + {t(SEVERITY_LABEL_KEY[f.severity])} + +
    +

    + {t(`auditEvidence_${f.rule_id}`, { + defaultValue: f.evidence, + })} +

    +

    + {t(CATEGORY_LABEL_KEY[f.category])} + {" · "} + + {f.file} + {f.line != null ? `:${f.line}` : ""} + +

    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/crates/desktop/src/components/skill-detail.tsx b/crates/desktop/src/components/skill-detail.tsx index 6cb8cac3..5ff1b740 100644 --- a/crates/desktop/src/components/skill-detail.tsx +++ b/crates/desktop/src/components/skill-detail.tsx @@ -29,10 +29,12 @@ import { globalSkillLockQueryOptions, openSkillFolderMutationOptions, projectSkillLockQueryOptions, + skillAuditQueryOptions, skillContentQueryOptions, skillTreeQueryOptions, } from "../requests/skills"; import { ManageSkillAgentsDialog } from "./manage-skill-agents-dialog"; +import { SkillAudit } from "./skill-audit"; import { DeleteSkillDialog, DeleteSkillLocationDialog, @@ -128,6 +130,13 @@ export function SkillDetail({ group, projectPath }: SkillDetailProps) { }), }); + const { data: skillAudit } = useQuery({ + ...skillAuditQueryOptions({ + api, + path: skill.source_path ?? undefined, + }), + }); + const currentSkillSource = useMemo(() => { const skillItem = group.items[0]; if (skillItem.source === "global") { @@ -298,6 +307,8 @@ export function SkillDetail({ group, projectPath }: SkillDetailProps) { + {skillAudit && } + {skill.tools.length > 0 && (

diff --git a/crates/desktop/src/lib/api.ts b/crates/desktop/src/lib/api.ts index ce694aa0..d28031d9 100644 --- a/crates/desktop/src/lib/api.ts +++ b/crates/desktop/src/lib/api.ts @@ -3,6 +3,8 @@ import type { AgentAvailabilityDto, AgentInfo, AgentProviderResponse, + AuditReportDto, + AuditRequest, CCMarketplaceAddRequest, CCMarketplaceListResponse, CCMarketplaceMutationResponse, @@ -203,6 +205,11 @@ export function createApi(baseUrl: string, token: string) { .post("skills/install", { json: data, timeout: 300000 }) .json(); }, + audit(data: AuditRequest): Promise { + return client + .post("skills/audit", { json: data, timeout: 60000 }) + .json(); + }, delete( agent: string, name: string, diff --git a/crates/desktop/src/lib/locales/en.ts b/crates/desktop/src/lib/locales/en.ts index cd5bb06f..04420c51 100644 --- a/crates/desktop/src/lib/locales/en.ts +++ b/crates/desktop/src/lib/locales/en.ts @@ -576,6 +576,35 @@ export default { selectFileOrFolder: "Select a file or folder containing SKILL.md", skillImported: "Skill imported successfully", importError: "Failed to import skill: {{error}}", + installAnyway: "Install anyway", + auditBlockedHint: + "This skill did not pass the security audit. Review the findings, then confirm to install anyway.", + auditing: "Auditing", + auditFailed: + "Security audit could not be completed. Import is blocked until it passes — or confirm below to import anyway.", + auditVerdictBenign: "Benign", + auditVerdictSuspicious: "Suspicious", + auditVerdictMalicious: "Malicious", + auditSeverityInfo: "Info", + auditSeverityLow: "Low", + auditSeverityMedium: "Medium", + auditSeverityHigh: "High", + auditSeverityCritical: "Critical", + auditCategoryCredentialExfil: "Credential exfiltration", + auditCategoryDataExfil: "Data exfiltration", + auditCategoryCommandInjection: "Command injection", + auditCategoryPromptInjection: "Prompt injection", + auditCategoryToolChaining: "Tool chaining", + auditCategoryPersistence: "Persistence", + auditCategoryHostTamper: "Host tampering", + auditCategoryObfuscation: "Obfuscation", + auditCategoryOther: "Other", + auditSummaryBenign: "No suspicious behavior found", + auditSummarySuspicious: + "Suspicious behavior found — review the findings below", + auditSummaryMalicious: "Malicious behavior found — install with caution", + auditFindingCount_one: "{{count}} finding", + auditFindingCount_other: "{{count}} findings", selectedPath: "Selected Path", file: "File", folder: "Folder", diff --git a/crates/desktop/src/lib/locales/zh-Hans.ts b/crates/desktop/src/lib/locales/zh-Hans.ts index 3adfa9a8..964a47d0 100644 --- a/crates/desktop/src/lib/locales/zh-Hans.ts +++ b/crates/desktop/src/lib/locales/zh-Hans.ts @@ -556,6 +556,83 @@ export default { selectFileOrFolder: "选择包含 SKILL.md 的文件或文件夹", skillImported: "技能导入成功", importError: "技能导入失败: {{error}}", + installAnyway: "仍要安装", + auditBlockedHint: "该技能未通过安全审计。请查看下列发现,确认后仍要安装。", + auditing: "审计中", + auditFailed: + "安全审计未能完成。在审计通过前无法导入,或勾选下方确认后仍要导入。", + auditVerdictBenign: "良性", + auditVerdictSuspicious: "可疑", + auditVerdictMalicious: "恶意", + auditSeverityInfo: "信息", + auditSeverityLow: "低", + auditSeverityMedium: "中", + auditSeverityHigh: "高", + auditSeverityCritical: "严重", + auditCategoryCredentialExfil: "凭据外泄", + auditCategoryDataExfil: "数据外泄", + auditCategoryCommandInjection: "命令注入", + auditCategoryPromptInjection: "提示注入", + auditCategoryToolChaining: "工具链滥用", + auditCategoryPersistence: "持久化", + auditCategoryHostTamper: "主机篡改", + auditCategoryObfuscation: "混淆", + auditCategoryOther: "其他", + auditSummaryBenign: "未发现可疑行为", + auditSummarySuspicious: "发现可疑行为,建议查看下方详情", + auditSummaryMalicious: "发现恶意行为,请谨慎安装", + auditFindingCount_other: "{{count}} 项", + auditEvidence_aghub_credential_file_exfil: + "读取 .ssh/.env/凭据文件并通过网络外发", + auditEvidence_aghub_download_pipe_execute: + "下载远程脚本并直接管道进 shell 执行", + auditEvidence_aghub_reverse_shell: + "建立反向 shell(socket 连接 + dup2/exec)", + auditEvidence_aghub_raw_ip_payload: "连接或从硬编码的裸 IP 地址拉取内容", + auditEvidence_aghub_external_payload_instruction: + "文档指示下载并运行外部二进制/脚本", + auditEvidence_aghub_known_exfil_host: + "引用了已知的一次性外发/粘贴/webhook 主机", + auditEvidence_aghub_reads_secret: "读取凭据文件或窃取密钥/环境变量", + auditEvidence_aghub_network_egress: "向网络端点发送数据", + auditEvidence_clawhub_host_platform_tamper: + "修改 agent 自身源码并重新构建(供应链自感染)", + auditEvidence_clawhub_memory_credential_storage: + "指示 agent 把令牌/密钥存进记忆或对话", + auditEvidence_clawhub_remote_recipe_fetch: + "运行时从远程端点拉取可变指令/配方", + auditEvidence_clawhub_mnemonic_argv: "把助记词/私钥作为命令行参数传递", + auditEvidence_clawhub_confirmation_bypass: + "用魔术令牌跳过危险命令的人工确认", + auditEvidence_clawhub_autonomous_answer_egress: + "自主循环向应答/提现端点发送数据", + auditEvidence_autonomy_abuse_generic: "绕过用户控制的无界自主行为", + auditEvidence_prompt_injection_unicode_steganography: + "隐藏的 Unicode 字符,用于隐形提示注入/隐写", + auditEvidence_sql_injection_generic: + "SQL 注入特征(关键字、永真式、数据库函数)", + auditEvidence_script_injection_generic: "恶意脚本注入特征", + auditEvidence_tool_chaining_abuse_generic: + "可疑的工具链调用,可能导致数据外泄", + auditEvidence_prompt_injection_generic: + "用于覆盖或强制恶意工具调用的提示词", + auditEvidence_command_injection_generic: + "命令注入特征(shell 操作符、系统命令、网络工具)", + auditEvidence_system_manipulation_generic: "系统篡改、提权与破坏性文件操作", + auditEvidence_capability_inflation_generic: + "通过能力膨胀操纵 skill 发现协议", + auditEvidence_code_execution_generic: "对不可信输入执行危险代码", + auditEvidence_embedded_elf_binary: "skill 包内嵌入 ELF 可执行文件头", + auditEvidence_embedded_pe_executable: + "skill 包内嵌入 PE(Windows)可执行文件头", + auditEvidence_embedded_macho_binary: + "skill 包内嵌入 Mach-O(macOS)可执行文件头", + auditEvidence_embedded_shebang_in_binary: "二进制内容中嵌入 shebang 脚本头", + auditEvidence_credential_harvesting_generic: + "可能泄露 API key、密码、令牌、证书等敏感信息", + auditEvidence_indirect_prompt_injection_generic: + "通过外部来源的指令操纵进行间接提示注入", + auditEvidence_coercive_injection_generic: "工具描述字段中的胁迫式提示注入", selectedPath: "已选路径", file: "文件", folder: "文件夹", diff --git a/crates/desktop/src/lib/locales/zh-Hant.ts b/crates/desktop/src/lib/locales/zh-Hant.ts index 2b775d77..ea958583 100644 --- a/crates/desktop/src/lib/locales/zh-Hant.ts +++ b/crates/desktop/src/lib/locales/zh-Hant.ts @@ -555,6 +555,83 @@ export default { selectFileOrFolder: "選擇包含 SKILL.md 的檔案或資料夾", skillImported: "技能匯入成功", importError: "技能匯入失敗:{{error}}", + installAnyway: "仍要安裝", + auditBlockedHint: "此技能未通過安全審計。請查看下列發現,確認後仍要安裝。", + auditing: "審計中", + auditFailed: + "安全審計未能完成。在審計通過前無法匯入,或勾選下方確認後仍要匯入。", + auditVerdictBenign: "良性", + auditVerdictSuspicious: "可疑", + auditVerdictMalicious: "惡意", + auditSeverityInfo: "資訊", + auditSeverityLow: "低", + auditSeverityMedium: "中", + auditSeverityHigh: "高", + auditSeverityCritical: "嚴重", + auditCategoryCredentialExfil: "憑證外洩", + auditCategoryDataExfil: "資料外洩", + auditCategoryCommandInjection: "命令注入", + auditCategoryPromptInjection: "提示注入", + auditCategoryToolChaining: "工具鏈濫用", + auditCategoryPersistence: "持久化", + auditCategoryHostTamper: "主機竄改", + auditCategoryObfuscation: "混淆", + auditCategoryOther: "其他", + auditSummaryBenign: "未發現可疑行為", + auditSummarySuspicious: "發現可疑行為,建議查看下方詳情", + auditSummaryMalicious: "發現惡意行為,請謹慎安裝", + auditFindingCount_other: "{{count}} 項", + auditEvidence_aghub_credential_file_exfil: + "讀取 .ssh/.env/憑證檔案並透過網路外發", + auditEvidence_aghub_download_pipe_execute: + "下載遠端腳本並直接管道進 shell 執行", + auditEvidence_aghub_reverse_shell: + "建立反向 shell(socket 連線 + dup2/exec)", + auditEvidence_aghub_raw_ip_payload: "連線或從硬編碼的裸 IP 位址拉取內容", + auditEvidence_aghub_external_payload_instruction: + "文件指示下載並執行外部二進位/腳本", + auditEvidence_aghub_known_exfil_host: + "引用了已知的一次性外發/貼上/webhook 主機", + auditEvidence_aghub_reads_secret: "讀取憑證檔案或竊取金鑰/環境變數", + auditEvidence_aghub_network_egress: "向網路端點傳送資料", + auditEvidence_clawhub_host_platform_tamper: + "修改 agent 自身原始碼並重新建置(供應鏈自感染)", + auditEvidence_clawhub_memory_credential_storage: + "指示 agent 把權杖/金鑰存進記憶或對話", + auditEvidence_clawhub_remote_recipe_fetch: + "執行時從遠端端點拉取可變指令/配方", + auditEvidence_clawhub_mnemonic_argv: "把助記詞/私鑰作為命令列參數傳遞", + auditEvidence_clawhub_confirmation_bypass: + "用魔術權杖跳過危險命令的人工確認", + auditEvidence_clawhub_autonomous_answer_egress: + "自主迴圈向應答/提現端點傳送資料", + auditEvidence_autonomy_abuse_generic: "繞過使用者控制的無界自主行為", + auditEvidence_prompt_injection_unicode_steganography: + "隱藏的 Unicode 字元,用於隱形提示注入/隱寫", + auditEvidence_sql_injection_generic: + "SQL 注入特徵(關鍵字、永真式、資料庫函式)", + auditEvidence_script_injection_generic: "惡意腳本注入特徵", + auditEvidence_tool_chaining_abuse_generic: + "可疑的工具鏈呼叫,可能導致資料外洩", + auditEvidence_prompt_injection_generic: + "用於覆寫或強制惡意工具呼叫的提示詞", + auditEvidence_command_injection_generic: + "命令注入特徵(shell 運算子、系統命令、網路工具)", + auditEvidence_system_manipulation_generic: "系統竄改、提權與破壞性檔案操作", + auditEvidence_capability_inflation_generic: + "透過能力膨脹操縱 skill 探索協定", + auditEvidence_code_execution_generic: "對不可信輸入執行危險程式碼", + auditEvidence_embedded_elf_binary: "skill 套件內嵌入 ELF 可執行檔頭", + auditEvidence_embedded_pe_executable: + "skill 套件內嵌入 PE(Windows)可執行檔頭", + auditEvidence_embedded_macho_binary: + "skill 套件內嵌入 Mach-O(macOS)可執行檔頭", + auditEvidence_embedded_shebang_in_binary: "二進位內容中嵌入 shebang 腳本頭", + auditEvidence_credential_harvesting_generic: + "可能洩漏 API key、密碼、權杖、憑證等敏感資訊", + auditEvidence_indirect_prompt_injection_generic: + "透過外部來源的指令操縱進行間接提示注入", + auditEvidence_coercive_injection_generic: "工具描述欄位中的脅迫式提示注入", selectedPath: "已選路徑", file: "檔案", folder: "資料夾", diff --git a/crates/desktop/src/pages/skills-sh/components/install-modal.tsx b/crates/desktop/src/pages/skills-sh/components/install-modal.tsx index 264d4815..9b1368f1 100644 --- a/crates/desktop/src/pages/skills-sh/components/install-modal.tsx +++ b/crates/desktop/src/pages/skills-sh/components/install-modal.tsx @@ -3,8 +3,9 @@ import { useTranslation } from "react-i18next"; import { AgentSelector } from "../../../components/agent-selector"; import { InstallTargetSelector } from "../../../components/install-target-selector"; import { ResultStatusItem } from "../../../components/result-status-item"; +import { SkillAudit } from "../../../components/skill-audit"; import { SkillInfoCard } from "../../../components/skill-info-card"; -import type { MarketSkill } from "../../../generated/dto"; +import type { AuditReportDto, MarketSkill } from "../../../generated/dto"; import type { InstallResult } from "../../../lib/install-utils"; import type { Project } from "../../../lib/store"; @@ -26,8 +27,11 @@ interface InstallModalProps { selectedProjectId: string | null; onSelectedProjectIdChange: (id: string | null) => void; projects: Project[]; + audit: AuditReportDto | null; + auditBlocked: boolean; onClose: () => void; onInstall: () => void; + onForceInstall: () => void; } export function InstallModal({ @@ -46,11 +50,19 @@ export function InstallModal({ selectedProjectId, onSelectedProjectIdChange, projects, + audit, + auditBlocked, onClose, onInstall, + onForceInstall, }: InstallModalProps) { const { t } = useTranslation(); + // View precedence: audit-blocked → results → agent picker. + const showBlocked = auditBlocked; + const showResults = !showBlocked && installResults.length > 0; + const showPicker = !showBlocked && !showResults; + return ( @@ -71,7 +83,7 @@ export function InstallModal({ /> )} - {installResults.length === 0 && ( + {showPicker && (

{t("selectAgentsForSkill")} @@ -121,8 +133,18 @@ export function InstallModal({

)} - {installResults.length > 0 && ( + {showBlocked && audit && (
+

+ {t("auditBlockedHint")} +

+ +
+ )} + + {showResults && ( +
+ {audit && } {installResults.map((result) => ( - {installResults.length === 0 && ( + {showPicker && ( <> )} - {installResults.length > 0 && ( + {showBlocked && ( + <> + + + + )} + {showResults && ( diff --git a/crates/desktop/src/pages/skills-sh/hooks/use-skill-install.ts b/crates/desktop/src/pages/skills-sh/hooks/use-skill-install.ts index cc678609..ddbcc479 100644 --- a/crates/desktop/src/pages/skills-sh/hooks/use-skill-install.ts +++ b/crates/desktop/src/pages/skills-sh/hooks/use-skill-install.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { MarketSkill } from "../../../generated/dto"; +import type { AuditReportDto, MarketSkill } from "../../../generated/dto"; import { useAgentAvailability } from "../../../hooks/use-agent-availability"; import { useApi } from "../../../hooks/use-api"; import { useInstallTarget } from "../../../hooks/use-install-target"; @@ -45,6 +45,11 @@ export function useSkillInstall() { const [installResults, setInstallResults] = useState([]); const [isInstalling, setIsInstalling] = useState(false); const [installAll, setInstallAll] = useState(false); + const [audit, setAudit] = useState(null); + const [auditBlocked, setAuditBlocked] = useState(false); + const [pendingSessionId, setPendingSessionId] = useState( + null, + ); const skillAgents = availableAgents.filter( (a) => @@ -57,16 +62,28 @@ export function useSkillInstall() { setSelectedAgents(new Set()); setInstallResults([]); setInstallAll(false); + setAudit(null); + setAuditBlocked(false); + setPendingSessionId(null); resetInstallTarget(); setInstallModalOpen(true); }; - const handleInstall = async () => { + // One install pass: clone → audit → install. The audit report always comes + // back and is shown in the flow; a Suspicious/Malicious verdict blocks the + // install (with a session_id), and the "install anyway" retry calls this + // again with force + that session_id so the already-cloned source is + // reused instead of re-cloned. + const runInstall = async ( + forceUnsafe: boolean, + sessionId: string | null, + ) => { if (!selectedSkill) return; if (selectedAgents.size === 0) return; if (installToProject && !selectedProjectId) return; setIsInstalling(true); + setAuditBlocked(false); const pendingResults = buildPendingResults( selectedAgents, @@ -82,8 +99,20 @@ export function useSkillInstall() { scope: installToProject ? "project" : "global", project_path: selectedProject?.path ?? null, install_all: installAll, + force_unsafe: forceUnsafe, + session_id: sessionId, }); + setAudit(response.audit ?? null); + + if (response.audit_blocked) { + setAuditBlocked(true); + setPendingSessionId(response.session_id ?? null); + setInstallResults([]); + setIsInstalling(false); + return; + } + const updatedResults = pendingResults.map((result) => ({ ...result, status: (response.success ? "success" : "error") as @@ -115,12 +144,18 @@ export function useSkillInstall() { setIsInstalling(false); }; + const handleInstall = () => runInstall(false, null); + const handleForceInstall = () => runInstall(true, pendingSessionId); + const handleCloseInstallModal = () => { setInstallModalOpen(false); setSelectedSkill(null); setSelectedAgents(new Set()); setInstallResults([]); setInstallAll(false); + setAudit(null); + setAuditBlocked(false); + setPendingSessionId(null); resetInstallTarget(); }; @@ -140,8 +175,11 @@ export function useSkillInstall() { selectedProjectId, setSelectedProjectId, projects, + audit, + auditBlocked, handleInstallClick, handleInstall, + handleForceInstall, handleCloseInstallModal, }; } diff --git a/crates/desktop/src/pages/skills-sh/search.tsx b/crates/desktop/src/pages/skills-sh/search.tsx index 63f6d088..58402aaf 100644 --- a/crates/desktop/src/pages/skills-sh/search.tsx +++ b/crates/desktop/src/pages/skills-sh/search.tsx @@ -66,8 +66,11 @@ export default function SkillsSearchPage() { selectedProjectId, setSelectedProjectId, projects, + audit, + auditBlocked, handleInstallClick, handleInstall, + handleForceInstall, handleCloseInstallModal, } = useSkillInstall(); @@ -249,8 +252,11 @@ export default function SkillsSearchPage() { selectedProjectId={selectedProjectId} onSelectedProjectIdChange={setSelectedProjectId} projects={projects} + audit={audit} + auditBlocked={auditBlocked} onClose={handleCloseInstallModal} onInstall={handleInstall} + onForceInstall={handleForceInstall} />
); diff --git a/crates/desktop/src/requests/keys.ts b/crates/desktop/src/requests/keys.ts index ddfe7c8a..95f7a8ab 100644 --- a/crates/desktop/src/requests/keys.ts +++ b/crates/desktop/src/requests/keys.ts @@ -30,6 +30,7 @@ export const queryKeys = { scope: "global" | "project" | "all" = "global", projectRoot?: string, ) => ["skills", "tree", path, scope, projectRoot ?? null] as const, + audit: (path: string) => ["skills", "audit", path] as const, lock: { all: () => ["skills", "lock"] as const, global: () => ["skills", "lock", "global"] as const, diff --git a/crates/desktop/src/requests/skills.ts b/crates/desktop/src/requests/skills.ts index 1d479a26..ed1de156 100644 --- a/crates/desktop/src/requests/skills.ts +++ b/crates/desktop/src/requests/skills.ts @@ -110,6 +110,20 @@ export function skillContentQueryOptions({ }); } +export function skillAuditQueryOptions({ + api, + path, + enabled = true, + staleTime = 60_000, +}: SkillPathQueryParams) { + return queryOptions({ + queryKey: queryKeys.skills.audit(path ?? ""), + queryFn: () => api.skills.audit({ path: path! }), + enabled: enabled && Boolean(path), + staleTime, + }); +} + export function skillTreeQueryOptions({ api, path,