mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: add skill version revocation (#3049)
This commit is contained in:
@@ -56,6 +56,7 @@ skills|skill
|
||||
|
||||
```text
|
||||
unhide <slug>
|
||||
revoke-version <slug>
|
||||
rescan <slug>
|
||||
reports
|
||||
triage-report <report-id>
|
||||
@@ -65,11 +66,14 @@ Examples:
|
||||
|
||||
```sh
|
||||
bun run admin -- skills unhide <slug> --reason "<reason>" --yes
|
||||
bun run admin -- skills revoke-version <slug> --version <version> --reason "<reason>" --yes
|
||||
bun run admin -- skills rescan <slug> --reason "<reason>" --yes
|
||||
bun run admin -- skills reports --status open
|
||||
bun run admin -- skills triage-report <report-id> --status confirmed --action hide --note "<note>" --yes
|
||||
```
|
||||
|
||||
Pass `--owner <handle>` to `revoke-version` when more than one publisher uses the same slug.
|
||||
|
||||
### Users
|
||||
|
||||
`bun run admin -- users --help` exposes:
|
||||
@@ -201,6 +205,9 @@ only after admin auth succeeds.
|
||||
- `skills unhide` is a moderator manual restore. It clears skill hidden state,
|
||||
applies a clean manual override to top-level moderation fields, preserves
|
||||
version-level scanner records, updates public stats, and writes audit logs.
|
||||
- `skills revoke-version` permanently removes one exact version, records staff
|
||||
evidence, advances latest pointers to a safe survivor when one exists, and
|
||||
keeps the skill independently hidden when no usable version remains.
|
||||
- There is no standalone `skills hide` command in `clawhub-admin`; use report
|
||||
triage with `--action hide` when resolving a report that should hide a skill.
|
||||
- `users ban` is disruptive: it revokes API tokens, marks the user deleted,
|
||||
|
||||
@@ -152,6 +152,60 @@ describe("downloads helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 410 for an explicitly requested revoked version", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
slug: "demo",
|
||||
tags: {},
|
||||
latestVersionId: undefined,
|
||||
},
|
||||
moderationInfo: null,
|
||||
};
|
||||
}
|
||||
if ("version" in args) {
|
||||
return {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 3,
|
||||
files: [{ path: "SKILL.md", storageId: "_storage:1" }],
|
||||
softDeletedAt: 123,
|
||||
manualRevocation: {
|
||||
reason: "confirmed unsafe artifact",
|
||||
reviewerUserId: "users:moderator",
|
||||
revokedAt: 123,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return null;
|
||||
});
|
||||
const runAfter = vi.fn();
|
||||
const storageGet = vi.fn();
|
||||
|
||||
const response = await downloadZipHandler(
|
||||
{
|
||||
runQuery,
|
||||
runMutation,
|
||||
scheduler: { runAfter },
|
||||
storage: { get: storageGet },
|
||||
} as unknown as ActionCtx,
|
||||
new Request("https://example.com/api/v1/download?slug=demo&version=1.0.0"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
expect(await response.text()).toBe("Version not available");
|
||||
expect(storageGet).not.toHaveBeenCalled();
|
||||
expect(runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("threads owner handle through the skill lookup", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
|
||||
@@ -3545,6 +3545,79 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("skill version moderation posts exact-version revocations", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
alreadyRevoked: false,
|
||||
replacementVersion: null,
|
||||
skillHidden: true,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0/moderation", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({
|
||||
state: "revoked",
|
||||
reason: "confirmed unsafe artifact",
|
||||
ownerHandle: "publisher",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
version: "1.0.0",
|
||||
skillHidden: true,
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
(internal as unknown as { skills: Record<string, unknown> }).skills
|
||||
.revokeSkillVersionForUserInternal,
|
||||
{
|
||||
actorUserId: "users:moderator",
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
reason: "confirmed unsafe artifact",
|
||||
ownerHandle: "publisher",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported skill version moderation states", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error(`unexpected mutation ${JSON.stringify(args)}`);
|
||||
});
|
||||
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0/moderation", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({ state: "approved", reason: "no longer blocked" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skill appeal posts owner appeal requests", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SkillAppealRequestSchema,
|
||||
SkillAppealResolveRequestSchema,
|
||||
SkillReportTriageRequestSchema,
|
||||
SkillVersionRevokeRequestSchema,
|
||||
normalizeTextContentType,
|
||||
parseArk,
|
||||
type SkillAppealListStatus,
|
||||
@@ -346,6 +347,7 @@ const internalRefs = internal as unknown as {
|
||||
};
|
||||
skills: {
|
||||
deleteOwnedVersionForUserInternal: unknown;
|
||||
revokeSkillVersionForUserInternal: unknown;
|
||||
getSecurityVerdictTargetInternal: unknown;
|
||||
getVerifyTargetBySlugInternal: unknown;
|
||||
getSkillBySlugInternal: unknown;
|
||||
@@ -2885,6 +2887,39 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
|
||||
const action = segments[1] ?? "";
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? "";
|
||||
|
||||
if (
|
||||
segments.length === 4 &&
|
||||
segments[1] === "versions" &&
|
||||
segments[2] &&
|
||||
segments[3] === "moderation"
|
||||
) {
|
||||
if (!slug) return text("Slug required", 400, rate.headers);
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
try {
|
||||
const body = parseArk(
|
||||
SkillVersionRevokeRequestSchema,
|
||||
await request.json(),
|
||||
"Skill version moderation payload",
|
||||
) as { state: "revoked"; reason: string; ownerHandle?: string };
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.skills.revokeSkillVersionForUserInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
slug,
|
||||
version: segments[2],
|
||||
reason: body.reason,
|
||||
...(body.ownerHandle ? { ownerHandle: body.ownerHandle } : {}),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return text("Invalid JSON", 400, rate.headers);
|
||||
return skillVersionModerationErrorToResponse(error, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments[0] === "-" && segments[1] === "repair-vt-pending" && segments.length === 2) {
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
@@ -3208,6 +3243,15 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
function skillVersionModerationErrorToResponse(error: unknown, headers: HeadersInit) {
|
||||
const message = error instanceof Error ? error.message : "Skill version moderation failed";
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("unauthorized")) return text(message, 401, headers);
|
||||
if (lower.includes("forbidden")) return text(message, 403, headers);
|
||||
if (lower.includes("not found")) return text(message, 404, headers);
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
|
||||
function skillRescanErrorToResponse(error: unknown, headers: HeadersInit) {
|
||||
const message = error instanceof Error ? error.message : "Skill rescan failed";
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
@@ -1026,6 +1026,13 @@ const skillVersions = defineTable({
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
ownerDeletedAt: v.optional(v.number()),
|
||||
ownerDeletedBy: v.optional(v.id("users")),
|
||||
manualRevocation: v.optional(
|
||||
v.object({
|
||||
reason: v.string(),
|
||||
reviewerUserId: v.id("users"),
|
||||
revokedAt: v.number(),
|
||||
}),
|
||||
),
|
||||
sha256hash: v.optional(v.string()),
|
||||
vtAnalysis: v.optional(vtAnalysisValidator),
|
||||
skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildSkillVersionRevocationPlan, revokeSkillVersionForUser } from "./skills";
|
||||
|
||||
function makeVersion(id: string, version: string, createdAt: number) {
|
||||
return {
|
||||
_id: id,
|
||||
skillId: "skills:demo",
|
||||
version,
|
||||
changelog: `${version} changes`,
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: {
|
||||
name: `Demo ${version}`,
|
||||
description: `Description ${version}`,
|
||||
},
|
||||
clawdis: { version },
|
||||
},
|
||||
icon: `lucide:${version}`,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkill() {
|
||||
return {
|
||||
_id: "skills:demo",
|
||||
slug: "demo",
|
||||
displayName: "Demo 2.0.0",
|
||||
summary: "Description 2.0.0",
|
||||
icon: "lucide:2.0.0",
|
||||
latestVersionId: "skillVersions:v2",
|
||||
latestVersionSummary: {
|
||||
version: "2.0.0",
|
||||
createdAt: 20,
|
||||
changelog: "2.0.0 changes",
|
||||
},
|
||||
tags: {
|
||||
latest: "skillVersions:v2",
|
||||
stable: "skillVersions:v2",
|
||||
legacy: "skillVersions:v1",
|
||||
},
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "user.banned",
|
||||
softDeletedAt: 100,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSkillVersionRevocationPlan", () => {
|
||||
it("moves latest pointers to the highest available replacement without lifting a parent hold", () => {
|
||||
const plan = buildSkillVersionRevocationPlan({
|
||||
actorUserId: "users:moderator" as never,
|
||||
skill: makeSkill() as never,
|
||||
target: makeVersion("skillVersions:v2", "2.0.0", 20) as never,
|
||||
replacement: makeVersion("skillVersions:v1", "1.0.0", 10) as never,
|
||||
reason: "confirmed unsafe artifact",
|
||||
now: 200,
|
||||
});
|
||||
|
||||
expect(plan.versionPatch).toEqual({
|
||||
softDeletedAt: 200,
|
||||
manualRevocation: {
|
||||
reason: "confirmed unsafe artifact",
|
||||
reviewerUserId: "users:moderator",
|
||||
revokedAt: 200,
|
||||
},
|
||||
});
|
||||
expect(plan.skillPatch).toMatchObject({
|
||||
latestVersionId: "skillVersions:v1",
|
||||
displayName: "Demo 1.0.0",
|
||||
summary: "Description 1.0.0",
|
||||
icon: "lucide:1.0.0",
|
||||
tags: {
|
||||
latest: "skillVersions:v1",
|
||||
legacy: "skillVersions:v1",
|
||||
},
|
||||
});
|
||||
expect(plan.skillPatch).not.toHaveProperty("softDeletedAt");
|
||||
expect(plan.skillPatch).not.toHaveProperty("moderationReason");
|
||||
});
|
||||
|
||||
it("creates an independent hold when the revoked latest has no available replacement", () => {
|
||||
const skill = {
|
||||
...makeSkill(),
|
||||
tags: {
|
||||
latest: "skillVersions:v2",
|
||||
stable: "skillVersions:v2",
|
||||
},
|
||||
};
|
||||
const plan = buildSkillVersionRevocationPlan({
|
||||
actorUserId: "users:moderator" as never,
|
||||
skill: skill as never,
|
||||
target: makeVersion("skillVersions:v2", "2.0.0", 20) as never,
|
||||
replacement: null,
|
||||
reason: "confirmed unsafe artifact",
|
||||
now: 200,
|
||||
});
|
||||
|
||||
expect(plan.skillPatch).toMatchObject({
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: 200,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "manual.version_revoked",
|
||||
moderationNotes: "confirmed unsafe artifact",
|
||||
hiddenAt: 200,
|
||||
hiddenBy: "users:moderator",
|
||||
manualOverride: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not change skill pointers when a non-latest version is revoked", () => {
|
||||
const plan = buildSkillVersionRevocationPlan({
|
||||
actorUserId: "users:moderator" as never,
|
||||
skill: makeSkill() as never,
|
||||
target: makeVersion("skillVersions:v1", "1.0.0", 10) as never,
|
||||
replacement: null,
|
||||
reason: "confirmed unsafe artifact",
|
||||
now: 200,
|
||||
});
|
||||
|
||||
expect(plan.skillPatch).toEqual({
|
||||
tags: {
|
||||
latest: "skillVersions:v2",
|
||||
stable: "skillVersions:v2",
|
||||
},
|
||||
updatedAt: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeSkillVersionForUser", () => {
|
||||
it("rejects non-moderator actors before resolving the skill", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
_id: "users:publisher",
|
||||
role: "user",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
revokeSkillVersionForUser(ctx as never, {
|
||||
actorUserId: "users:publisher" as never,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
reason: "confirmed unsafe artifact",
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
expect(ctx.db.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves already-hidden skills so their exact versions can be revoked before unban", async () => {
|
||||
const hiddenSkill = {
|
||||
_id: "skills:hidden",
|
||||
slug: "hidden-demo",
|
||||
softDeletedAt: 100,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "user.banned",
|
||||
tags: {},
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
_id: "users:moderator",
|
||||
role: "moderator",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([hiddenSkill]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "skillSlugAliases") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
revokeSkillVersionForUser(ctx as never, {
|
||||
actorUserId: "users:moderator" as never,
|
||||
slug: "hidden-demo",
|
||||
version: "1.0.0",
|
||||
reason: "confirmed unsafe artifact",
|
||||
}),
|
||||
).rejects.toThrow("Skill version not found");
|
||||
});
|
||||
});
|
||||
@@ -730,6 +730,32 @@ describe("skills ban/unban batches", () => {
|
||||
expect(patch).not.toHaveBeenCalledWith("skills:removed", expect.anything());
|
||||
});
|
||||
|
||||
it("does not restore an independent exact-version revocation hold", async () => {
|
||||
const { ctx, patch } = makeCtx({
|
||||
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:revoked",
|
||||
ownerUserId: "users:owner",
|
||||
softDeletedAt: 2_000,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "manual.version_revoked",
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
restoreUnbanHandler(ctx, { ownerUserId: "users:owner", bannedAt: 1_000 }),
|
||||
).resolves.toMatchObject({
|
||||
restoredCount: 0,
|
||||
scheduled: false,
|
||||
});
|
||||
|
||||
expect(patch).not.toHaveBeenCalledWith("skills:revoked", expect.anything());
|
||||
});
|
||||
|
||||
it("continues stale retired comment cleanup phases at skill reports", async () => {
|
||||
const { ctx, query, scheduler } = makeCtx({
|
||||
user: { _id: "users:owner", role: "admin" },
|
||||
|
||||
+243
-5
@@ -590,13 +590,11 @@ type SkillVersionOwnerDeleteAvailability = Pick<
|
||||
| "skillId"
|
||||
| "softDeletedAt"
|
||||
| "ownerDeletedAt"
|
||||
| "manualRevocation"
|
||||
| "staticScan"
|
||||
| "vtAnalysis"
|
||||
| "llmAnalysis"
|
||||
> & {
|
||||
// Exact-version moderator revocation lands separately; stay compatible before its schema does.
|
||||
manualRevocation?: unknown;
|
||||
};
|
||||
>;
|
||||
|
||||
function isSkillVersionAvailableForOwnerDeleteSafety(
|
||||
version: SkillVersionOwnerDeleteAvailability | null | undefined,
|
||||
@@ -638,12 +636,102 @@ async function findReplacementLatestSkillVersion(
|
||||
(candidate) =>
|
||||
candidate._id !== quarantinedVersionId &&
|
||||
!candidate.softDeletedAt &&
|
||||
!candidate.manualRevocation &&
|
||||
!isKnownMaliciousSkillVersion(candidate),
|
||||
)
|
||||
.sort(compareSkillVersionsForRestore)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
type SkillVersionRevocationSkill = Pick<
|
||||
Doc<"skills">,
|
||||
| "_id"
|
||||
| "slug"
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "icon"
|
||||
| "latestVersionId"
|
||||
| "latestVersionSummary"
|
||||
| "tags"
|
||||
>;
|
||||
|
||||
type SkillVersionRevocationVersion = Pick<
|
||||
Doc<"skillVersions">,
|
||||
"_id" | "skillId" | "version" | "createdAt" | "changelog" | "changelogSource" | "parsed" | "icon"
|
||||
>;
|
||||
|
||||
export function buildSkillVersionRevocationPlan(params: {
|
||||
actorUserId: Id<"users">;
|
||||
skill: SkillVersionRevocationSkill;
|
||||
target: SkillVersionRevocationVersion;
|
||||
replacement: SkillVersionRevocationVersion | null;
|
||||
reason: string;
|
||||
now: number;
|
||||
}) {
|
||||
const versionPatch: Partial<Doc<"skillVersions">> = {
|
||||
softDeletedAt: params.now,
|
||||
manualRevocation: {
|
||||
reason: params.reason,
|
||||
reviewerUserId: params.actorUserId,
|
||||
revokedAt: params.now,
|
||||
},
|
||||
};
|
||||
const isLatest =
|
||||
params.skill.latestVersionId === params.target._id ||
|
||||
params.skill.tags.latest === params.target._id ||
|
||||
params.skill.latestVersionSummary?.version === params.target.version;
|
||||
const nextTags = Object.fromEntries(
|
||||
Object.entries(params.skill.tags).filter(([, versionId]) => versionId !== params.target._id),
|
||||
) as Doc<"skills">["tags"];
|
||||
|
||||
if (!isLatest) {
|
||||
return {
|
||||
versionPatch,
|
||||
skillPatch: {
|
||||
tags: nextTags,
|
||||
updatedAt: params.now,
|
||||
} satisfies Partial<Doc<"skills">>,
|
||||
isLatest,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.replacement) {
|
||||
nextTags.latest = params.replacement._id;
|
||||
return {
|
||||
versionPatch,
|
||||
skillPatch: {
|
||||
displayName: skillDisplayNameFromSkillVersion(params.replacement) ?? params.skill.slug,
|
||||
summary: skillSummaryFromSkillVersion(params.replacement),
|
||||
icon: skillIconFromSkillVersion(params.replacement) ?? params.skill.icon,
|
||||
latestVersionId: params.replacement._id,
|
||||
latestVersionSummary: latestVersionSummaryFromSkillVersion(params.replacement),
|
||||
tags: nextTags,
|
||||
updatedAt: params.now,
|
||||
} satisfies Partial<Doc<"skills">>,
|
||||
isLatest,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
versionPatch,
|
||||
skillPatch: {
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: nextTags,
|
||||
softDeletedAt: params.now,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "manual.version_revoked",
|
||||
moderationNotes: params.reason,
|
||||
hiddenAt: params.now,
|
||||
hiddenBy: params.actorUserId,
|
||||
manualOverride: undefined,
|
||||
lastReviewedAt: params.now,
|
||||
updatedAt: params.now,
|
||||
} satisfies Partial<Doc<"skills">>,
|
||||
isLatest,
|
||||
};
|
||||
}
|
||||
|
||||
async function clearSkillEmbeddingsLatestVersion(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
@@ -8444,13 +8532,14 @@ async function setSkillEmbeddingsLatestVersion(
|
||||
skillId: Id<"skills">,
|
||||
latestVersionId: Id<"skillVersions">,
|
||||
now: number,
|
||||
skillHidden = false,
|
||||
) {
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId);
|
||||
for (const embedding of embeddings) {
|
||||
const isLatest = embedding.versionId === latestVersionId;
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isLatest,
|
||||
visibility: embeddingVisibilityFor(isLatest, embedding.isApproved),
|
||||
visibility: skillHidden ? "deleted" : embeddingVisibilityFor(isLatest, embedding.isApproved),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
@@ -9615,6 +9704,155 @@ export const deleteOwnedVersionForUserInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export async function revokeSkillVersionForUser(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
actorUserId: Id<"users">;
|
||||
slug: string;
|
||||
version: string;
|
||||
reason: string;
|
||||
ownerHandle?: string;
|
||||
},
|
||||
) {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertModerator(actor);
|
||||
|
||||
const slug = args.slug.trim().toLowerCase();
|
||||
if (!slug) throw new ConvexError("Slug required");
|
||||
const versionName = args.version.trim();
|
||||
if (!versionName) throw new ConvexError("Version required");
|
||||
const reason = trimManualOverrideNote(args.reason);
|
||||
const ownerHandle = args.ownerHandle?.trim().replace(/^@+/, "") || undefined;
|
||||
|
||||
const resolved = await resolveSkillBySlugOrAliasForOwner(ctx, slug, ownerHandle, {
|
||||
includeSoftDeleted: true,
|
||||
});
|
||||
if (resolved.ambiguous) {
|
||||
throw new ConvexError("Slug is used by multiple publishers. Pass an owner handle.");
|
||||
}
|
||||
const skill = resolved.skill;
|
||||
if (!skill) throw new ConvexError("Skill not found");
|
||||
|
||||
const version = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", versionName))
|
||||
.unique();
|
||||
if (!version) throw new ConvexError("Skill version not found");
|
||||
|
||||
if (version.manualRevocation) {
|
||||
return {
|
||||
ok: true as const,
|
||||
slug: skill.slug,
|
||||
version: version.version,
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
alreadyRevoked: true,
|
||||
replacementVersion:
|
||||
skill.latestVersionId === version._id
|
||||
? null
|
||||
: (skill.latestVersionSummary?.version ?? null),
|
||||
skillHidden: Boolean(skill.softDeletedAt),
|
||||
};
|
||||
}
|
||||
|
||||
const isLatest =
|
||||
skill.latestVersionId === version._id ||
|
||||
skill.tags.latest === version._id ||
|
||||
skill.latestVersionSummary?.version === version.version;
|
||||
const replacement = isLatest
|
||||
? await findReplacementLatestSkillVersion(ctx, skill._id, version._id)
|
||||
: null;
|
||||
const now = Date.now();
|
||||
const plan = buildSkillVersionRevocationPlan({
|
||||
actorUserId: actor._id,
|
||||
skill,
|
||||
target: version,
|
||||
replacement,
|
||||
reason,
|
||||
now,
|
||||
});
|
||||
|
||||
if (replacement && !shouldPreserveExistingModerationLock(skill)) {
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
Object.assign(
|
||||
plan.skillPatch,
|
||||
applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch: buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: replacement,
|
||||
now,
|
||||
}),
|
||||
now,
|
||||
stripUpdatedAt: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.db.patch(version._id, plan.versionPatch);
|
||||
const nextSkill = { ...skill, ...plan.skillPatch };
|
||||
await ctx.db.patch(skill._id, plan.skillPatch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (plan.isLatest) {
|
||||
if (replacement) {
|
||||
await setSkillEmbeddingsLatestVersion(
|
||||
ctx,
|
||||
skill._id,
|
||||
replacement._id,
|
||||
now,
|
||||
Boolean(nextSkill.softDeletedAt),
|
||||
);
|
||||
} else {
|
||||
await clearSkillEmbeddingsLatestVersion(ctx, skill._id, now);
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
|
||||
}
|
||||
}
|
||||
await syncSkillSearchDigestForSkillDoc(ctx, nextSkill);
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: actor._id,
|
||||
action: "skill.version.revoke",
|
||||
targetType: "skillVersion",
|
||||
targetId: version._id,
|
||||
metadata: {
|
||||
skillId: skill._id,
|
||||
slug: skill.slug,
|
||||
version: version.version,
|
||||
reason,
|
||||
replacementVersion: replacement?.version ?? null,
|
||||
skillHidden: Boolean(nextSkill.softDeletedAt),
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
slug: skill.slug,
|
||||
version: version.version,
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
alreadyRevoked: false,
|
||||
replacementVersion: replacement?.version ?? null,
|
||||
skillHidden: Boolean(nextSkill.softDeletedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export const revokeSkillVersionForUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
slug: v.string(),
|
||||
version: v.string(),
|
||||
reason: v.string(),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await revokeSkillVersionForUser(ctx, args);
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteOwnedVersion = mutation({
|
||||
args: { versionId: v.id("skillVersions") },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
cmdRecoverPersonalPublisher,
|
||||
cmdReclassifyBan,
|
||||
cmdRepairVtPendingSkills,
|
||||
cmdRevokeSkillVersion,
|
||||
cmdRescanAllSkills,
|
||||
cmdRescanSkill,
|
||||
cmdSetRole,
|
||||
@@ -772,6 +773,20 @@ function registerPluginOperations(command: Command) {
|
||||
}
|
||||
|
||||
function registerSkillModerationCommands(command: Command) {
|
||||
command
|
||||
.command("revoke-version")
|
||||
.description("Permanently remove one skill version from public access")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.requiredOption("--version <version>", "Exact version to revoke")
|
||||
.requiredOption("--reason <reason>", "Audit reason")
|
||||
.option("--owner <handle>", "Owner handle when the slug is shared")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdRevokeSkillVersion(opts, slug, options, isInputAllowed());
|
||||
});
|
||||
|
||||
command
|
||||
.command("unhide")
|
||||
.description("Manually restore a hidden skill after moderator review")
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
cmdRecoverPersonalPublisher,
|
||||
cmdReclassifyBan,
|
||||
cmdRepairVtPendingSkills,
|
||||
cmdRevokeSkillVersion,
|
||||
cmdRescanAllSkills,
|
||||
cmdRescanSkill,
|
||||
cmdSetRole,
|
||||
@@ -201,6 +202,94 @@ describe("cmdRescanSkill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdRevokeSkillVersion", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(
|
||||
cmdRevokeSkillVersion(
|
||||
makeGlobalOpts(),
|
||||
"demo",
|
||||
{ version: "1.0.0", reason: "confirmed unsafe artifact" },
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow(/--yes/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts an exact-version revocation request", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
alreadyRevoked: false,
|
||||
replacementVersion: null,
|
||||
skillHidden: true,
|
||||
});
|
||||
|
||||
const result = await cmdRevokeSkillVersion(
|
||||
makeGlobalOpts(),
|
||||
"Demo",
|
||||
{ version: "1.0.0", reason: "confirmed unsafe artifact", yes: true },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
skillHidden: true,
|
||||
});
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/skills/demo/versions/1.0.0/moderation",
|
||||
token: "tkn",
|
||||
body: { state: "revoked", reason: "confirmed unsafe artifact" },
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes an owner qualifier for duplicate slugs", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
alreadyRevoked: false,
|
||||
replacementVersion: null,
|
||||
skillHidden: true,
|
||||
});
|
||||
|
||||
await cmdRevokeSkillVersion(
|
||||
makeGlobalOpts(),
|
||||
"demo",
|
||||
{
|
||||
version: "1.0.0",
|
||||
reason: "confirmed unsafe artifact",
|
||||
owner: "@publisher",
|
||||
yes: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
state: "revoked",
|
||||
reason: "confirmed unsafe artifact",
|
||||
ownerHandle: "publisher",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdRescanAllSkills", () => {
|
||||
it("requires --yes for real runs when input is disabled", async () => {
|
||||
await expect(cmdRescanAllSkills(makeGlobalOpts(), {}, false)).rejects.toThrow(/--yes/i);
|
||||
|
||||
@@ -20,11 +20,71 @@ import {
|
||||
ApiV1SkillScanBatchStatusResponseSchema,
|
||||
ApiV1SkillScanSubmitResponseSchema,
|
||||
ApiV1SkillRepairVtPendingResponseSchema,
|
||||
ApiV1SkillVersionRevokeResponseSchema,
|
||||
ApiV1UnbanUserResponseSchema,
|
||||
ApiV1UserSearchResponseSchema,
|
||||
parseArk,
|
||||
} from "../../../clawhub/src/schema/index.js";
|
||||
|
||||
export async function cmdRevokeSkillVersion(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: {
|
||||
version?: string;
|
||||
reason?: string;
|
||||
owner?: string;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
},
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase();
|
||||
if (!slug) fail("Skill slug required");
|
||||
const version = options.version?.trim();
|
||||
if (!version) fail("--version required");
|
||||
const reason = options.reason?.trim();
|
||||
if (!reason) fail("--reason required");
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "") || undefined;
|
||||
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false;
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail("Pass --yes (no input)");
|
||||
const ok = await promptConfirm(`Revoke ${slug}@${version}? This version cannot be restored.`);
|
||||
if (!ok) return undefined;
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json ? null : createCrabLoader(`Revoking ${slug}@${version}`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/moderation`,
|
||||
token,
|
||||
body: { state: "revoked", reason, ...(ownerHandle ? { ownerHandle } : {}) },
|
||||
},
|
||||
ApiV1SkillVersionRevokeResponseSchema,
|
||||
);
|
||||
const parsed = parseArk(
|
||||
ApiV1SkillVersionRevokeResponseSchema,
|
||||
result,
|
||||
"Skill version revocation response",
|
||||
);
|
||||
spinner?.succeed(
|
||||
parsed.alreadyRevoked
|
||||
? `OK. ${slug}@${version} was already revoked`
|
||||
: `OK. Revoked ${slug}@${version}`,
|
||||
);
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdBanUser(
|
||||
opts: GlobalOpts,
|
||||
identifierArg: string,
|
||||
|
||||
@@ -473,6 +473,26 @@ export const ApiV1SkillModerationResponseSchema = type({
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
export const SkillVersionRevokeRequestSchema = type({
|
||||
state: '"revoked"',
|
||||
reason: "string",
|
||||
ownerHandle: "string?",
|
||||
});
|
||||
export type SkillVersionRevokeRequest = (typeof SkillVersionRevokeRequestSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillVersionRevokeResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
versionId: "string",
|
||||
alreadyRevoked: "boolean",
|
||||
replacementVersion: "string|null",
|
||||
skillHidden: "boolean",
|
||||
});
|
||||
export type ApiV1SkillVersionRevokeResponse =
|
||||
(typeof ApiV1SkillVersionRevokeResponseSchema)[inferred];
|
||||
|
||||
export const SkillReportStatusSchema = type('"open"|"confirmed"|"dismissed"');
|
||||
export type SkillReportStatus = (typeof SkillReportStatusSchema)[inferred];
|
||||
export const SkillReportFinalActionSchema = type('"none"|"hide"');
|
||||
|
||||
Vendored
+17
@@ -347,6 +347,23 @@ export declare const ApiV1SkillModerationResponseSchema: import("arktype/interna
|
||||
legacyReason?: string | null | undefined;
|
||||
} | null;
|
||||
}, {}>;
|
||||
export declare const SkillVersionRevokeRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
state: "revoked";
|
||||
reason: string;
|
||||
ownerHandle?: string | undefined;
|
||||
}, {}>;
|
||||
export type SkillVersionRevokeRequest = (typeof SkillVersionRevokeRequestSchema)[inferred];
|
||||
export declare const ApiV1SkillVersionRevokeResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
slug: string;
|
||||
version: string;
|
||||
skillId: string;
|
||||
versionId: string;
|
||||
alreadyRevoked: boolean;
|
||||
replacementVersion: string | null;
|
||||
skillHidden: boolean;
|
||||
}, {}>;
|
||||
export type ApiV1SkillVersionRevokeResponse = (typeof ApiV1SkillVersionRevokeResponseSchema)[inferred];
|
||||
export declare const SkillReportStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "confirmed" | "dismissed", {}>;
|
||||
export type SkillReportStatus = (typeof SkillReportStatusSchema)[inferred];
|
||||
export declare const SkillReportFinalActionSchema: import("arktype/internal/variants/string.ts").StringType<"none" | "hide", {}>;
|
||||
|
||||
Vendored
+15
@@ -330,6 +330,21 @@ export const ApiV1SkillModerationResponseSchema = type({
|
||||
}).array(),
|
||||
}).or("null"),
|
||||
});
|
||||
export const SkillVersionRevokeRequestSchema = type({
|
||||
state: '"revoked"',
|
||||
reason: "string",
|
||||
ownerHandle: "string?",
|
||||
});
|
||||
export const ApiV1SkillVersionRevokeResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
versionId: "string",
|
||||
alreadyRevoked: "boolean",
|
||||
replacementVersion: "string|null",
|
||||
skillHidden: "boolean",
|
||||
});
|
||||
export const SkillReportStatusSchema = type('"open"|"confirmed"|"dismissed"');
|
||||
export const SkillReportFinalActionSchema = type('"none"|"hide"');
|
||||
export const SkillReportListStatusSchema = SkillReportStatusSchema.or('"all"');
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -373,6 +373,26 @@ export const ApiV1SkillModerationResponseSchema = type({
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
export const SkillVersionRevokeRequestSchema = type({
|
||||
state: '"revoked"',
|
||||
reason: "string",
|
||||
ownerHandle: "string?",
|
||||
});
|
||||
export type SkillVersionRevokeRequest = (typeof SkillVersionRevokeRequestSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillVersionRevokeResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
version: "string",
|
||||
skillId: "string",
|
||||
versionId: "string",
|
||||
alreadyRevoked: "boolean",
|
||||
replacementVersion: "string|null",
|
||||
skillHidden: "boolean",
|
||||
});
|
||||
export type ApiV1SkillVersionRevokeResponse =
|
||||
(typeof ApiV1SkillVersionRevokeResponseSchema)[inferred];
|
||||
|
||||
export const SkillReportStatusSchema = type('"open"|"confirmed"|"dismissed"');
|
||||
export type SkillReportStatus = (typeof SkillReportStatusSchema)[inferred];
|
||||
export const SkillReportFinalActionSchema = type('"none"|"hide"');
|
||||
|
||||
@@ -26,6 +26,25 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
- Restore pages only clear the exact `softDeletedAt` timestamp from the ban
|
||||
being lifted and only for skills hidden with `moderationReason = "user.banned"`.
|
||||
|
||||
## Exact-version revocation
|
||||
|
||||
- Staff can permanently revoke one hosted skill version without deleting the
|
||||
publisher account or preventing a later corrected version.
|
||||
- Revocation soft-deletes the `skillVersions` row and records
|
||||
`manualRevocation` evidence with the reason, reviewer, and timestamp. Revoked
|
||||
versions must remain unavailable from exact-version metadata, raw-file, and
|
||||
ZIP download paths.
|
||||
- If the revoked version is current, `latestVersionId`, `latest` tags, card
|
||||
metadata, embeddings, and search state move to the highest remaining
|
||||
non-revoked, non-malicious version.
|
||||
- If no usable version remains, the skill gets its own
|
||||
`moderationReason = "manual.version_revoked"` hold and no latest pointer.
|
||||
This hold is intentionally independent from `user.banned`, so account unban
|
||||
cannot re-expose the revoked skill history.
|
||||
- Publishing a new version may make that corrected version current and clear
|
||||
the skill-level hold. It must never clear `manualRevocation` or
|
||||
`softDeletedAt` from older revoked versions.
|
||||
|
||||
## Account and publisher deletion
|
||||
|
||||
- User and org deletion are soft-delete flows. They must not hard-delete users,
|
||||
|
||||
Reference in New Issue
Block a user