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
+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"');