@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
160 lines (159 loc) • 6.47 kB
JavaScript
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}` : ''}`;
}
}