UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

160 lines (159 loc) • 6.47 kB
import { z } from 'zod'; import { SCAN_FAILED_MESSAGE, singleLine } from './format.js'; import { parseJsonResponse, requestText } from './network.js'; import { fetchSkillText } from './skill-fetch.js'; import { AGENT_API_ERROR_CODES, } from '../types.js'; export class VigilbaseApiError extends Error { status; body; retryAfterSec; code; /** The server's sanitised explanation, set only for known vigilbase.com problem errors. */ reason; /** CLI-authored remediation, never server text (see one-error-hints.ts). */ hint; /** * The message is the CLI's own text, never the server's, so formatError prints it although the * error carries a body. Unmarked errors with a body print only a generic `Request failed`. */ isCliAuthored; /** The One tool run a failed scan recorded. */ toolRunId; constructor(message, options) { super(message); this.name = 'VigilbaseApiError'; this.status = options.status; this.body = options.body; this.retryAfterSec = options.retryAfterSec; this.code = options.code; this.reason = options.reason; this.hint = options.hint; this.isCliAuthored = options.isCliAuthored ?? false; this.toolRunId = options.toolRunId; } } export function retryAfterSeconds(response, body) { const header = response.headers.get('retry-after'); if (header) { const parsed = Number.parseInt(header, 10); if (Number.isFinite(parsed) && parsed >= 0) return parsed; } if (body && typeof body === 'object' && 'retryAfterSec' in body && typeof body.retryAfterSec === 'number') { return body.retryAfterSec; } return undefined; } /** Longer messages are withheld rather than cut. */ const MAX_REASON_SOURCE_LENGTH = 1000; const problemSchema = z.object({ error: z.object({ code: z.unknown(), message: z.string().max(MAX_REASON_SOURCE_LENGTH) }), code: z.unknown(), }); /** * The reason a vigilbase.com problem+json error gives, when its code is one com sends. * Anything else stays redacted: other media types, unknown codes, odd messages. */ function problemReason(response, body) { const mediaType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase(); if (mediaType !== 'application/problem+json') return undefined; const problem = problemSchema.safeParse(body); if (!problem.success) return undefined; const code = z.enum(AGENT_API_ERROR_CODES).safeParse(problem.data.error.code ?? problem.data.code); if (!code.success) return undefined; const message = singleLine(problem.data.error.message); return message ? { code: code.data, message } : undefined; } /** `fallback` is the CLI's own message, used when com gave no reason it accepts. */ function publicApiError(fallback, response, body) { const reason = problemReason(response, body); return new VigilbaseApiError(reason?.message ?? fallback, { status: response.status, body, retryAfterSec: retryAfterSeconds(response, body), code: reason?.code, reason: reason?.message, isCliAuthored: !reason, }); } /** The versioned public tools API. The unversioned /api/agent/tools aliases sunset on 2027-09-01. */ export const PUBLIC_TOOLS_PATH = '/api/v1/agent/tools'; /** * Public (unauthenticated) vigilbase.com tools client. * Vigilbase One authenticated calls will use a separate client + device-auth tokens. */ export class VigilbaseClient { config; constructor(config) { this.config = config; } get origin() { return this.config.publicBaseUrl; } async request(path, init) { const url = `${this.origin}${path.startsWith('/') ? path : `/${path}`}`; const { response, text } = await requestText(this.config, url, { ...init, headers: { Accept: 'application/json', 'User-Agent': this.config.userAgent, ...(init?.headers ?? {}), }, }); return { response, body: parseJsonResponse(response, text) }; } async listTools() { const { response, body } = await this.request(PUBLIC_TOOLS_PATH); if (!response.ok) { throw publicApiError(`Failed to list tools (HTTP ${response.status})`, response, body); } const schema = z.object({ success: z.literal(true), tools: z.array(z.object({ toolName: z.string(), name: z.string(), description: z.string(), targetDescription: z.string(), uiUrl: z.string(), skillUrl: z.string(), executeUrl: z.string(), }).passthrough()), }).passthrough(); if (!schema.safeParse(body).success) { throw new VigilbaseApiError('Invalid tool catalog response. Check the API URL or retry later.', { status: response.status }); } return body; } async executeTool(toolName, target) { const { response, body } = await this.request(`${PUBLIC_TOOLS_PATH}/${encodeURIComponent(toolName)}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target }), }); const parsed = (body ?? {}); if (response.status === 429) { throw publicApiError('Rate limit exceeded. Retry after the indicated delay.', response, parsed); } if (!response.ok && response.status !== 422) { throw publicApiError(`Tool execution failed (HTTP ${response.status})`, response, parsed); } if (!z.object({ success: z.boolean() }).passthrough().safeParse(body).success) { throw new VigilbaseApiError('Invalid tool execution response. Check the API URL or retry later.', { status: response.status }); } if (!parsed.success) { throw publicApiError(SCAN_FAILED_MESSAGE, response, body); } return parsed; } async fetchSkillMarkdown(skillUrl) { return fetchSkillText(this.config, skillUrl); } badgeUrl(domain, style = 'default') { const params = new URLSearchParams(); if (style !== 'default') params.set('style', style); const query = params.toString(); return `${this.origin}/api/badge/${encodeURIComponent(domain)}${query ? `?${query}` : ''}`; } }