diff --git a/mcp/package.json b/mcp/package.json index df2df59f4..97858bafe 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -81,6 +81,7 @@ "neo4j-driver": "5.28.3", "node-cache": "5.1.2", "p-queue": "^6.6.2", + "pg": "8.13.1", "redis": "^6.2.1", "simple-git": "3.30.0", "tsx": "4.21.0", @@ -99,6 +100,7 @@ "@types/jsonwebtoken": "9.0.10", "@types/node": "22.19.9", "@types/node-cache": "4.2.5", + "@types/pg": "8.11.10", "@types/xmldom": "0.1.34", "clipboardy": "4.0.0", "esbuild": "0.25.12", diff --git a/mcp/src/auditor/agent.ts b/mcp/src/auditor/agent.ts new file mode 100644 index 000000000..41210c62a --- /dev/null +++ b/mcp/src/auditor/agent.ts @@ -0,0 +1,174 @@ +import { ToolLoopAgent, StopCondition, stepCountIs } from "ai"; +import { getModelDetails, getProviderOptions } from "../aieo/src/provider.js"; +import { AuditBrowser } from "./browser.js"; + +function maxOutputTokensFor(provider?: string): number { + const env = Number(process.env.MAX_OUTPUT_TOKENS); + if (env > 0) return env; + return provider === "anthropic" ? 128_000 : 64_000; +} +import { getAuditorTools, AuditorTools, END_OF_AUDIT } from "./tools.js"; +import { AUDITOR_SYSTEM_PROMPT } from "./prompt.js"; +import { + AuditJob, + EvidenceCollector, + EvidenceKind, + EvidenceRecord, + Verdict, +} from "./types.js"; + +const DEFAULT_MAX_TURNS = + parseInt(process.env.AUDIT_MAX_TURNS || "", 10) || 40; + +const RUN_TIMEOUT_MS = + parseInt(process.env.AUDIT_RUN_TIMEOUT_MS || "", 10) || 900_000; +const MODEL_TIMEOUT_MS = + parseInt(process.env.AUDIT_MODEL_TIMEOUT_MS || "", 10) || 300_000; + +function createCollector(): EvidenceCollector { + const records: EvidenceRecord[] = []; + const strongIds = new Set(); + return { + records, + strongIds, + verdict: undefined, + push(kind: EvidenceKind, summary: string, data?: string, strong = false): string { + const id = `ev${records.length + 1}`; + records.push({ id, kind, summary, data: data ?? "" }); + if (strong) strongIds.add(id); + return id; + }, + }; +} + +function hasEndMarker(): StopCondition { + return ({ steps }) => { + for (const step of steps) { + for (const item of step.content) { + if (item.type === "text" && item.text?.includes(END_OF_AUDIT)) { + return true; + } + } + } + return false; + }; +} + +export interface PreparedAuditor { + run(): Promise; +} + +export function prepareAuditor( + job: AuditJob, + maxTurns: number = DEFAULT_MAX_TURNS, +): PreparedAuditor { + const startedAt = new Date().toISOString(); + const collector = createCollector(); + const logs: string[] = []; + + const abortController = new AbortController(); + + const modelName = job.model.model ?? job.model.provider; + const { model, modelId, provider } = getModelDetails( + modelName, + job.model.apiKey, + job.model.host, + undefined, + abortController.signal, + MODEL_TIMEOUT_MS, + ); + + const browser = new AuditBrowser(model); + + const tools = getAuditorTools({ deck: job.deck, collector, browser }); + + const stopWhen: StopCondition[] = [ + hasEndMarker(), + stepCountIs(maxTurns) as StopCondition, + ]; + + const agent = new ToolLoopAgent({ + model, + instructions: AUDITOR_SYSTEM_PROMPT, + tools, + stopWhen, + stopSequences: [END_OF_AUDIT], + providerOptions: getProviderOptions(provider as any, undefined, modelId) as any, + maxOutputTokens: maxOutputTokensFor(provider), + onStepFinish: (sf) => { + for (const item of sf.content) { + if (item.type === "tool-call") { + logs.push(`tool_call ${item.toolName}`); + console.log(`[auditor] taskId=${job.taskId} tool_call ${item.toolName}`); + } else if (item.type === "text" && item.text) { + const line = item.text.slice(0, 200).replace(/\n/g, " "); + logs.push(`text ${line}`); + } + } + }, + }); + + async function run(): Promise { + const timer = setTimeout(() => abortController.abort(), RUN_TIMEOUT_MS); + let runError: string | undefined; + + const userPrompt = + `Audit task ${job.taskId}. Load the task and its diff, the feature context, and the map, ` + + `then exercise the RUNNING app to reach an evidence-backed verdict. ` + + `Capture proof for anything you mark works, and call submit_verdict when done.`; + + console.log( + `[auditor] run_start taskId=${job.taskId} model=${modelId} provider=${provider} appUrl=${job.deck.map.appUrl}`, + ); + + try { + await agent.generate({ + prompt: userPrompt, + abortSignal: abortController.signal, + }); + } catch (err: any) { + runError = err?.message ?? String(err); + console.error(`[auditor] run_error taskId=${job.taskId}: ${runError}`); + } finally { + clearTimeout(timer); + await browser.close().catch(() => {}); + } + + const finishedAt = new Date().toISOString(); + const submitted = collector.verdict; + + if (submitted) { + return { + taskId: job.taskId, + overall: submitted.overall, + claims: submitted.claims, + observations: submitted.observations, + summary: submitted.summary, + evidence: collector.records, + startedAt, + finishedAt, + ...(runError ? { error: runError } : {}), + }; + } + + return { + taskId: job.taskId, + overall: "unknown", + claims: [], + observations: [ + `Auditor ended without submitting a verdict after ${logs.length} logged step events.`, + ...(collector.records.length > 0 + ? [`${collector.records.length} evidence records were captured.`] + : []), + ], + summary: + "The audit ended before submit_verdict was called; no honest verdict could be produced.", + evidence: collector.records, + startedAt, + finishedAt, + error: runError ?? "no verdict submitted", + }; + } + + return { run }; +} diff --git a/mcp/src/auditor/browser.ts b/mcp/src/auditor/browser.ts new file mode 100644 index 000000000..c71f69ae4 --- /dev/null +++ b/mcp/src/auditor/browser.ts @@ -0,0 +1,262 @@ +import { Stagehand, AISdkClient } from "@browserbasehq/stagehand"; +import type { LanguageModel } from "ai"; + +export interface NetEntry { + method: string; + url: string; + status: number; + type: string; + mimeType?: string; + error?: string; +} + +export interface ConsoleEntry { + level: string; + text: string; + url?: string; +} + +const NET_TYPES = new Set(["XHR", "Fetch", "Document"]); +const NET_CAP = 200; +const CONSOLE_CAP = 200; +const ACTION_CAP = 50; +const CONSOLE_TEXT_CAP = 500; + +export class AuditBrowser { + private stagehand?: Stagehand; + private readonly model: LanguageModel; + + private readonly attachedSessions = new Set(); + private readonly consoleAttached = new Set(); + private readonly pending = new Map(); + + private networkBuf: NetEntry[] = []; + private networkSinceAction: NetEntry[] = []; + private consoleBuf: ConsoleEntry[] = []; + private consoleSinceAction: ConsoleEntry[] = []; + + constructor(model: LanguageModel) { + this.model = model; + } + + private async ensure(): Promise { + if (this.stagehand) return this.stagehand; + const sh = new Stagehand({ + env: "LOCAL", + domSettleTimeout: 60000, + localBrowserLaunchOptions: { + headless: true, + viewport: { width: 1024, height: 768 }, + }, + llmClient: new AISdkClient({ model: this.model as any }), + }); + await sh.init(); + this.stagehand = sh; + return sh; + } + + private async page(sh: Stagehand): Promise { + const page = sh.context.activePage() as any; + if (!page) throw new Error("no active page available"); + this.attach(page); + return page; + } + + private pushNet(entry: NetEntry): void { + this.networkBuf.push(entry); + if (this.networkBuf.length > NET_CAP) this.networkBuf.shift(); + this.networkSinceAction.push(entry); + if (this.networkSinceAction.length > ACTION_CAP) this.networkSinceAction.shift(); + } + + private pushConsole(entry: ConsoleEntry): void { + if (entry.text && entry.text.length > CONSOLE_TEXT_CAP) { + entry.text = entry.text.slice(0, CONSOLE_TEXT_CAP) + "…"; + } + this.consoleBuf.push(entry); + if (this.consoleBuf.length > CONSOLE_CAP) this.consoleBuf.shift(); + this.consoleSinceAction.push(entry); + if (this.consoleSinceAction.length > ACTION_CAP) this.consoleSinceAction.shift(); + } + + private attach(page: any): void { + try { + const targetId = page?.targetId?.() ?? "default"; + if (!this.consoleAttached.has(targetId)) { + this.consoleAttached.add(targetId); + page.on("console", (msg: any) => { + try { + this.pushConsole({ + level: msg.type?.() ?? "log", + text: msg.text?.() ?? "", + url: msg.location?.()?.url, + }); + } catch { + /* ignore console decode errors */ + } + }); + } + + const session = page?.mainFrame?.()?.session; + if (!session) return; + const sid = session.id ?? "default"; + if (this.attachedSessions.has(sid)) return; + this.attachedSessions.add(sid); + + this.subscribe(session); + } catch { + /* a CDP quirk must never fail the browser action */ + } + } + + private subscribe(session: any): void { + session.send("Runtime.enable").catch(() => {}); + session.on("Runtime.exceptionThrown", (p: any) => { + const d = p?.exceptionDetails; + this.pushConsole({ + level: "error", + text: + d?.exception?.description ?? + d?.text ?? + "uncaught exception", + url: d?.url, + }); + }); + + session.send("Log.enable").catch(() => {}); + session.on("Log.entryAdded", (p: any) => { + const e = p?.entry; + if (!e) return; + if (e.level === "error" || e.level === "warning") { + this.pushConsole({ level: e.level, text: e.text ?? "", url: e.url }); + } + }); + + session.send("Network.enable").catch(() => {}); + session.on("Network.requestWillBeSent", (p: any) => { + const type = p?.type ?? ""; + if (p?.redirectResponse && NET_TYPES.has(type)) { + this.pushNet({ + method: p?.request?.method ?? "GET", + url: p?.redirectResponse?.url ?? p?.request?.url ?? "", + status: p?.redirectResponse?.status ?? 0, + type, + }); + } + this.pending.set(p.requestId, { + method: p?.request?.method ?? "GET", + url: p?.request?.url ?? "", + type, + }); + if (this.pending.size > 500) { + const first = this.pending.keys().next().value; + if (first !== undefined) this.pending.delete(first); + } + }); + session.on("Network.responseReceived", (p: any) => { + const type = p?.type ?? this.pending.get(p?.requestId)?.type ?? ""; + if (!NET_TYPES.has(type)) { + this.pending.delete(p?.requestId); + return; + } + const req = this.pending.get(p?.requestId); + this.pushNet({ + method: req?.method ?? "GET", + url: p?.response?.url ?? req?.url ?? "", + status: p?.response?.status ?? 0, + type, + mimeType: p?.response?.mimeType, + }); + this.pending.delete(p?.requestId); + }); + session.on("Network.loadingFailed", (p: any) => { + const req = this.pending.get(p?.requestId); + const type = req?.type ?? p?.type ?? ""; + if (req && !NET_TYPES.has(type)) { + this.pending.delete(p?.requestId); + return; + } + this.pushNet({ + method: req?.method ?? "", + url: req?.url ?? "(unknown)", + status: 0, + type, + error: p?.errorText ?? "loading failed", + }); + this.pending.delete(p?.requestId); + }); + } + + drainNetworkDelta(): NetEntry[] { + const d = this.networkSinceAction; + this.networkSinceAction = []; + return d; + } + + drainConsoleDelta(): ConsoleEntry[] { + const d = this.consoleSinceAction; + this.consoleSinceAction = []; + return d; + } + + snapshotNetwork(): NetEntry[] { + return [...this.networkBuf]; + } + + snapshotConsole(): ConsoleEntry[] { + return [...this.consoleBuf]; + } + + async open(url: string): Promise<{ url: string; ok: boolean }> { + const sh = await this.ensure(); + const page = await this.page(sh); + await page.goto(url); + return { url, ok: true }; + } + + async act(action: string): Promise<{ action: string; result: unknown }> { + const sh = await this.ensure(); + await this.page(sh); + const result = await sh.act(action); + return { action, result }; + } + + async observe( + instruction: string, + ): Promise<{ instruction: string; observations: unknown }> { + const sh = await this.ensure(); + await this.page(sh); + const observations = await sh.observe(instruction); + return { instruction, observations }; + } + + async extract( + instruction: string, + ): Promise<{ instruction: string; extraction: unknown }> { + const sh = await this.ensure(); + await this.page(sh); + const extraction = await sh.extract(instruction); + return { instruction, extraction }; + } + + async currentUrl(): Promise { + const sh = await this.ensure(); + const page = await this.page(sh); + return page.url(); + } + + async screenshot(): Promise { + const sh = await this.ensure(); + const page = await this.page(sh); + const buffer = await page.screenshot({ fullPage: false }); + return buffer.toString("base64"); + } + + async close(): Promise { + if (this.stagehand) { + const sh = this.stagehand; + this.stagehand = undefined; + await sh.close(); + } + } +} diff --git a/mcp/src/auditor/prompt.ts b/mcp/src/auditor/prompt.ts new file mode 100644 index 000000000..f3591bb4d --- /dev/null +++ b/mcp/src/auditor/prompt.ts @@ -0,0 +1,19 @@ +export const AUDITOR_SYSTEM_PROMPT = `You are an independent Auditor. Your job is to determine, with evidence, whether the specific TASK was actually solved in the running application. You did not write this code; you audit it. You never modify anything. + +SCOPE +Judge ONLY what THIS task was asked to do (its prompt + its diff). The feature context is background — do not hold this task accountable for parts of the feature other tasks own. You may add feature-level notes as observations, never as failures of this task. Start by reading the task, its diff, the feature context, and the map so you know what "solved" means and where the running app is. + +METHOD FREEDOM +Decide for yourself how to prove it — drive the UI, call APIs, read logs, measure timing. There is no fixed pipeline and no checklist: compose the tools however the task's nature demands. Use the cheapest sufficient method; reserve the browser for genuinely visual checks — an API call, a log line, or a timing sample is faster and stronger when the task is not about pixels. When you drive the UI, browser_open and browser_act report the network requests and console output the page produced — use read_network to prove the app actually called its API and got the right status, and read_console to catch runtime errors a screenshot hides. For a change that writes data, confirm it independently: after the write, use db_query (or a fresh read via a different path) to prove the new state actually persisted. + +ACCESS +You are NOT told how to log in or navigate — every app differs. Discover it by driving the app: snapshot the page to see what is there, try the app's own dev/mock/offline mode, and fill the visible login form with placeholder credentials. The app's base URL is in the map. If access is genuinely blocked, say so as the reason for an unknown verdict — do not invent steps you were told. + +EPISTEMICS (the core) +Only mark a claim works if you CAPTURED proof it works, using a PROBE tool — http_request, sample, read_logs, read_network, read_console, db_query, browser_extract, browser_screenshot, or browser_current_url — and cite the evidence id it returned in proof[]. A note from the capture tool is NOT proof and will not back a works claim: a works claim with no probe-captured proof id is automatically downgraded to unknown, and overall follows. "It compiles" or "looks right" is NOT proof. Prefer cheap http/log/timing probes when a claim can be checked without the UI; reach for a screenshot when the check is genuinely visual. If you cannot reach the app or cannot tell, mark unknown and describe what happened. If it is genuinely broken, mark broken with the reason. Be honest — an unjustified works is the worst possible outcome. Inspect what actually happened; never assume. + +PERSISTENCE +If a tool call fails or a page looks empty, wait briefly and retry, or try browser_observe to find elements and browser_act to interact, before concluding. Do not give up after one failure. + +FINISHING +Work toward a verdict efficiently. As soon as you have captured proof for each claim the task makes, STOP probing and call submit_verdict — do not keep exploring once the evidence is sufficient. Over-exploration burns the turn budget and risks the run ending with NO verdict, which is the worst outcome. A focused audit of a handful of claims rarely needs more than a dozen or so tool calls. Call submit_verdict with per-claim verdicts, each backed by captured evidence ids, plus a holistic overall verdict and a short summary. submit_verdict is the only way to end the audit; if you never call it, the audit fails as unknown.`; diff --git a/mcp/src/auditor/report.ts b/mcp/src/auditor/report.ts new file mode 100644 index 000000000..0dcda8efb --- /dev/null +++ b/mcp/src/auditor/report.ts @@ -0,0 +1,45 @@ +import { fetch } from "undici"; +import { Verdict } from "./types.js"; + +const REPORT_ATTEMPTS = + parseInt(process.env.AUDIT_REPORT_ATTEMPTS || "", 10) || 4; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function reportVerdict( + responseUrl: string, + callbackApiKey: string, + verdict: Verdict, +): Promise { + for (let attempt = 1; attempt <= REPORT_ATTEMPTS; attempt++) { + try { + const resp = await fetch(responseUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": callbackApiKey, + }, + body: JSON.stringify(verdict), + }); + if (resp.ok) { + console.log( + `[auditor] reported taskId=${verdict.taskId} overall=${verdict.overall} status=${resp.status}`, + ); + return; + } + console.error( + `[auditor] report non-ok taskId=${verdict.taskId} status=${resp.status} attempt=${attempt}/${REPORT_ATTEMPTS}`, + ); + } catch (err: any) { + console.error( + `[auditor] report attempt ${attempt}/${REPORT_ATTEMPTS} failed taskId=${verdict.taskId}: ${err?.message ?? String(err)}`, + ); + } + if (attempt < REPORT_ATTEMPTS) await sleep(500 * 2 ** (attempt - 1)); + } + console.error( + `[auditor] report gave up taskId=${verdict.taskId} after ${REPORT_ATTEMPTS} attempts`, + ); +} diff --git a/mcp/src/auditor/run.ts b/mcp/src/auditor/run.ts new file mode 100644 index 000000000..da7266c42 --- /dev/null +++ b/mcp/src/auditor/run.ts @@ -0,0 +1,66 @@ +import { prepareAuditor } from "./agent.js"; +import { reportVerdict } from "./report.js"; +import { AuditJob, Verdict } from "./types.js"; + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function fallbackVerdict( + taskId: string, + error: string, + startedAt: string, +): Verdict { + return { + taskId, + overall: "unknown", + claims: [], + observations: [], + summary: "The auditor could not complete.", + evidence: [], + startedAt, + finishedAt: new Date().toISOString(), + error, + }; +} + +async function main(): Promise { + const startedAt = new Date().toISOString(); + let job: AuditJob | undefined; + + try { + const raw = await readStdin(); + job = JSON.parse(raw) as AuditJob; + } catch (err: any) { + console.error( + `[auditor] failed to read/parse job from stdin: ${err?.message ?? String(err)}`, + ); + return; + } + + let verdict: Verdict; + try { + verdict = await prepareAuditor(job).run(); + } catch (err: any) { + verdict = fallbackVerdict( + job.taskId, + err?.message ?? String(err), + startedAt, + ); + } + + if (job.responseUrl && job.callbackApiKey) { + await reportVerdict(job.responseUrl, job.callbackApiKey, verdict); + } else { + console.error("[auditor] no responseUrl/callbackApiKey — verdict not reported"); + console.log(JSON.stringify(verdict)); + } +} + +main().catch((err) => { + console.error(`[auditor] fatal: ${err?.message ?? String(err)}`); +}); diff --git a/mcp/src/auditor/schema.ts b/mcp/src/auditor/schema.ts new file mode 100644 index 000000000..d37045c25 --- /dev/null +++ b/mcp/src/auditor/schema.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export const OutcomeSchema = z.enum(["works", "broken", "unknown"]); + +export const ClaimSchema = z.object({ + claim: z.string().describe("The specific thing the task claimed to do."), + verdict: OutcomeSchema.describe( + "works only if you captured proof; broken with a reason; unknown if you could not tell.", + ), + proof: z + .array(z.string()) + .describe( + "Probe-captured evidence ids that back this verdict — ids returned by http_request, sample, read_logs, read_network, read_console, db_query, browser_extract, browser_screenshot, or browser_current_url. Notes from the capture tool do NOT count. A works verdict with no such id is downgraded to unknown.", + ), + reasoning: z + .string() + .describe("Why this verdict, referencing what you actually observed."), +}); + +export const VerdictSchema = z.object({ + overall: OutcomeSchema.describe("Holistic verdict for the whole task."), + claims: z + .array(ClaimSchema) + .describe("Per-claim verdicts, each backed by captured evidence ids."), + observations: z + .array(z.string()) + .describe( + "Feature-level or incidental notes. Never counted as failures of THIS task.", + ), + summary: z.string().describe("A short holistic summary of the audit."), +}); + +export type VerdictInput = z.infer; diff --git a/mcp/src/auditor/tools.ts b/mcp/src/auditor/tools.ts new file mode 100644 index 000000000..3b998d057 --- /dev/null +++ b/mcp/src/auditor/tools.ts @@ -0,0 +1,517 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { fetch } from "undici"; +import pg from "pg"; +import { AuditorContext, ClaimVerdict } from "./types.js"; +import { VerdictSchema } from "./schema.js"; + +const execAsync = promisify(exec); + +const DB_ROW_CAP = 50; + +export const END_OF_AUDIT = "[END_OF_AUDIT]"; + +function resolveUrl(appUrl: string, url: string): string { + try { + return new URL(url).toString(); + } catch { + try { + return new URL(url, appUrl).toString(); + } catch { + return url; + } + } +} + +function browserError(op: string, err: any) { + return { + error: `${op} failed: ${err?.message ?? String(err)}`, + hint: "Wait briefly and retry, or use browser_observe to locate elements and browser_act to interact before concluding. Do not give up after one failure.", + }; +} + +const MUTATING: Array<[RegExp, string]> = [ + [/\bgit\b[^\n|;&]*\b(commit|push|reset|checkout|merge|rebase|add|clean|stash)\b/, "git write command"], + [/\brm\b/, "rm"], + [/\bmv\b/, "mv"], + [/\bcp\b/, "cp"], + [/\b(mkdir|rmdir|touch|truncate|tee|chmod|chown|ln)\b/, "filesystem mutation"], + [/\bsed\b[^\n|;&]*-i\b/, "in-place sed"], + [/\b(npm|yarn|pnpm|pip|pip3|cargo|go|apt|apt-get|brew)\b[^\n|;&]*\b(install|add|i|remove|uninstall|update|upgrade|get)\b/, "package install"], + [/>>?/, "output redirection to a file"], + [/\bkill\b|\bpkill\b/, "process kill"], +]; + +function commandRejection(cmd: string): string | undefined { + for (const [re, label] of MUTATING) { + if (re.test(cmd)) { + return `run_command rejected: ${label} is not allowed — the Auditor only inspects, it never mutates. Use read-only commands (ls, cat, curl, timing).`; + } + } + return undefined; +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[idx]; +} + +export function getAuditorTools(ctx: AuditorContext) { + const { deck, collector, browser } = ctx; + + const read_task = tool({ + description: + "Read the TASK you are auditing: its prompt, description, and the diff of the change. The diff bounds the scope — judge only what this task changed.", + inputSchema: z.object({}), + execute: async () => ({ + prompt: deck.task.prompt, + description: deck.task.description, + diff: deck.diff, + }), + }); + + const read_feature_context = tool({ + description: + "Read the broader feature context. This is BACKGROUND ONLY — other tasks own other parts of the feature. Judge the TASK's responsibility, not the whole feature.", + inputSchema: z.object({}), + execute: async () => ({ featureContext: deck.featureContext }), + }); + + const read_map = tool({ + description: + "Read the map to the running app: its base URL and any notes. This is where you exercise the app.", + inputSchema: z.object({}), + execute: async () => ({ appUrl: deck.map.appUrl, notes: deck.map.notes }), + }); + + const browser_open = tool({ + description: + "Open a URL (absolute, or a path relative to the app base URL) in a real browser. Returns the load result.", + inputSchema: z.object({ + url: z.string().describe("Absolute URL or a path relative to the app base URL."), + }), + execute: async ({ url }: { url: string }) => { + try { + const res = await browser.open(resolveUrl(deck.map.appUrl, url)); + return { + ...res, + network: browser.drainNetworkDelta(), + console: browser.drainConsoleDelta(), + }; + } catch (err: any) { + return browserError("browser_open", err); + } + }, + }); + + const browser_act = tool({ + description: + "Perform a natural-language action on the current page, e.g. 'Click the sign in button' or 'Type hello into the search input'. Keep actions atomic and specific.", + inputSchema: z.object({ + action: z.string().describe("The atomic action to perform in natural language."), + }), + execute: async ({ action }: { action: string }) => { + try { + const res = await browser.act(action); + return { + ...res, + network: browser.drainNetworkDelta(), + console: browser.drainConsoleDelta(), + }; + } catch (err: any) { + return browserError("browser_act", err); + } + }, + }); + + const browser_observe = tool({ + description: + "Observe the current page in natural language to find candidate actionable elements (e.g. 'find the login button'). Use before acting when you are unsure what is on the page.", + inputSchema: z.object({ + instruction: z + .string() + .describe("What you are looking for on the page right now."), + }), + execute: async ({ instruction }: { instruction: string }) => { + try { + return await browser.observe(instruction); + } catch (err: any) { + return browserError("browser_observe", err); + } + }, + }); + + const browser_extract = tool({ + description: + "Extract a structured/text observation from the current page in natural language (e.g. 'the visible error message' or 'the list of rows'). Captures a dom evidence record and returns its id you can cite in proof[].", + inputSchema: z.object({ + instruction: z + .string() + .describe("What to read/extract from the page."), + }), + execute: async ({ instruction }: { instruction: string }) => { + try { + const { extraction } = await browser.extract(instruction); + const data = + typeof extraction === "string" + ? extraction + : JSON.stringify(extraction); + const id = collector.push("dom", instruction, data, true); + return { id, extraction }; + } catch (err: any) { + return browserError("browser_extract", err); + } + }, + }); + + const browser_screenshot = tool({ + description: + "Take a screenshot of the current page. Captures a screenshot evidence record and returns its id — cite that id in a claim's proof[]. The raw image is stored as evidence, not returned here.", + inputSchema: z.object({}), + execute: async () => { + try { + const base64 = await browser.screenshot(); + const id = collector.push("screenshot", "screenshot of current page", base64, true); + return { id, note: `Screenshot captured as evidence ${id}. Cite ${id} in proof[].` }; + } catch (err: any) { + return browserError("browser_screenshot", err); + } + }, + }); + + const browser_current_url = tool({ + description: + "Return the current page URL of the browser. Useful to confirm redirects and navigation outcomes. Captures a dom evidence record and returns its id plus the url.", + inputSchema: z.object({}), + execute: async () => { + try { + const url = await browser.currentUrl(); + const id = collector.push("dom", `current url: ${url}`, url, true); + return { id, url }; + } catch (err: any) { + return browserError("browser_current_url", err); + } + }, + }); + + const read_network = tool({ + description: + "Snapshot the network requests the PAGE has made so far (XHR/fetch/document only) — method, url, status, and any failures. Use this after a browser action to prove the app actually called its API and got the expected status. Captures a network evidence record and returns its id to cite in proof[].", + inputSchema: z.object({}), + execute: async () => { + const entries = browser.snapshotNetwork(); + const id = collector.push( + "network", + `page network log (${entries.length} requests)`, + JSON.stringify(entries), + true, + ); + return { id, entries }; + }, + }); + + const read_console = tool({ + description: + "Snapshot the browser console output the PAGE has produced so far — console.* calls, uncaught exceptions, and resource-load errors. Use this to detect runtime breakage a screenshot hides. Captures a console evidence record and returns its id to cite in proof[].", + inputSchema: z.object({}), + execute: async () => { + const entries = browser.snapshotConsole(); + const errors = entries.filter((e) => e.level === "error").length; + const id = collector.push( + "console", + `page console (${entries.length} entries, ${errors} errors)`, + JSON.stringify(entries), + true, + ); + return { id, entries, errorCount: errors }; + }, + }); + + const http_request = tool({ + description: + "Make a timed HTTP request against the running app or its API. Returns status, elapsed ms, response headers, and a snippet of the body.", + inputSchema: z.object({ + url: z.string().describe("Absolute URL to request."), + method: z.string().optional().describe("HTTP method (default GET)."), + headers: z.record(z.string(), z.string()).optional(), + body: z.string().optional().describe("Raw request body, if any."), + }), + execute: async ({ + url, + method, + headers, + body, + }: { + url: string; + method?: string; + headers?: Record; + body?: string; + }) => { + const start = Date.now(); + try { + const resp = await fetch(url, { + method: method ?? "GET", + headers, + body: body ?? undefined, + }); + const ms = Date.now() - start; + const text = await resp.text(); + const respHeaders: Record = {}; + resp.headers.forEach((v, k) => { + respHeaders[k] = v; + }); + const bodySnippet = text.slice(0, 2000); + const id = collector.push( + "http", + `HTTP ${method ?? "GET"} ${url} -> ${resp.status} in ${ms}ms`, + JSON.stringify({ status: resp.status, ms, bodySnippet }), + true, + ); + return { + id, + status: resp.status, + ms, + headers: respHeaders, + bodySnippet, + }; + } catch (err: any) { + const ms = Date.now() - start; + const message = err?.message ?? String(err); + const id = collector.push( + "http", + `HTTP ${method ?? "GET"} ${url} -> request failed in ${ms}ms`, + JSON.stringify({ status: 0, ms, bodySnippet: `request failed: ${message}` }), + true, + ); + return { + id, + status: 0, + ms, + headers: {}, + bodySnippet: `request failed: ${message}`, + }; + } + }, + }); + + const read_logs = tool({ + description: + "Fetch recent application logs if a log source is configured for this environment. Returns the logs, or a note that no log source is available.", + inputSchema: z.object({}), + execute: async () => { + const cmd = process.env.AUDIT_LOGS_CMD; + if (!cmd) return { logs: "no log source" }; + try { + const { stdout, stderr } = await execAsync(cmd, { + timeout: 30000, + maxBuffer: 1024 * 1024, + }); + const logs = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : ""); + const id = collector.push("log", "recent application logs", logs, true); + return { id, logs }; + } catch (err: any) { + const logs = `log fetch failed: ${err?.message ?? String(err)}`; + const id = collector.push("log", "log fetch failed", logs, true); + return { id, logs }; + } + }, + }); + + const db_query = tool({ + description: + "Run a READ-ONLY SQL query (Postgres) against the app database to independently confirm state persisted — e.g. after a write through the UI, SELECT the row to prove it exists. Enforced read-only (READ ONLY transaction + statement timeout + row cap), so a write attempt fails honestly. Captures a db evidence record and returns its id to cite in proof[]. Only available when a database URL is configured.", + inputSchema: z.object({ + query: z.string().describe("A single read-only SELECT statement."), + }), + execute: async ({ query }: { query: string }) => { + const url = process.env.AUDIT_DB_URL || process.env.DATABASE_URL; + if (!url) { + return { + unavailable: true, + message: "no database configured (AUDIT_DB_URL/DATABASE_URL unset)", + }; + } + const client = new pg.Client({ + connectionString: url, + connectionTimeoutMillis: 5000, + }); + try { + await client.connect(); + await client.query("SET default_transaction_read_only = on"); + await client.query("BEGIN"); + await client.query("SET TRANSACTION READ ONLY"); + await client.query("SET LOCAL statement_timeout = '5s'"); + // extended protocol (values: []) rejects multi-statement strings + const res = await client.query({ text: query, values: [] }); + await client.query("ROLLBACK"); + const rows = res.rows.slice(0, DB_ROW_CAP); + const id = collector.push( + "db", + `db_query rows=${res.rowCount ?? rows.length}`, + JSON.stringify({ rowCount: res.rowCount, rows }), + true, + ); + return { id, rowCount: res.rowCount, rows }; + } catch (err: any) { + const message = err?.message ?? String(err); + const id = collector.push( + "db", + "db_query failed", + message, + true, + ); + return { id, error: `db_query failed: ${message}` }; + } finally { + await client.end().catch(() => {}); + } + }, + }); + + const run_command = tool({ + description: + "Run a READ-ONLY shell command to inspect the running system (ls, cat, curl, timing, etc.). Mutating commands are rejected. Returns stdout and stderr.", + inputSchema: z.object({ + cmd: z.string().describe("The read-only shell command to run."), + }), + execute: async ({ cmd }: { cmd: string }) => { + const rejection = commandRejection(cmd); + if (rejection) return { rejected: true, message: rejection }; + try { + const { stdout, stderr } = await execAsync(cmd, { + timeout: 60000, + maxBuffer: 1024 * 1024, + }); + return { stdout: stdout ?? "", stderr: stderr ?? "" }; + } catch (err: any) { + return { + stdout: err?.stdout ?? "", + stderr: err?.stderr ?? (err?.message ?? String(err)), + }; + } + }, + }); + + const sample = tool({ + description: + "Call a URL n times and measure timing. Returns count, median ms, p95 ms, and the individual samples. Use for performance/timing claims.", + inputSchema: z.object({ + url: z.string().describe("Absolute URL to sample."), + n: z.number().describe("Number of requests to make."), + }), + execute: async ({ url, n }: { url: string; n: number }) => { + const count = Math.max(1, Math.min(50, Math.floor(n))); + const samples: number[] = []; + for (let i = 0; i < count; i++) { + const start = Date.now(); + try { + const resp = await fetch(url, { method: "GET" }); + await resp.arrayBuffer(); + } catch { + /* still record the elapsed time of the failed attempt */ + } + samples.push(Date.now() - start); + } + const sorted = [...samples].sort((a, b) => a - b); + const medianMs = percentile(sorted, 50); + const p95Ms = percentile(sorted, 95); + const id = collector.push( + "timing", + `sampled ${url} n=${count} median=${medianMs}ms p95=${p95Ms}ms`, + JSON.stringify({ count, medianMs, p95Ms, samples }), + true, + ); + return { id, count, medianMs, p95Ms, samples }; + }, + }); + + const capture = tool({ + description: + "Record a free-form NOTE for the trail. A note is NOT proof and cannot back a works verdict — only the probe tools (http_request, sample, read_logs, read_network, read_console, db_query, browser_extract, browser_screenshot, browser_current_url) produce evidence that backs works. Use this for context you observed, not to justify a verdict.", + inputSchema: z.object({ + summary: z.string().describe("A short human-readable description of what you observed."), + data: z.string().optional().describe("The underlying note text."), + }), + execute: async ({ + summary, + data, + }: { + summary: string; + data?: string; + }) => { + const id = collector.push("note", summary, data); + return { id, note: "Recorded as a NOTE — not proof; cannot back a works verdict." }; + }, + }); + + const submit_verdict = tool({ + description: + "Submit the final audit verdict and END the audit. A claim may be marked works ONLY if its proof[] cites at least one probe-captured evidence id (from http_request, sample, read_logs, read_network, read_console, db_query, browser_extract, browser_screenshot, or browser_current_url); notes do not count. A works claim without such proof is downgraded to unknown, and overall is downgraded to match. This is the terminal tool.", + inputSchema: VerdictSchema, + execute: async (input: z.infer) => { + const strong = collector.strongIds; + const notes: string[] = []; + + const claims: ClaimVerdict[] = input.claims.map((c): ClaimVerdict => { + if (c.verdict !== "works") return c; + const backed = c.proof.filter((id) => strong.has(id)); + if (backed.length === 0) { + notes.push( + `Guard: claim "${c.claim}" was submitted as works with no probe-captured proof; downgraded to unknown.`, + ); + return { + ...c, + verdict: "unknown", + proof: backed, + reasoning: `${c.reasoning} [auditor guard: no captured proof backed this works claim]`, + }; + } + return { ...c, proof: backed }; + }); + + const hasBroken = claims.some((c) => c.verdict === "broken"); + const allWorks = claims.length > 0 && claims.every((c) => c.verdict === "works"); + + let overall = input.overall; + if (overall === "works" && !allWorks) { + overall = hasBroken ? "broken" : "unknown"; + notes.push( + `Guard: overall downgraded from works to ${overall} because not every claim is backed as works.`, + ); + } + + collector.verdict = { + overall, + claims, + observations: notes.length > 0 ? [...input.observations, ...notes] : input.observations, + summary: input.summary, + }; + return `Verdict recorded. ${END_OF_AUDIT}`; + }, + }); + + return { + read_task, + read_feature_context, + read_map, + browser_open, + browser_act, + browser_observe, + browser_extract, + browser_screenshot, + browser_current_url, + read_network, + read_console, + http_request, + read_logs, + db_query, + run_command, + sample, + capture, + submit_verdict, + }; +} + +export type AuditorTools = ReturnType; diff --git a/mcp/src/auditor/types.ts b/mcp/src/auditor/types.ts new file mode 100644 index 000000000..31af2f57f --- /dev/null +++ b/mcp/src/auditor/types.ts @@ -0,0 +1,88 @@ +export type Outcome = "works" | "broken" | "unknown"; + +export interface DeckTask { + prompt: string; + description: string | null; +} + +export interface DeckMap { + appUrl: string; + notes: string | null; +} + +export interface Deck { + task: DeckTask; + diff: string; + featureContext: string | null; + map: DeckMap; +} + +export interface JobModel { + apiKey: string; + host?: string; + provider?: string; + model?: string; +} + +export interface AuditJob { + taskId: string; + deck: Deck; + model: JobModel; + responseUrl: string; + callbackApiKey: string; +} + +export type EvidenceKind = + | "screenshot" + | "http" + | "log" + | "timing" + | "dom" + | "network" + | "console" + | "db" + | "note"; + +export interface EvidenceRecord { + id: string; + kind: EvidenceKind; + summary: string; + data: string; +} + +export interface ClaimVerdict { + claim: string; + verdict: Outcome; + proof: string[]; + reasoning: string; +} + +export interface Verdict { + taskId: string; + overall: Outcome; + claims: ClaimVerdict[]; + observations: string[]; + summary: string; + evidence: EvidenceRecord[]; + startedAt: string; + finishedAt: string; + error?: string; +} + +export interface EvidenceCollector { + records: EvidenceRecord[]; + strongIds: Set; + verdict?: { + overall: Outcome; + claims: ClaimVerdict[]; + observations: string[]; + summary: string; + }; + push(kind: EvidenceKind, summary: string, data?: string, strong?: boolean): string; +} + +export interface AuditorContext { + deck: Deck; + collector: EvidenceCollector; + browser: import("./browser.js").AuditBrowser; +} diff --git a/mcp/yarn.lock b/mcp/yarn.lock index b8202c488..a1c49a73a 100644 --- a/mcp/yarn.lock +++ b/mcp/yarn.lock @@ -2517,6 +2517,15 @@ dependencies: parse-path "*" +"@types/pg@8.11.10": + version "8.11.10" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.11.10.tgz#b8fb2b2b759d452fe3ec182beadd382563b63291" + integrity sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^4.0.1" + "@types/qs@*": version "6.14.0" resolved "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz" @@ -4994,6 +5003,11 @@ object-keys@^1.1.1: resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== +obuf@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + ollama-ai-provider-v2@^1.5.0: version "1.5.5" resolved "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-1.5.5.tgz" @@ -5186,6 +5200,78 @@ pend@~1.2.0: resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== +pg-cloudflare@^1.1.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497" + integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A== + +pg-connection-string@^2.7.0: + version "2.14.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9" + integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-numeric@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pg-numeric/-/pg-numeric-1.0.2.tgz#816d9a44026086ae8ae74839acd6a09b0636aa3a" + integrity sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw== + +pg-pool@^3.7.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90" + integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw== + +pg-protocol@*, pg-protocol@^1.7.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.0.tgz#cffb008826561ee9770a8a15dc21f269d731b305" + integrity sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg== + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg-types@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-4.1.0.tgz#49138d5ff1c94634d7eed42aa9ccba78ea2e52f0" + integrity sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg== + dependencies: + pg-int8 "1.0.1" + pg-numeric "1.0.2" + postgres-array "~3.0.1" + postgres-bytea "~3.0.0" + postgres-date "~2.1.0" + postgres-interval "^3.0.0" + postgres-range "^1.1.1" + +pg@8.13.1: + version "8.13.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080" + integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ== + dependencies: + pg-connection-string "^2.7.0" + pg-pool "^3.7.0" + pg-protocol "^1.7.0" + pg-types "^2.1.0" + pgpass "1.x" + optionalDependencies: + pg-cloudflare "^1.1.1" + +pgpass@1.x: + version "1.0.6" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.6.tgz#d3f6629b024b16fe2a9f8b033e6befc34e2ddb07" + integrity sha512-lqIfH7bdgsxHAY/ZnUOwm+aCFKrsHBDhSFuk9O0B9uCqJAIkrKTo/+LQqLPLUS4e04+jCmQVikxE3QipH5chPw== + picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" @@ -5306,6 +5392,55 @@ postcss-load-config@^6.0.1: dependencies: lilconfig "^3.1.1" +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-array@~3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-3.0.4.tgz#4efcaf4d2c688d8bcaa8620ed13f35f299f7528c" + integrity sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ== + +postgres-bytea@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz#c40b3da0222c500ff1e51c5d7014b60b79697c7a" + integrity sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ== + +postgres-bytea@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-3.0.0.tgz#9048dc461ac7ba70a6a42d109221619ecd1cb089" + integrity sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw== + dependencies: + obuf "~1.1.2" + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-date@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-2.1.0.tgz#b85d3c1fb6fb3c6c8db1e9942a13a3bf625189d0" + integrity sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +postgres-interval@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-3.0.0.tgz#baf7a8b3ebab19b7f38f07566c7aab0962f0c86a" + integrity sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw== + +postgres-range@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863" + integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w== + preact@10.28.3: version "10.28.3" resolved "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz" @@ -6409,6 +6544,11 @@ ws@^8.18.0: resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz" integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz"