feat: add skill version revocation (#3049)

This commit is contained in:
Patrick Erichsen
2026-07-09 21:36:21 -07:00
committed by GitHub
parent 6491975787
commit 52ef3f42aa
17 changed files with 920 additions and 6 deletions
@@ -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,
+54
View File
@@ -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) {
+73
View File
@@ -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",
+44
View File
@@ -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();
+7
View File
@@ -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),
+210
View File
@@ -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");
});
});
+26
View File
@@ -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
View File
@@ -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) => {
+15
View File
@@ -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,
+20
View File
@@ -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"');
+17
View File
@@ -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", {}>;
+15
View File
@@ -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"');
File diff suppressed because one or more lines are too long
+20
View File
@@ -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"');
+19
View File
@@ -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,