Skip to content
Open
6 changes: 5 additions & 1 deletion crates/desktop/src/components/deep-link-import-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -151,7 +153,9 @@ export function DeepLinkImportModal({
| "error",
error: response.success
? undefined
: t("skillInstallFailed"),
: ((response.audit_blocked
? response.audit?.summary
: undefined) ?? t("skillInstallFailed")),
}));
}

Expand Down
175 changes: 99 additions & 76 deletions crates/desktop/src/components/import-github-skill-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -721,93 +722,115 @@ export function ImportGithubSkillPanel({
) : (
<div className="space-y-2">
{scannedSkills.map((skill) => (
<button
<div
key={skill.path}
type="button"
onClick={() => {
if (
phase === "selecting" &&
!isBranchSwitching
)
togglePath(skill.path);
}}
disabled={
phase !== "selecting" ||
isBranchSwitching
}
className="flex w-full items-start gap-3 rounded-lg border border-border p-3 text-left transition-colors hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60 data-[selected=true]:border-accent/30 data-[selected=true]:bg-accent/5"
data-selected={selectedPaths.has(
skill.path,
)}
className="space-y-1.5"
>
<Checkbox
isSelected={selectedPaths.has(
skill.path,
)}
isDisabled={
<button
type="button"
onClick={() => {
if (
phase ===
"selecting" &&
!isBranchSwitching
)
togglePath(
skill.path,
);
}}
disabled={
phase !== "selecting" ||
isBranchSwitching
}
onChange={() =>
togglePath(skill.path)
}
aria-label={skill.name}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
</Checkbox>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<BookOpenIcon className="size-4 shrink-0 text-muted" />
<span className="font-medium text-foreground">
{skill.name}
</span>
{skill.version && (
<Chip
size="sm"
variant="secondary"
>
v{skill.version}
</Chip>
)}
{skill.author && (
<Chip
size="sm"
variant="secondary"
>
{skill.author}
</Chip>
)}
</div>
{skill.description && (
<p className="mt-1 text-sm text-muted">
{skill.description}
</p>
className="flex w-full items-start gap-3 rounded-lg border border-border p-3 text-left transition-colors hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60 data-[selected=true]:border-accent/30 data-[selected=true]:bg-accent/5"
data-selected={selectedPaths.has(
skill.path,
)}
</div>
<div
onClick={(e) =>
e.stopPropagation()
}
>
<Button
variant="ghost"
size="sm"
isIconOnly
aria-label={t(
"description",
<Checkbox
isSelected={selectedPaths.has(
skill.path,
)}
onPress={() =>
setPreviewSkill(
skill,
isDisabled={
phase !==
"selecting" ||
isBranchSwitching
}
onChange={() =>
togglePath(
skill.path,
)
}
aria-label={skill.name}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
</Checkbox>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<BookOpenIcon className="size-4 shrink-0 text-muted" />
<span className="font-medium text-foreground">
{skill.name}
</span>
{skill.version && (
<Chip
size="sm"
variant="secondary"
>
v
{
skill.version
}
</Chip>
)}
{skill.author && (
<Chip
size="sm"
variant="secondary"
>
{
skill.author
}
</Chip>
)}
</div>
{skill.description && (
<p className="mt-1 text-sm text-muted">
{
skill.description
}
</p>
)}
</div>
<div
onClick={(e) =>
e.stopPropagation()
}
>
<EyeIcon className="size-4" />
</Button>
</div>
</button>
<Button
variant="ghost"
size="sm"
isIconOnly
aria-label={t(
"description",
)}
onPress={() =>
setPreviewSkill(
skill,
)
}
>
<EyeIcon className="size-4" />
</Button>
</div>
</button>
{skill.audit && (
<SkillAudit
report={skill.audit}
/>
)}
</div>
))}
</div>
)}
Expand Down
62 changes: 57 additions & 5 deletions crates/desktop/src/components/import-skill-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,30 @@ import {
Alert,
Button,
Card,
Checkbox,
FieldError,
Fieldset,
Form,
Input,
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;
Expand Down Expand Up @@ -56,6 +61,7 @@ export function ImportSkillPanel({
);

const [error, setError] = useState<string | null>(null);
const [forceUnsafe, setForceUnsafe] = useState(false);

const {
control,
Expand Down Expand Up @@ -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(),
};
Expand Down Expand Up @@ -125,6 +150,7 @@ export function ImportSkillPanel({
shouldDirty: true,
shouldValidate: true,
});
setForceUnsafe(false);
}
};

Expand All @@ -135,6 +161,7 @@ export function ImportSkillPanel({
shouldDirty: true,
shouldValidate: true,
});
setForceUnsafe(false);
}
};

Expand Down Expand Up @@ -238,6 +265,19 @@ export function ImportSkillPanel({
</Fieldset.Group>
</Fieldset>

{skillAudit && <SkillAudit report={skillAudit} />}

{auditFailed && (
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>
{t("auditFailed")}
</Alert.Description>
</Alert.Content>
</Alert>
)}

<Fieldset>
<Fieldset.Group>
<Controller
Expand Down Expand Up @@ -273,6 +313,15 @@ export function ImportSkillPanel({
</Fieldset.Group>
</Fieldset>

{canForceUnsafe && (
<Checkbox
isSelected={forceUnsafe}
onChange={setForceUnsafe}
>
{t("installAnyway")}
</Checkbox>
)}

<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
Expand All @@ -286,12 +335,15 @@ export function ImportSkillPanel({
isDisabled={
importMutation.isPending ||
isSubmitting ||
skillAgents.length === 0
skillAgents.length === 0 ||
auditBlocked

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block local import while audit is pending

When a user selects a local skill and clicks Import before skillAuditQueryOptions returns, skillAudit is still undefined, so auditBlocked is false and this button stays enabled. The submit path then calls the existing import_skill API, which only runs add_skill_from_path and does not re-audit, so a malicious local skill can bypass the new gate during that pending/error window; disable submission until the audit has completed successfully or enforce the audit server-side.

Useful? React with 👍 / 👎.

}
>
{importMutation.isPending
? t("importing")
: t("import")}
: auditPending
? t("auditing")
: t("import")}
</Button>
</div>
</Form>
Expand Down
Loading