Skip to content

Commit a2466dc

Browse files
committed
feat(cli): show skill moderation in inspect
Fixes openclaw#1483
1 parent 16e87c1 commit a2466dc

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

packages/clawhub/src/cli/commands/inspect.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,80 @@ describe("cmdInspect", () => {
132132
expect(mockLog).toHaveBeenCalledWith("Model: gpt-5.2");
133133
});
134134

135+
it("prints skill moderation status without requiring a version fetch", async () => {
136+
httpMocks.apiRequest.mockResolvedValueOnce({
137+
skill: {
138+
slug: "demo",
139+
displayName: "Demo",
140+
summary: null,
141+
tags: { latest: "2.0.0" },
142+
stats: {},
143+
createdAt: 1,
144+
updatedAt: 2,
145+
},
146+
latestVersion: { version: "2.0.0", createdAt: 3, changelog: "init", license: "MIT-0" },
147+
owner: null,
148+
moderation: {
149+
isSuspicious: true,
150+
isMalwareBlocked: false,
151+
verdict: "suspicious",
152+
reasonCodes: ["network-send", "credential-pattern"],
153+
updatedAt: 1_700_000_000_000,
154+
engineVersion: "scanner-v2",
155+
summary: "Found credential-like configuration and outbound network behavior.",
156+
},
157+
});
158+
159+
await cmdInspect(makeGlobalOpts(), "demo");
160+
161+
expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1);
162+
expect(mockLog).toHaveBeenCalledWith("Moderation: SUSPICIOUS");
163+
expect(mockLog).toHaveBeenCalledWith("Reasons: network-send, credential-pattern");
164+
expect(mockLog).toHaveBeenCalledWith("Moderation Updated: 2023-11-14T22:13:20.000Z");
165+
expect(mockLog).toHaveBeenCalledWith("Moderation Engine: scanner-v2");
166+
expect(mockLog).toHaveBeenCalledWith(
167+
"Moderation Summary: Found credential-like configuration and outbound network behavior.",
168+
);
169+
});
170+
171+
it("includes moderation metadata in inspect JSON output", async () => {
172+
httpMocks.apiRequest.mockResolvedValueOnce({
173+
skill: {
174+
slug: "demo",
175+
displayName: "Demo",
176+
summary: null,
177+
tags: {},
178+
stats: {},
179+
createdAt: 1,
180+
updatedAt: 2,
181+
},
182+
latestVersion: null,
183+
owner: null,
184+
moderation: {
185+
isSuspicious: false,
186+
isMalwareBlocked: false,
187+
verdict: "clean",
188+
reasonCodes: [],
189+
updatedAt: null,
190+
engineVersion: null,
191+
summary: null,
192+
},
193+
});
194+
195+
await cmdInspect(makeGlobalOpts(), "demo", { json: true });
196+
197+
const output = JSON.parse(String(mockLog.mock.calls[0]?.[0]));
198+
expect(output.moderation).toEqual({
199+
isSuspicious: false,
200+
isMalwareBlocked: false,
201+
verdict: "clean",
202+
reasonCodes: [],
203+
updatedAt: null,
204+
engineVersion: null,
205+
summary: null,
206+
});
207+
});
208+
135209
it("rejects when both version and tag are provided", async () => {
136210
await expect(
137211
cmdInspect(makeGlobalOpts(), "demo", { version: "1.0.0", tag: "latest" }),

packages/clawhub/src/cli/commands/inspect.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ type SecurityStatus = {
3636
model: string | null;
3737
};
3838

39+
type ModerationStatus = {
40+
isSuspicious: boolean;
41+
isMalwareBlocked: boolean;
42+
verdict?: "clean" | "suspicious" | "malicious";
43+
reasonCodes?: string[];
44+
updatedAt?: number | null;
45+
engineVersion?: string | null;
46+
summary?: string | null;
47+
};
48+
3949
export async function cmdInspect(opts: GlobalOpts, slug: string, options: InspectOptions = {}) {
4050
const trimmed = slug.trim();
4151
if (!trimmed) fail("Slug required");
@@ -121,6 +131,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
121131
skill: skillResult.skill,
122132
latestVersion: skillResult.latestVersion,
123133
owner: skillResult.owner,
134+
moderation: skillResult.moderation ?? null,
124135
version: versionResult?.version ?? null,
125136
versions: versionsList?.items ?? null,
126137
file: options.file ? { path: options.file, content: fileContent } : null,
@@ -140,6 +151,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
140151
(versionResult?.version as { license?: string | null } | undefined)?.license ?? null,
141152
owner: skillResult.owner,
142153
});
154+
printModerationSummary(skillResult.moderation ?? null);
143155
}
144156

145157
if (shouldPrintMeta && versionResult?.version) {
@@ -236,6 +248,60 @@ function printVersionSummary(version: unknown) {
236248
}
237249
}
238250

251+
function printModerationSummary(moderation: unknown) {
252+
const status = normalizeModeration(moderation);
253+
if (!status) return;
254+
const label = status.isMalwareBlocked
255+
? "MALICIOUS"
256+
: status.isSuspicious
257+
? "SUSPICIOUS"
258+
: (status.verdict ?? "clean").toUpperCase();
259+
console.log(`Moderation: ${label}`);
260+
if (status.reasonCodes?.length) {
261+
console.log(`Reasons: ${status.reasonCodes.join(", ")}`);
262+
}
263+
if (typeof status.updatedAt === "number") {
264+
console.log(`Moderation Updated: ${formatTimestamp(status.updatedAt)}`);
265+
}
266+
if (status.engineVersion) {
267+
console.log(`Moderation Engine: ${status.engineVersion}`);
268+
}
269+
if (status.summary) {
270+
console.log(`Moderation Summary: ${truncate(status.summary, 160)}`);
271+
}
272+
}
273+
274+
function normalizeModeration(moderation: unknown): ModerationStatus | null {
275+
if (!moderation || typeof moderation !== "object") return null;
276+
const value = moderation as {
277+
isSuspicious?: unknown;
278+
isMalwareBlocked?: unknown;
279+
verdict?: unknown;
280+
reasonCodes?: unknown;
281+
updatedAt?: unknown;
282+
engineVersion?: unknown;
283+
summary?: unknown;
284+
};
285+
if (typeof value.isSuspicious !== "boolean") return null;
286+
if (typeof value.isMalwareBlocked !== "boolean") return null;
287+
const verdict =
288+
value.verdict === "clean" || value.verdict === "suspicious" || value.verdict === "malicious"
289+
? value.verdict
290+
: undefined;
291+
const reasonCodes = Array.isArray(value.reasonCodes)
292+
? value.reasonCodes.filter((reason): reason is string => typeof reason === "string")
293+
: undefined;
294+
return {
295+
isSuspicious: value.isSuspicious,
296+
isMalwareBlocked: value.isMalwareBlocked,
297+
verdict,
298+
reasonCodes,
299+
updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : null,
300+
engineVersion: typeof value.engineVersion === "string" ? value.engineVersion : null,
301+
summary: typeof value.summary === "string" && value.summary.trim() ? value.summary : null,
302+
};
303+
}
304+
239305
function normalizeTags(tags: unknown): Record<string, string> {
240306
if (!tags || typeof tags !== "object") return {};
241307
const entries = Object.entries(tags as Record<string, unknown>);

packages/clawhub/src/schema/schemas.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,14 @@ export const ApiV1SkillVersionResponseSchema = type({
251251
changelogSource: '"auto"|"user"|null?',
252252
license: '"MIT-0"|null?',
253253
files: "unknown?",
254+
security: type({
255+
status: '"clean"|"suspicious"|"malicious"|"pending"|"error"',
256+
hasWarnings: "boolean",
257+
checkedAt: "number|null?",
258+
model: "string|null?",
259+
})
260+
.or("null")
261+
.optional(),
254262
}).or("null"),
255263
skill: type({
256264
slug: "string",

0 commit comments

Comments
 (0)