Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/integrations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"scripts": {
"build": "tsc -b",
"typecheck": "tsc -b --pretty false",
"clean": "tsc -b --clean"
"clean": "tsc -b --clean",
"test": "node --test \"dist/**/*.test.js\""
},
"dependencies": {
"@clawguard/contracts": "workspace:*"
Expand Down
89 changes: 89 additions & 0 deletions packages/integrations/src/clawhub-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import { HttpClawHubClient } from "./clawhub-client.js";

test("getSkill returns neutral verdicts when provider verdict fields are absent", async () => {
const client = new HttpClawHubClient({
baseUrl: "https://clawhub.test",
fetchImpl: async () =>
new Response(JSON.stringify({ slug: "safe-skill", name: "Safe skill" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
});

const result = await client.getSkill("safe-skill");
assert.ok(result);
assert.equal(result.clawHubVerdict.verdict, "unknown");
assert.equal(result.virusTotalVerdict.verdict, "unknown");
});

test("getSkill extracts explicit provider verdicts", async () => {
const client = new HttpClawHubClient({
baseUrl: "https://clawhub.test",
fetchImpl: async () =>
new Response(
JSON.stringify({
slug: "risky",
verdicts: {
clawhub: { verdict: "review", summary: "Needs review", confidence: 70 },
virustotal: { verdict: "block", summary: "Known malware", maliciousDetections: 12 },
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
});

const result = await client.getSkill("risky");
assert.ok(result);
assert.equal(result.clawHubVerdict.verdict, "review");
assert.equal(result.virusTotalVerdict.verdict, "block");
assert.equal(result.virusTotalVerdict.maliciousDetections, 12);
});

test("getSkillMarkdown requests remote SKILL.md and returns content", async () => {
let calledUrl = "";
const client = new HttpClawHubClient({
baseUrl: "https://clawhub.test",
fetchImpl: async (input) => {
calledUrl = String(input);
return new Response("# Remote SKILL", {
status: 200,
headers: { "content-type": "text/markdown" },
});
},
});

const markdown = await client.getSkillMarkdown("demo");
assert.equal(markdown, "# Remote SKILL");
assert.match(calledUrl, /\/api\/v1\/skills\/demo\/file\?path=SKILL\.md$/);
});

test("listSkills supports sorting endpoint", async () => {
let calledUrl = "";
const client = new HttpClawHubClient({
baseUrl: "https://clawhub.test",
fetchImpl: async (input) => {
calledUrl = String(input);
return new Response(
JSON.stringify([
{ slug: "first", name: "First" },
{ slug: "second", description: "Second skill" },
{ name: "missing-slug" },
]),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
},
});

const entries = await client.listSkills("trending");
assert.equal(entries.length, 2);
assert.deepEqual(entries[0], { slug: "first", name: "First" });
assert.match(calledUrl, /\/api\/v1\/skills\?sort=trending$/);
});
207 changes: 207 additions & 0 deletions packages/integrations/src/clawhub-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type { ThreatIntelVerdict, VerdictLevel } from "@clawguard/contracts";
import { IntegrationHttpError } from "./errors.js";

export type ClawHubSort = "trending" | "installs" | "recent";

export interface ClawHubClientOptions {
baseUrl: string;
enabled?: boolean;
fetchImpl?: typeof fetch;
}

export interface ClawHubSkillEnrichment {
slug: string;
metadata: Record<string, unknown>;
clawHubVerdict: ThreatIntelVerdict;
virusTotalVerdict: ThreatIntelVerdict;
}

export interface ClawHubSkillListEntry {
slug: string;
name?: string;
description?: string;
}

export class HttpClawHubClient {
private readonly baseUrl: string;
private readonly enabled: boolean;
private readonly fetchImpl: typeof fetch;

constructor(options: ClawHubClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
this.enabled = options.enabled ?? true;
this.fetchImpl = options.fetchImpl ?? fetch;
}

async getSkill(slug: string): Promise<ClawHubSkillEnrichment | null> {
if (!this.enabled) {
return null;
}

const response = await this.requestJson(`/api/v1/skills/${encodeURIComponent(slug)}`);
if (response.status === 404) {
return null;
}

const body = await response.json();
const metadata = isRecord(body) ? body : {};

return {
slug,
metadata,
clawHubVerdict: this.resolveVerdict(metadata, "clawhub", "skill", slug),
virusTotalVerdict: this.resolveVerdict(metadata, "virustotal", "skill", slug),
};
}

async getSkillMarkdown(slug: string): Promise<string | null> {
if (!this.enabled) {
return null;
}

const response = await this.request(`/api/v1/skills/${encodeURIComponent(slug)}/file?path=SKILL.md`);
if (response.status === 404) {
return null;
}

const text = await response.text();
return text.length > 0 ? text : null;
}

async listSkills(sort: ClawHubSort): Promise<ClawHubSkillListEntry[]> {
if (!this.enabled) {
return [];
}

const response = await this.requestJson(`/api/v1/skills?sort=${encodeURIComponent(sort)}`);
const payload = await response.json();

if (!Array.isArray(payload)) {
return [];
}

const entries: ClawHubSkillListEntry[] = [];
for (const item of payload) {
if (!isRecord(item)) {
continue;
}

const slug = asString(item.slug);
if (!slug) {
continue;
}

const name = asString(item.name);
const description = asString(item.description);

entries.push({
slug,
...(name ? { name } : {}),
...(description ? { description } : {}),
});
}

return entries;
}

private async requestJson(path: string): Promise<Response> {
const response = await this.request(path);
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
throw new Error(`Expected JSON response for ${path}`);
}

return response;
}

private async request(path: string): Promise<Response> {
const url = `${this.baseUrl}${path}`;
const response = await this.fetchImpl(url);

if (response.ok) {
return response;
}

if (response.status === 404) {
return response;
}

throw new IntegrationHttpError(`ClawHub request failed: ${response.status} ${url}`, response.status, url);
}

private resolveVerdict(
metadata: Record<string, unknown>,
provider: ThreatIntelVerdict["provider"],
subjectType: ThreatIntelVerdict["subjectType"],
subject: string,
): ThreatIntelVerdict {
const providerRecord = this.getProviderVerdictRecord(metadata, provider);
const verdict = coerceVerdictLevel(providerRecord?.verdict) ?? "unknown";

const maliciousDetections = asNumber(providerRecord?.maliciousDetections);
const suspiciousDetections = asNumber(providerRecord?.suspiciousDetections);
const harmlessDetections = asNumber(providerRecord?.harmlessDetections);
const undetectedDetections = asNumber(providerRecord?.undetectedDetections);
const confidence = asNumber(providerRecord?.confidence);
const sourceUrl = asString(providerRecord?.sourceUrl);

return {
provider,
subjectType,
subject,
verdict,
summary:
asString(providerRecord?.summary) ??
(verdict === "unknown" ? "No provider verdict available." : `Provider verdict: ${verdict}.`),
...(maliciousDetections !== undefined ? { maliciousDetections } : {}),
...(suspiciousDetections !== undefined ? { suspiciousDetections } : {}),
...(harmlessDetections !== undefined ? { harmlessDetections } : {}),
...(undetectedDetections !== undefined ? { undetectedDetections } : {}),
...(confidence !== undefined ? { confidence } : {}),
...(sourceUrl ? { sourceUrl } : {}),
observedAt: new Date().toISOString(),
};
}

private getProviderVerdictRecord(
metadata: Record<string, unknown>,
provider: ThreatIntelVerdict["provider"],
): Record<string, unknown> | undefined {
const explicit = isRecord(metadata.verdicts) ? metadata.verdicts[provider] : undefined;
if (isRecord(explicit)) {
return explicit;
}

if (provider === "clawhub") {
const fallback = coerceVerdictLevel(metadata.verdict);
return fallback ? { verdict: fallback, summary: "ClawHub verdict extracted from skill metadata." } : undefined;
}

const vtVerdict = coerceVerdictLevel(metadata.virusTotalVerdict);
if (vtVerdict) {
return { verdict: vtVerdict, summary: "VirusTotal verdict extracted from skill metadata." };
}

return undefined;
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function asString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

function coerceVerdictLevel(value: unknown): VerdictLevel | undefined {
if (value === "allow" || value === "review" || value === "block" || value === "unknown") {
return value;
}

return undefined;
}
11 changes: 11 additions & 0 deletions packages/integrations/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class IntegrationHttpError extends Error {
readonly status: number;
readonly url: string;

constructor(message: string, status: number, url: string) {
super(message);
this.name = "IntegrationHttpError";
this.status = status;
this.url = url;
}
}
11 changes: 2 additions & 9 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,2 @@
import type { ThreatIntelVerdict } from "@clawguard/contracts";

export interface ClawHubClient {
getSkill(slug: string): Promise<ThreatIntelVerdict | null>;
}

export interface VirusTotalClient {
getFileVerdict(contentHash: string): Promise<ThreatIntelVerdict | null>;
}
export * from "./clawhub-client.js";
export * from "./errors.js";
Loading