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")}
+
+ )}
+
{importMutation.isPending
? t("importing")
- : t("import")}
+ : auditPending
+ ? t("auditing")
+ : t("import")}
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 (
+
+
+ hasFindings && setExpanded((v) => !v)}
+ aria-expanded={hasFindings ? expanded : undefined}
+ >
+
+
+ {t(VERDICT_LABEL_KEY[report.verdict])}
+
+
+ {t(VERDICT_SUMMARY_KEY[report.verdict])}
+
+ {hasFindings && (
+
+ {t("auditFindingCount", { count: findingCount })}
+
+ )}
+ {hasFindings &&
+ (expanded ? (
+
+ ) : (
+
+ ))}
+
+
+ {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 && (
<>
{t("cancel")}
@@ -162,7 +184,22 @@ export function InstallModal({
>
)}
- {installResults.length > 0 && (
+ {showBlocked && (
+ <>
+
+ {t("cancel")}
+
+
+ {isInstalling
+ ? t("installing")
+ : t("installAnyway")}
+
+ >
+ )}
+ {showResults && (
{t("done")}
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,