diff --git a/package-lock.json b/package-lock.json index c2e89e6b..c89801d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@avoidwork/madz", - "version": "1.50.1", + "version": "1.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@avoidwork/madz", - "version": "1.50.1", + "version": "1.51.0", "license": "BSD-3-Clause", "dependencies": { "@langchain/langgraph": "^1.4.12", @@ -22,17 +22,20 @@ "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "cron-parser": "^5.10.0", + "csv-generate": "^4.4.1", "csv-parse": "^7.0.2", "csv-stringify": "^6.8.3", "deepagents": "^1.13.0", "exceljs": "^4.4.0", "googleapis": "^176.0.0", + "graphql-request": "^6.1.0", "imap-simple": "^5.1.0", "ink": "^7.1.1", "ink-scroll-view": "^0.3.7", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "js-yaml": "^5.3.0", + "jsonpath-plus": "^10.3.0", "marked": "^18.0.10", "node-emoji": "^2.2.0", "nodemailer": "^9.0.5", @@ -141,6 +144,15 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", @@ -356,6 +368,30 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, "node_modules/@langchain/core": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.9.tgz", @@ -3068,6 +3104,35 @@ "node": ">=18" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3082,6 +3147,12 @@ "node": ">= 8" } }, + "node_modules/csv-generate": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.6.1.tgz", + "integrity": "sha512-eELl9K716LSSeP2/YcCjch525JztnnERe3jEARWw2v1FN9ukUYfZTNYZ4Rq2Jj/MFKMauffOy9VCaqQTpFThDQ==", + "license": "MIT" + }, "node_modules/csv-parse": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz", @@ -3858,6 +3929,29 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", + "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "cross-fetch": "^3.1.5" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, "node_modules/gtoken": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", @@ -4409,6 +4503,15 @@ "js-yaml": "bin/js-yaml.mjs" } }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -4418,6 +4521,24 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -6417,6 +6538,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -6613,6 +6740,22 @@ "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 4b50fe77..94f5c0b8 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "cli-table3": "^0.6.5", "cron-parser": "^5.10.0", "csv-parse": "^7.0.2", + "csv-generate": "^4.4.1", "csv-stringify": "^6.8.3", "deepagents": "^1.13.0", "exceljs": "^4.4.0", @@ -83,7 +84,9 @@ "ink-scroll-view": "^0.3.7", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "graphql-request": "^6.1.0", "js-yaml": "^5.3.0", + "jsonpath-plus": "^10.3.0", "marked": "^18.0.10", "node-emoji": "^2.2.0", "nodemailer": "^9.0.5", diff --git a/src/tools/api.js b/src/tools/api.js new file mode 100644 index 00000000..84c31bb4 --- /dev/null +++ b/src/tools/api.js @@ -0,0 +1,160 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { filterUrl } from "../sandbox/urlFilter.js"; +import { parseSizeString } from "./common.js"; + +const DEFAULT_TIMEOUT = 30000; +const DEFAULT_MAX_RESPONSE_SIZE = "10mb"; + +/** + * Zod schema for REST API client input. + */ +export const ApiClientSchema = z.object({ + url: z.string().url().describe("Target URL (http/https only)"), + method: z + .enum(["GET", "POST", "PUT", "DELETE", "PATCH"]) + .optional() + .describe("HTTP method (default: GET)"), + headers: z.record(z.string()).optional().describe("Custom headers"), + body: z.any().optional().describe("Request body (auto-serialized for POST/PUT/PATCH)"), + auth: z + .object({ + type: z.enum(["bearer", "basic", "apikey"]).describe("Authentication type"), + token: z.string().optional().describe("Bearer token or API key"), + username: z.string().optional().describe("Basic auth username"), + password: z.string().optional().describe("Basic auth password"), + headerName: z.string().optional().describe("API key header name (default: X-API-Key)"), + }) + .optional() + .describe("Authentication configuration"), + timeout: z + .number() + .int() + .positive() + .optional() + .describe("Request timeout in ms (default: 30000)"), + maxResponseSize: z.string().optional().describe("Max response body size (default: 10mb)"), +}); + +/** + * Build auth headers from auth config. + * @param {object} auth - Authentication config + * @returns {object} Headers object with auth + */ +function buildAuthHeaders(auth) { + const headers = {}; + + if (auth.type === "bearer" && auth.token) { + headers["Authorization"] = `Bearer ${auth.token}`; + } else if (auth.type === "basic" && auth.username && auth.password) { + const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString("base64"); + headers["Authorization"] = `Basic ${encoded}`; + } else if (auth.type === "apikey" && auth.token) { + const headerName = auth.headerName || "X-API-Key"; + headers[headerName] = auth.token; + } + + return headers; +} + +/** + * Execute a REST API request with full security controls. + * @param {z.infer} input - Tool input + * @returns {Promise} Response object + */ +export async function apiClient(input) { + const { + url, + method = "GET", + headers: customHeaders = {}, + body, + auth, + timeout, + maxResponseSize, + } = input; + + // Validate URL + const urlValidation = filterUrl(url); + if (!urlValidation.allowed) { + return { ok: false, error: urlValidation.reason }; + } + + // Build request config + const timeoutMs = timeout || DEFAULT_TIMEOUT; + const maxBytes = parseSizeString(maxResponseSize || DEFAULT_MAX_RESPONSE_SIZE); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const requestHeaders = { ...customHeaders }; + + // Add auth headers + if (auth) { + const authHeaders = buildAuthHeaders(auth); + Object.assign(requestHeaders, authHeaders); + } + + // Auto-set content type for methods with body + if (body && ["POST", "PUT", "PATCH"].includes(method)) { + if (typeof body === "object" && !Buffer.isBuffer(body)) { + requestHeaders["Content-Type"] = "application/json"; + } + } + + const requestOptions = { + method, + headers: requestHeaders, + signal: controller.signal, + }; + + if (body && ["POST", "PUT", "PATCH"].includes(method)) { + requestOptions.body = typeof body === "string" ? body : JSON.stringify(body); + } + + try { + const response = await fetch(url, requestOptions); + clearTimeout(timeoutId); + + // Read body with size limit + const responseText = await response.text(); + + if (responseText.length > maxBytes) { + return { + ok: false, + error: `Response size (${responseText.length} bytes) exceeds limit (${maxBytes} bytes)`, + status: response.status, + statusText: response.statusText, + }; + } + + // Try to parse JSON response + let parsedBody; + try { + parsedBody = JSON.parse(responseText); + } catch (_err) { + parsedBody = responseText; + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: parsedBody, + headers: Object.fromEntries(response.headers.entries()), + }; + } catch (err) { + clearTimeout(timeoutId); + const reason = + err.name === "AbortError" ? `Request timed out after ${timeoutMs}ms` : err.message; + return { ok: false, error: reason }; + } +} + +/** + * REST API client tool — make authenticated HTTP requests. + */ +export const apiClientTool = tool(apiClient, { + name: "apiClient", + description: + "Make authenticated REST API requests (GET, POST, PUT, DELETE, PATCH) with URL validation, timeout, and response size limits.", + schema: ApiClientSchema, +}); diff --git a/src/tools/data.js b/src/tools/data.js new file mode 100644 index 00000000..ffa43c59 --- /dev/null +++ b/src/tools/data.js @@ -0,0 +1,599 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { load as loadYaml, dump as dumpYaml } from "js-yaml"; +import { JSONPath } from "jsonpath-plus"; +import { parse } from "csv-parse/sync"; +import { stringify as stringifyCsv } from "csv-stringify/sync"; + +/** + * Zod schema for data manipulation and transformation input. + */ +export const DataToolSchema = z.object({ + action: z + .enum([ + "parse", + "serialize", + "filter", + "transform", + "validate", + "yamlParse", + "yamlSerialize", + "yamlPath", + "yamlTransform", + "yamlValidate", + "jsonToCsv", + "csvToJson", + "jsonToYaml", + "yamlToJson", + "mapFields", + ]) + .describe("Data action to perform"), + input: z.string().optional().describe("String input (JSON/YAML/CSV)"), + data: z.any().optional().describe("Object input (for serialization)"), + path: z.string().optional().describe("JSONPath or dot-notation path"), + schema: z.any().optional().describe("Schema for validation"), + mapping: z + .object({ + renames: z.record(z.string()).optional().describe("Field rename map"), + additions: z.record(z.any()).optional().describe("Fields to add"), + removals: z.array(z.string()).optional().describe("Fields to remove"), + }) + .optional() + .describe("Transformation mapping rules"), + indent: z.number().optional().describe("Serialization indent (default: 2)"), +}); + +/** + * Parse JSON string to object. + * @param {string} input - JSON string + * @returns {object} Parse result + */ +function parseJson(input) { + if (!input || typeof input !== "string") { + return { ok: false, error: "Input must be a non-empty string" }; + } + try { + const parsed = JSON.parse(input); + return { ok: true, data: parsed }; + } catch (err) { + return { ok: false, error: `JSON parse error: ${err.message}` }; + } +} + +/** + * Serialize object to JSON string. + * @param {object} data - Object to serialize + * @param {number} [indent=2] - Indentation + * @returns {object} Serialization result + */ +function serializeJson(data, indent = 2) { + try { + const serialized = JSON.stringify(data, null, indent); + return { ok: true, data: serialized }; + } catch (err) { + return { ok: false, error: `Serialization error: ${err.message}` }; + } +} + +/** + * Filter JSON using JSONPath. + * @param {object} data - Parsed JSON data + * @param {string} path - JSONPath expression + * @returns {object} Filter result + */ +function filterJson(data, path) { + if (!path) { + return { ok: false, error: "path is required for filter action" }; + } + try { + const results = JSONPath({ path, json: data }); + return { ok: true, data: results }; + } catch (err) { + return { ok: false, error: `JSONPath error: ${err.message}` }; + } +} + +/** + * Transform JSON using mapping rules. + * @param {object} data - Object to transform + * @param {object} mapping - Mapping rules + * @returns {object} Transformation result + */ +function transformJson(data, mapping) { + if (!data || typeof data !== "object" || Array.isArray(data)) { + return { ok: false, error: "Transform requires an object (not array) as input" }; + } + + const result = { ...data }; + + // Apply renames + if (mapping?.renames) { + for (const [from, to] of Object.entries(mapping.renames)) { + if (result[from] !== undefined) { + result[to] = result[from]; + delete result[from]; + } + } + } + + // Apply additions + if (mapping?.additions) { + Object.assign(result, mapping.additions); + } + + // Apply removals + if (mapping?.removals) { + for (const field of mapping.removals) { + delete result[field]; + } + } + + return { ok: true, data: result }; +} + +/** + * Validate JSON against a schema using zod-like validation. + * @param {object} data - Data to validate + * @param {object} schema - Schema definition + * @returns {object} Validation result + */ +function validateJson(data, schema) { + if (!schema) { + return { ok: false, error: "schema is required for validate action" }; + } + + const errors = []; + const valid = validateAgainstSchema(data, schema, errors, ""); + + return { + ok: true, + valid, + errors: valid ? [] : errors, + }; +} + +/** + * Recursively validate data against a schema. + * @param {any} data - Data to validate + * @param {object} schema - Schema definition + * @param {string[]} errors - Error accumulator + * @param {string} path - Current path + * @returns {boolean} Whether valid + */ +function validateAgainstSchema(data, schema, errors, path) { + if (schema.type === "object") { + if (typeof data !== "object" || Array.isArray(data)) { + errors.push(`${path || "root"}: expected object, got ${typeof data}`); + return false; + } + if (schema.properties) { + let valid = true; + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (schema.required && schema.required.includes(key)) { + if (!(key in data)) { + errors.push(`${path ? `${path}.` : ""}${key}: required field missing`); + valid = false; + } + } + if (key in data) { + const fieldValid = validateAgainstSchema( + data[key], + propSchema, + errors, + `${path ? `${path}.` : ""}${key}`, + ); + if (!fieldValid) valid = false; + } + } + return valid; + } + return true; + } + + if (schema.type === "array") { + if (!Array.isArray(data)) { + errors.push(`${path || "root"}: expected array, got ${typeof data}`); + return false; + } + if (schema.items) { + for (let i = 0; i < data.length; i++) { + const valid = validateAgainstSchema( + data[i], + schema.items, + errors, + `${path ? `${path}.` : ""}[${i}]`, + ); + if (!valid) return false; + } + } + return true; + } + + if (schema.type === "string") { + if (typeof data !== "string") { + errors.push(`${path || "root"}: expected string, got ${typeof data}`); + return false; + } + } + + if (schema.type === "number" || schema.type === "integer") { + if (typeof data !== "number") { + errors.push(`${path || "root"}: expected ${schema.type}, got ${typeof data}`); + return false; + } + } + + if (schema.type === "boolean") { + if (typeof data !== "boolean") { + errors.push(`${path || "root"}: expected boolean, got ${typeof data}`); + return false; + } + } + + return true; +} + +/** + * Parse YAML string to object. + * @param {string} input - YAML string + * @returns {object} Parse result + */ +function parseYaml(input) { + if (!input || typeof input !== "string") { + return { ok: false, error: "Input must be a non-empty string" }; + } + try { + const parsed = loadYaml(input); + return { ok: true, data: parsed }; + } catch (err) { + return { ok: false, error: `YAML parse error: ${err.message}` }; + } +} + +/** + * Serialize object to YAML string. + * @param {object} data - Object to serialize + * @param {number} [indent=2] - Indentation + * @returns {object} Serialization result + */ +function serializeYaml(data, indent = 2) { + try { + const serialized = dumpYaml(data, { indent, lineWidth: 100 }); + return { ok: true, data: serialized }; + } catch (err) { + return { ok: false, error: `YAML serialization error: ${err.message}` }; + } +} + +/** + * Access YAML data using dot-notation path. + * @param {object} data - Parsed YAML data + * @param {string} path - Dot-notation path (e.g., "database.host") + * @returns {object} Access result + */ +function accessYamlPath(data, path) { + if (!path) { + return { ok: false, error: "path is required for path access" }; + } + const parts = path + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter(Boolean); + let current = data; + for (const part of parts) { + if (current === null || current === undefined) { + return { ok: true, data: null }; + } + current = current[part]; + } + return { ok: true, data: current }; +} + +/** + * Transform YAML using mapping rules. + * @param {object} data - Object to transform + * @param {object} mapping - Mapping rules + * @returns {object} Transformation result + */ +function transformYaml(data, mapping) { + if (!data || typeof data !== "object" || Array.isArray(data)) { + return { ok: false, error: "Transform requires an object (not array) as input" }; + } + + const result = { ...data }; + + if (mapping?.renames) { + for (const [from, to] of Object.entries(mapping.renames)) { + if (result[from] !== undefined) { + result[to] = result[from]; + delete result[from]; + } + } + } + if (mapping?.additions) { + Object.assign(result, mapping.additions); + } + if (mapping?.removals) { + for (const field of mapping.removals) { + delete result[field]; + } + } + + return { ok: true, data: result }; +} + +/** + * Validate YAML data structure. + * @param {object} data - Parsed YAML data + * @param {object} schema - Schema definition + * @returns {object} Validation result + */ +function validateYaml(data, schema) { + if (!schema) { + return { ok: false, error: "schema is required for validate action" }; + } + const errors = []; + const valid = validateAgainstSchema(data, schema, errors, ""); + return { + ok: true, + valid, + errors: valid ? [] : errors, + }; +} + +/** + * Convert JSON array to CSV. + * @param {object[]} data - Array of objects + * @param {object} mapping - Optional field mapping + * @returns {object} CSV result + */ +function jsonToCsv(data) { + if (!Array.isArray(data)) { + return { ok: false, error: "JSON to CSV requires an array of objects" }; + } + if (data.length === 0) { + return { ok: true, data: "" }; + } + + // Flatten nested objects + const flatten = (obj, prefix = "") => { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + Object.assign(result, flatten(value, fullKey)); + } else { + result[fullKey] = value; + } + } + return result; + }; + + const flattened = data.map((row) => flatten(row)); + const headers = [...new Set(flattened.flatMap(Object.keys))]; + + const records = flattened.map((row) => { + const record = {}; + for (const header of headers) { + record[header] = row[header] !== undefined ? row[header] : ""; + } + return record; + }); + + const csv = stringifyCsv(records, { + header: true, + columns: headers.map((h) => ({ header: h, key: h })), + }); + return { ok: true, data: csv }; +} + +/** + * Convert CSV to JSON array. + * @param {string} input - CSV string + * @returns {object} JSON result + */ +function csvToJson(input) { + if (!input || typeof input !== "string") { + return { ok: false, error: "Input must be a non-empty CSV string" }; + } + try { + const records = parse(input, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + return { ok: true, data: records }; + } catch (err) { + return { ok: false, error: `CSV parse error: ${err.message}` }; + } +} + +/** + * Convert JSON to YAML. + * @param {object} data - JSON data + * @param {number} [indent=2] - Indentation + * @returns {object} YAML result + */ +function jsonToYaml(data, indent = 2) { + try { + const yaml = dumpYaml(data, { indent, lineWidth: 100 }); + return { ok: true, data: yaml }; + } catch (err) { + return { ok: false, error: `JSON to YAML error: ${err.message}` }; + } +} + +/** + * Convert YAML to JSON. + * @param {string} input - YAML string + * @returns {object} JSON result + */ +function yamlToJson(input) { + if (!input || typeof input !== "string") { + return { ok: false, error: "Input must be a non-empty YAML string" }; + } + try { + const parsed = loadYaml(input); + return { ok: true, data: JSON.stringify(parsed, null, 2) }; + } catch (err) { + return { ok: false, error: `YAML to JSON error: ${err.message}` }; + } +} + +/** + * Apply field mapping to data. + * @param {object|object[]} data - Data to transform + * @param {object} mapping - Mapping rules + * @returns {object} Transformed result + */ +function applyMapping(data, mapping) { + if (!mapping) return { ok: true, data }; + + if (Array.isArray(data)) { + const results = data.map((item) => { + const result = { ...item }; + if (mapping.renames) { + for (const [from, to] of Object.entries(mapping.renames)) { + if (result[from] !== undefined) { + result[to] = result[from]; + delete result[from]; + } + } + } + if (mapping.additions) { + Object.assign(result, mapping.additions); + } + if (mapping.removals) { + for (const field of mapping.removals) { + delete result[field]; + } + } + return result; + }); + return { ok: true, data: results }; + } + + return transformJson(data, mapping); +} + +/** + * Execute data manipulation/transformation action. + * @param {z.infer} input - Tool input + * @returns {Promise} Result object + */ +export async function dataTool(input) { + const { action } = input; + + switch (action) { + // JSON actions + case "parse": { + return parseJson(input.input); + } + case "serialize": { + return serializeJson(input.data, input.indent); + } + case "filter": { + if (!input.input) return { ok: false, error: "input is required for filter action" }; + const parseResult = parseJson(input.input); + if (!parseResult.ok) return parseResult; + return filterJson(parseResult.data, input.path); + } + case "transform": { + if (!input.data) return { ok: false, error: "data is required for transform action" }; + return transformJson(input.data, input.mapping); + } + case "validate": { + if (!input.input) return { ok: false, error: "input is required for validate action" }; + if (!input.schema) return { ok: false, error: "schema is required for validate action" }; + const parseResult = parseJson(input.input); + if (!parseResult.ok) return parseResult; + return validateJson(parseResult.data, input.schema); + } + + // YAML actions + case "yamlParse": { + return parseYaml(input.input); + } + case "yamlSerialize": { + return serializeYaml(input.data, input.indent); + } + case "yamlPath": { + if (!input.input) return { ok: false, error: "input is required for yamlPath action" }; + const parseResult = parseYaml(input.input); + if (!parseResult.ok) return parseResult; + return accessYamlPath(parseResult.data, input.path); + } + case "yamlTransform": { + if (!input.input) return { ok: false, error: "input is required for yamlTransform action" }; + const parseResult = parseYaml(input.input); + if (!parseResult.ok) return parseResult; + return transformYaml(parseResult.data, input.mapping); + } + case "yamlValidate": { + if (!input.input) return { ok: false, error: "input is required for yamlValidate action" }; + if (!input.schema) return { ok: false, error: "schema is required for yamlValidate action" }; + const parseResult = parseYaml(input.input); + if (!parseResult.ok) return parseResult; + return validateYaml(parseResult.data, input.schema); + } + + // Data transformation actions + case "jsonToCsv": { + if (!input.input) return { ok: false, error: "input is required for jsonToCsv action" }; + const parseResult = parseJson(input.input); + if (!parseResult.ok) return parseResult; + return jsonToCsv(parseResult.data, input.mapping); + } + case "csvToJson": { + return csvToJson(input.input); + } + case "jsonToYaml": { + if (!input.input) return { ok: false, error: "input is required for jsonToYaml action" }; + const parseResult = parseJson(input.input); + if (!parseResult.ok) return parseResult; + return jsonToYaml(parseResult.data, input.indent); + } + case "yamlToJson": { + return yamlToJson(input.input); + } + case "mapFields": { + if (!input.input) return { ok: false, error: "input is required for mapFields action" }; + // Try JSON first, then YAML + let parsed; + let isJson = false; + try { + parsed = JSON.parse(input.input); + isJson = true; + } catch (_err) { + try { + parsed = loadYaml(input.input); + isJson = false; + } catch (_err2) { + return { ok: false, error: "Input is neither valid JSON nor YAML" }; + } + } + const result = applyMapping(parsed, input.mapping); + if (!result.ok) return result; + // Return in the same format + return isJson + ? { ok: true, data: JSON.stringify(result.data, null, 2) } + : { ok: true, data: stringify(result.data, { indent: 2, lineWidth: 100 }) }; + } + + default: + return { + ok: false, + error: `Unknown action: "${action}". Valid actions: parse, serialize, filter, transform, validate, yamlParse, yamlSerialize, yamlPath, yamlTransform, yamlValidate, jsonToCsv, csvToJson, jsonToYaml, yamlToJson, mapFields`, + }; + } +} + +/** + * Data manipulation tool — JSON, YAML, CSV parsing, filtering, transformation, and conversion. + */ +export const dataToolTool = tool(dataTool, { + name: "dataTool", + description: + "Manipulate structured data: parse/serialize JSON/YAML, JSONPath filtering, field mapping, and format conversion (JSON↔CSV, JSON↔YAML).", + schema: DataToolSchema, +}); diff --git a/src/tools/graphql.js b/src/tools/graphql.js new file mode 100644 index 00000000..47fa6232 --- /dev/null +++ b/src/tools/graphql.js @@ -0,0 +1,206 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { filterUrl } from "../sandbox/urlFilter.js"; + +const DEFAULT_TIMEOUT = 30000; +const DEFAULT_MAX_DEPTH = 10; +const DEFAULT_MAX_COMPLEXITY = 1000; + +/** + * Zod schema for GraphQL client input. + */ +export const GraphQLClientSchema = z.object({ + url: z.string().url().describe("GraphQL endpoint URL"), + query: z.string().describe("GraphQL query or mutation string"), + variables: z.record(z.any()).optional().describe("Query variables"), + operationName: z.string().optional().describe("Operation name"), + timeout: z + .number() + .int() + .positive() + .optional() + .describe("Request timeout in ms (default: 30000)"), + headers: z.record(z.string()).optional().describe("Custom headers"), + introspection: z.boolean().optional().describe("Enable schema introspection (default: false)"), + maxDepth: z.number().int().positive().optional().describe("Max query depth (default: 10)"), + maxComplexity: z + .number() + .int() + .positive() + .optional() + .describe("Max query complexity (default: 1000)"), +}); + +/** + * Calculate query depth from AST. + * @param {string} query - GraphQL query string + * @returns {number} Maximum depth + */ +function calculateDepth(query) { + let maxDepth = 0; + let currentDepth = 0; + let inBlock = false; + + for (let i = 0; i < query.length; i++) { + const char = query[i]; + if (char === "{") { + currentDepth++; + if (currentDepth > maxDepth) maxDepth = currentDepth; + } else if (char === "}") { + currentDepth--; + } else if (char === '"') { + // Skip string literals + inBlock = !inBlock; + while (inBlock && i < query.length - 1) { + i++; + if (query[i] === '"') { + inBlock = false; + } + } + } + } + + return maxDepth; +} + +/** + * Estimate query complexity. + * @param {string} query - GraphQL query string + * @returns {number} Estimated complexity + */ +function estimateComplexity(query) { + // Count field selections (rough estimate) + const fieldMatches = query.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\s*\(/g); + const fieldCount = fieldMatches ? fieldMatches.length : 0; + + // Count nested selections + const openBraces = (query.match(/\{/g) || []).length; + const closeBraces = (query.match(/\}/g) || []).length; + const nestingLevel = Math.min(openBraces, closeBraces); + + // Each level of nesting multiplies complexity + return fieldCount * (nestingLevel + 1); +} + +/** + * Check if a query is an introspection query. + * @param {string} query - GraphQL query string + * @returns {boolean} + */ +function isIntrospectionQuery(query) { + return query.includes("__schema") || query.includes("__type"); +} + +/** + * Execute a GraphQL query or mutation. + * @param {z.infer} input - Tool input + * @returns {Promise} Response object + */ +export async function graphQLClient(input) { + const { + url, + query, + variables, + operationName, + timeout, + headers: customHeaders = {}, + introspection, + maxDepth, + maxComplexity, + } = input; + + // Validate URL + const urlValidation = filterUrl(url); + if (!urlValidation.allowed) { + return { ok: false, error: urlValidation.reason }; + } + + // Check introspection + if (!introspection && isIntrospectionQuery(query)) { + return { ok: false, error: "Introspection is disabled. Enable introspection to query schema." }; + } + + // Check depth limit + const depth = calculateDepth(query); + if (depth > (maxDepth || DEFAULT_MAX_DEPTH)) { + return { + ok: false, + error: `Query depth (${depth}) exceeds limit (${maxDepth || DEFAULT_MAX_DEPTH})`, + }; + } + + // Check complexity limit + const complexity = estimateComplexity(query); + if (complexity > (maxComplexity || DEFAULT_MAX_COMPLEXITY)) { + return { + ok: false, + error: `Query complexity (${complexity}) exceeds limit (${maxComplexity || DEFAULT_MAX_COMPLEXITY})`, + }; + } + + // Build request + const timeoutMs = timeout || DEFAULT_TIMEOUT; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const requestBody = { query }; + if (variables) requestBody.variables = variables; + if (operationName) requestBody.operationName = operationName; + + const requestHeaders = { + ...customHeaders, + "Content-Type": "application/json", + }; + + try { + const response = await fetch(url, { + method: "POST", + headers: requestHeaders, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const responseText = await response.text(); + let parsedBody; + try { + parsedBody = JSON.parse(responseText); + } catch (_err) { + return { + ok: false, + error: `Non-JSON response: ${responseText.slice(0, 500)}`, + status: response.status, + }; + } + + // Check for GraphQL errors + if (parsedBody.errors && parsedBody.errors.length > 0) { + return { + ok: false, + errors: parsedBody.errors, + data: parsedBody.data, + }; + } + + return { + ok: true, + data: parsedBody.data, + }; + } catch (err) { + clearTimeout(timeoutId); + const reason = + err.name === "AbortError" ? `Request timed out after ${timeoutMs}ms` : err.message; + return { ok: false, error: reason }; + } +} + +/** + * GraphQL client tool — execute queries and mutations. + */ +export const graphQLClientTool = tool(graphQLClient, { + name: "graphQLClient", + description: + "Execute GraphQL queries and mutations with depth/complexity limits, timeout, and optional schema introspection.", + schema: GraphQLClientSchema, +}); diff --git a/src/tools/index.js b/src/tools/index.js index f2712943..a9c62830 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -24,6 +24,10 @@ import { calendar } from "./calendar/index.js"; import { pdfGenerateTool } from "./pdfGenerate.js"; import { namecom } from "./namecom/index.js"; import { pptxGenerateTool } from "./fileCreate/pptx.js"; +import { apiClientTool } from "./api.js"; +import { graphQLClientTool } from "./graphql.js"; +import { webhookManagementTool } from "./webhooks.js"; +import { dataToolTool } from "./data.js"; /** * Maps tool names to required permission scopes. @@ -58,6 +62,10 @@ export const TOOL_PERMISSIONS = { pdfGenerate: ["filesystem:read", "filesystem:write", "network:outbound"], namecom: ["network:outbound"], pptxGenerate: ["filesystem:write"], + apiClientTool: ["network:outbound"], + graphQLClientTool: ["network:outbound"], + webhookManagementTool: ["network:outbound"], + dataToolTool: ["filesystem:read"], }; /** @@ -123,6 +131,10 @@ export const TOOL_CLASSIFICATIONS = { pdfGenerate: ["search", "research", "coding", "documentation", "debug"], namecom: ["search", "research", "coding", "documentation", "debug"], pptxGenerate: ["search", "research", "coding", "documentation", "debug"], + apiClientTool: ["search", "research", "coding", "debug"], + graphQLClientTool: ["search", "research", "coding", "debug"], + webhookManagementTool: ["security-audit", "coding", "debug"], + dataToolTool: ["search", "research", "coding", "documentation", "debug"], }; /** @@ -189,6 +201,10 @@ export const TOOLS = { pdfGenerate: pdfGenerateTool, namecom, pptxGenerate: pptxGenerateTool, + apiClientTool, + graphQLClientTool, + webhookManagementTool, + dataToolTool, }; /** diff --git a/src/tools/webhooks.js b/src/tools/webhooks.js new file mode 100644 index 00000000..3f0d8899 --- /dev/null +++ b/src/tools/webhooks.js @@ -0,0 +1,303 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { createHash, randomUUID } from "node:crypto"; +import { filterUrl } from "../sandbox/urlFilter.js"; + +const DEFAULT_RATE_LIMIT = 100; +const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Zod schema for webhook management input. + */ +export const WebhookSchema = z.object({ + action: z + .enum(["create", "list", "delete", "verify", "deliver"]) + .describe("Webhook action to perform"), + url: z.string().url().optional().describe("Webhook URL"), + secret: z.string().optional().describe("Webhook secret for HMAC signing"), + events: z.array(z.string()).optional().describe("Events to subscribe to"), + payload: z.any().optional().describe("Webhook payload"), + webhookId: z.string().optional().describe("Webhook ID for delete/verify/deliver"), + signature: z.string().optional().describe("HMAC signature header value"), + timestamp: z.string().optional().describe("Timestamp header value (ISO 8601)"), + headers: z.record(z.string()).optional().describe("Custom headers for delivery"), + rateLimit: z + .number() + .int() + .positive() + .optional() + .describe("Max deliveries per minute (default: 100)"), +}); + +/** + * In-memory webhook store. + * @type {Map} + */ +const webhookStore = new Map(); + +/** + * Rate limiter state per webhook. + * @type {Map} + */ +const rateLimitState = new Map(); + +/** + * Mask a secret string for safe display. + * @param {string} secret - Secret to mask + * @returns {string} Masked secret + */ +function maskSecret(secret) { + if (!secret || secret.length <= 4) return "****"; + return secret.slice(0, 2) + "*".repeat(secret.length - 4) + secret.slice(-2); +} + +/** + * Verify HMAC-SHA256 signature. + * @param {string} payload - Raw payload string + * @param {string} signature - Expected signature (hex) + * @param {string} secret - Secret key + * @returns {boolean} Whether signature is valid + */ +function verifyHmacSignature(payload, signature, secret) { + if (!signature || !secret) return false; + const expected = createHash("sha256").update(payload).digest("hex"); + return signature === expected; +} + +/** + * Validate timestamp against 5-minute window. + * @param {string} timestampStr - ISO 8601 timestamp string + * @returns {boolean} Whether timestamp is valid + */ +function validateTimestamp(timestampStr) { + if (!timestampStr) return false; + try { + const timestamp = new Date(timestampStr).getTime(); + const now = Date.now(); + return Math.abs(now - timestamp) <= TIMESTAMP_WINDOW_MS; + } catch (_err) { + return false; + } +} + +/** + * Check rate limit for a webhook. + * @param {string} webhookId - Webhook ID + * @param {number} rateLimit - Max deliveries per minute + * @returns {{ allowed: boolean, retryAfter?: number }} + */ +function checkRateLimit(webhookId, rateLimit = DEFAULT_RATE_LIMIT) { + const now = Date.now(); + const windowMs = 60 * 1000; // 1 minute + + if (!rateLimitState.has(webhookId)) { + rateLimitState.set(webhookId, []); + } + + const timestamps = rateLimitState.get(webhookId); + // Remove timestamps outside the window + while (timestamps.length > 0 && now - timestamps[0] > windowMs) { + timestamps.shift(); + } + + if (timestamps.length >= rateLimit) { + const oldest = timestamps[0]; + const retryAfter = Math.ceil((oldest + windowMs - now) / 1000); + return { allowed: false, retryAfter }; + } + + timestamps.push(now); + return { allowed: true }; +} + +/** + * Deliver a webhook payload to a registered endpoint. + * @param {string} webhookId - Webhook ID + * @param {object} payload - Payload to deliver + * @param {number} rateLimit - Rate limit per minute + * @returns {Promise} Delivery result + */ +async function deliverWebhook(webhookId, payload, rateLimit = DEFAULT_RATE_LIMIT) { + const webhook = webhookStore.get(webhookId); + if (!webhook) { + return { ok: false, error: `Webhook not found: ${webhookId}` }; + } + + // Check rate limit + const rateCheck = checkRateLimit(webhookId, rateLimit); + if (!rateCheck.allowed) { + return { + ok: false, + error: `Rate limit exceeded. Retry after ${rateCheck.retryAfter}s`, + rateLimited: true, + retryAfter: rateCheck.retryAfter, + }; + } + + // Build payload with metadata + const deliveryPayload = { + ...payload, + _id: webhookId, + _events: webhook.events || ["*"], + _timestamp: new Date().toISOString(), + }; + + // Sign payload + const payloadStr = JSON.stringify(deliveryPayload); + const signature = createHash("sha256") + .update(payloadStr + webhook.secret) + .digest("hex"); + + // Build headers + const headers = { + "Content-Type": "application/json", + "X-Webhook-Signature": `sha256=${signature}`, + "X-Webhook-Timestamp": new Date().toISOString(), + "X-Webhook-Id": webhookId, + ...webhook.headers, + }; + + // Deliver + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(webhook.url, { + method: "POST", + headers, + body: payloadStr, + signal: controller.signal, + }); + clearTimeout(timeoutId); + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + }; + } catch (err) { + clearTimeout(timeoutId); + return { + ok: false, + error: `Delivery failed: ${err.message}`, + }; + } +} + +/** + * Execute webhook management action. + * @param {z.infer} input - Tool input + * @returns {Promise} Result object + */ +export async function webhookManagement(input) { + const { action } = input; + + switch (action) { + case "create": { + const { url, secret, events, headers } = input; + if (!url) return { ok: false, error: "url is required for create action" }; + + const urlValidation = filterUrl(url); + if (!urlValidation.allowed) { + return { ok: false, error: urlValidation.reason }; + } + + const webhookId = randomUUID(); + webhookStore.set(webhookId, { + url, + secret: secret || randomUUID(), + events: events || ["*"], + headers: headers || {}, + createdAt: new Date().toISOString(), + }); + + return { + ok: true, + webhookId, + url, + secret: webhookStore.get(webhookId).secret, + events: events || ["*"], + message: "Webhook created successfully", + }; + } + + case "list": { + const webhooks = []; + for (const [id, webhook] of webhookStore) { + webhooks.push({ + webhookId: id, + url: webhook.url, + events: webhook.events, + createdAt: webhook.createdAt, + secret: maskSecret(webhook.secret), + }); + } + return { ok: true, webhooks }; + } + + case "delete": { + const { webhookId } = input; + if (!webhookId) { + return { ok: false, error: "webhookId is required for delete action" }; + } + if (!webhookStore.has(webhookId)) { + return { ok: false, error: `Webhook not found: ${webhookId}` }; + } + webhookStore.delete(webhookId); + rateLimitState.delete(webhookId); + return { ok: true, message: `Webhook ${webhookId} deleted` }; + } + + case "verify": { + const { webhookId, payload, signature, timestamp } = input; + if (!webhookId) { + return { ok: false, error: "webhookId is required for verify action" }; + } + const webhook = webhookStore.get(webhookId); + if (!webhook) { + return { ok: false, error: `Webhook not found: ${webhookId}` }; + } + + // Validate timestamp + if (timestamp && !validateTimestamp(timestamp)) { + return { ok: false, error: "Webhook timestamp expired (5-minute window)" }; + } + + // Verify signature + const payloadStr = typeof payload === "string" ? payload : JSON.stringify(payload); + const valid = verifyHmacSignature(payloadStr, signature, webhook.secret); + if (!valid) { + return { ok: false, error: "Invalid HMAC-SHA256 signature" }; + } + + return { ok: true, valid: true, message: "Webhook signature verified" }; + } + + case "deliver": { + const { webhookId, payload, rateLimit } = input; + if (!webhookId) { + return { ok: false, error: "webhookId is required for deliver action" }; + } + if (!payload) { + return { ok: false, error: "payload is required for deliver action" }; + } + return await deliverWebhook(webhookId, payload, rateLimit); + } + + default: + return { + ok: false, + error: `Unknown action: "${action}". Valid actions: create, list, delete, verify, deliver`, + }; + } +} + +/** + * Webhook management tool — create, list, delete, verify, and deliver webhooks. + */ +export const webhookManagementTool = tool(webhookManagement, { + name: "webhookManagement", + description: + "Manage webhooks: create, list, delete, verify HMAC-SHA256 signatures, and deliver payloads with rate limiting.", + schema: WebhookSchema, +}); diff --git a/tests/unit/tool_index.test.js b/tests/unit/tool_index.test.js index edb21550..62410f1b 100644 --- a/tests/unit/tool_index.test.js +++ b/tests/unit/tool_index.test.js @@ -154,8 +154,8 @@ describe("tools - buildToolConfig", () => { }); const toolNames = tools.map((t) => t.name); // filesystem:read enables: clarify, sampling, process (exempt), compactContext, scanAgents, - // sessionSearch, date, reflectionSessions, docx, pptx, xlsx, pdf - assert.strictEqual(toolNames.length, 12); + // sessionSearch, date, reflectionSessions, docx, pptx, xlsx, pdf, dataToolTool + assert.strictEqual(toolNames.length, 13); assert.ok(toolNames.includes("clarify")); assert.ok(toolNames.includes("sampling")); assert.ok(toolNames.includes("date")); @@ -166,5 +166,6 @@ describe("tools - buildToolConfig", () => { assert.ok(toolNames.includes("pptx")); assert.ok(toolNames.includes("xlsx")); assert.ok(toolNames.includes("pdf")); + assert.ok(toolNames.includes("dataTool")); }); }); diff --git a/tests/unit/tools/apiClient.test.js b/tests/unit/tools/apiClient.test.js new file mode 100644 index 00000000..168f30fa --- /dev/null +++ b/tests/unit/tools/apiClient.test.js @@ -0,0 +1,50 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { apiClientTool, apiClient, ApiClientSchema } from "../../../src/tools/api.js"; + +describe("apiClient tool - apiClient", () => { + it("rejects file:// URLs", async () => { + const result = await apiClient({ url: "file:///etc/passwd" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("rejects gopher:// URLs", async () => { + const result = await apiClient({ url: "gopher://example.com" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("rejects dict:// URLs", async () => { + const result = await apiClient({ url: "dict://example.com" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("accepts http:// URLs", async () => { + const result = await apiClient({ url: "http://example.com" }); + // Will fail to connect but should not be blocked by URL validation + assert.ok(result.status !== undefined || result.error); + }); + + it("accepts https:// URLs", async () => { + const result = await apiClient({ url: "https://example.com" }); + assert.ok(result.status !== undefined || result.error); + }); + + it("has correct schema", () => { + assert.ok(ApiClientSchema); + const shape = ApiClientSchema.shape; + assert.ok(shape.url); + assert.ok(shape.method); + assert.ok(shape.headers); + assert.ok(shape.body); + assert.ok(shape.auth); + assert.ok(shape.timeout); + }); + + it("is exported correctly", () => { + assert.ok(apiClientTool); + assert.ok(apiClient); + }); +}); diff --git a/tests/unit/tools/data.test.js b/tests/unit/tools/data.test.js new file mode 100644 index 00000000..8c6e55aa --- /dev/null +++ b/tests/unit/tools/data.test.js @@ -0,0 +1,270 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { dataTool, DataToolSchema } from "../../../src/tools/data.js"; + +describe("dataTool tool - dataTool", () => { + describe("JSON parsing", () => { + it("parses valid JSON", async () => { + const result = await dataTool({ action: "parse", input: '{"foo": "bar"}' }); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.data, { foo: "bar" }); + }); + + it("rejects invalid JSON", async () => { + const result = await dataTool({ action: "parse", input: "not json" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("rejects empty string", async () => { + const result = await dataTool({ action: "parse", input: "" }); + assert.strictEqual(result.ok, false); + }); + }); + + describe("JSON serialization", () => { + it("serializes object", async () => { + const result = await dataTool({ action: "serialize", data: { foo: "bar" } }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes('"foo"')); + }); + + it("serializes with custom indent", async () => { + const result = await dataTool({ action: "serialize", data: { a: 1 }, indent: 4 }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes(" ")); + }); + }); + + describe("JSONPath filtering", () => { + it("filters by key path", async () => { + const result = await dataTool({ + action: "filter", + input: JSON.stringify([{ name: "Alice" }, { name: "Bob" }]), + path: "$[*].name", + }); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.data, ["Alice", "Bob"]); + }); + + it("returns empty array for no matches", async () => { + const result = await dataTool({ + action: "filter", + input: JSON.stringify({ a: 1 }), + path: "$.nonexistent", + }); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.data, []); + }); + }); + + describe("JSON transformation", () => { + it("renames fields", async () => { + const result = await dataTool({ + action: "transform", + data: { name: "Alice", age: 30 }, + mapping: { renames: { name: "title" } }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.title, "Alice"); + assert.strictEqual(result.data.name, undefined); + }); + + it("adds fields", async () => { + const result = await dataTool({ + action: "transform", + data: { name: "Alice" }, + mapping: { additions: { age: 30 } }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.age, 30); + }); + + it("removes fields", async () => { + const result = await dataTool({ + action: "transform", + data: { name: "Alice", password: "secret" }, + mapping: { removals: ["password"] }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.password, undefined); + }); + }); + + describe("YAML parsing", () => { + it("parses valid YAML", async () => { + const result = await dataTool({ action: "yamlParse", input: "foo: bar\nbaz: 123" }); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.data, { foo: "bar", baz: 123 }); + }); + + it("rejects invalid YAML", async () => { + const result = await dataTool({ action: "yamlParse", input: "{{invalid yaml" }); + assert.strictEqual(result.ok, false); + }); + }); + + describe("YAML serialization", () => { + it("serializes object", async () => { + const result = await dataTool({ action: "yamlSerialize", data: { foo: "bar" } }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes("foo")); + }); + }); + + describe("YAML path access", () => { + it("accesses nested value", async () => { + const result = await dataTool({ + action: "yamlPath", + input: "database:\n host: localhost\n port: 5432", + path: "database.host", + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, "localhost"); + }); + + it("returns null for non-existent path", async () => { + const result = await dataTool({ + action: "yamlPath", + input: "{ a: 1 }", + path: "nonexistent", + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, undefined); + }); + }); + + describe("JSON to CSV", () => { + it("converts simple array", async () => { + const result = await dataTool({ + action: "jsonToCsv", + input: JSON.stringify([ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ]), + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes("name")); + assert.ok(result.data.includes("Alice")); + }); + + it("handles missing fields", async () => { + const result = await dataTool({ + action: "jsonToCsv", + input: JSON.stringify([{ name: "Alice" }, { name: "Bob", age: 25 }]), + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes("name")); + }); + }); + + describe("CSV to JSON", () => { + it("converts CSV", async () => { + const result = await dataTool({ + action: "csvToJson", + input: "name,age\nAlice,30\nBob,25", + }); + assert.strictEqual(result.ok, true); + assert.ok(Array.isArray(result.data)); + assert.strictEqual(result.data[0].name, "Alice"); + }); + + it("handles quoted fields", async () => { + const result = await dataTool({ + action: "csvToJson", + input: 'name,city\nAlice,"New York"', + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data[0].city, "New York"); + }); + }); + + describe("JSON to YAML", () => { + it("converts JSON to YAML", async () => { + const result = await dataTool({ + action: "jsonToYaml", + input: JSON.stringify({ foo: "bar", baz: 123 }), + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes("foo")); + }); + }); + + describe("YAML to JSON", () => { + it("converts YAML to JSON", async () => { + const result = await dataTool({ + action: "yamlToJson", + input: "foo: bar\nbaz: 123", + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes('"foo"')); + }); + }); + + describe("Field mapping", () => { + it("maps fields in JSON", async () => { + const result = await dataTool({ + action: "mapFields", + input: JSON.stringify({ name: "Alice", password: "secret" }), + mapping: { renames: { name: "title" }, removals: ["password"] }, + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes('"title"')); + assert.ok(!result.data.includes('"password"')); + }); + }); + + describe("Schema validation", () => { + it("validates against schema", async () => { + const result = await dataTool({ + action: "validate", + input: JSON.stringify({ name: "Alice", age: 30 }), + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name", "age"], + }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.valid, true); + }); + + it("rejects invalid data", async () => { + const result = await dataTool({ + action: "validate", + input: JSON.stringify({ name: "Alice" }), + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name", "age"], + }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.valid, false); + }); + }); + + describe("Unknown action", () => { + it("rejects unknown action", async () => { + const result = await dataTool({ action: "unknown" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + }); + + describe("Schema", () => { + it("has correct schema", () => { + assert.ok(DataToolSchema); + const shape = DataToolSchema.shape; + assert.ok(shape.action); + assert.ok(shape.input); + assert.ok(shape.data); + }); + }); +}); diff --git a/tests/unit/tools/graphql.test.js b/tests/unit/tools/graphql.test.js new file mode 100644 index 00000000..1a20afdc --- /dev/null +++ b/tests/unit/tools/graphql.test.js @@ -0,0 +1,54 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + graphQLClientTool, + graphQLClient, + GraphQLClientSchema, +} from "../../../src/tools/graphql.js"; + +describe("graphQLClient tool - graphQLClient", () => { + it("rejects file:// URLs", async () => { + const result = await graphQLClient({ url: "file:///etc/passwd", query: "{ test }" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("rejects gopher:// URLs", async () => { + const result = await graphQLClient({ url: "gopher://example.com", query: "{ test }" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("accepts https:// URLs", async () => { + const result = await graphQLClient({ url: "https://example.com/graphql", query: "{ test }" }); + assert.ok(result.ok === false || result.error); + }); + + it("rejects introspection when disabled", async () => { + const result = await graphQLClient({ + url: "https://example.com/graphql", + query: "{ __schema { types { name } } }", + introspection: false, + }); + // URL validation passes, but introspection check should catch it + assert.ok(result.error?.includes("Introspection is disabled") || result.ok === false); + }); + + it("has correct schema", () => { + assert.ok(GraphQLClientSchema); + const shape = GraphQLClientSchema.shape; + assert.ok(shape.url); + assert.ok(shape.query); + assert.ok(shape.variables); + assert.ok(shape.operationName); + assert.ok(shape.timeout); + assert.ok(shape.introspection); + assert.ok(shape.maxDepth); + assert.ok(shape.maxComplexity); + }); + + it("is exported correctly", () => { + assert.ok(graphQLClientTool); + assert.ok(graphQLClient); + }); +}); diff --git a/tests/unit/tools/webhooks.test.js b/tests/unit/tools/webhooks.test.js new file mode 100644 index 00000000..d52be7d1 --- /dev/null +++ b/tests/unit/tools/webhooks.test.js @@ -0,0 +1,116 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + webhookManagementTool, + webhookManagement, + WebhookSchema, +} from "../../../src/tools/webhooks.js"; + +describe("webhookManagement tool - webhookManagement", () => { + it("creates a webhook", async () => { + const result = await webhookManagement({ + action: "create", + url: "https://example.com/hook", + secret: "mysecret", + }); + assert.strictEqual(result.ok, true); + assert.ok(result.webhookId); + assert.strictEqual(result.url, "https://example.com/hook"); + }); + + it("lists webhooks with masked secrets", async () => { + await webhookManagement({ + action: "create", + url: "https://example.com/hook", + secret: "mysecret", + }); + const result = await webhookManagement({ action: "list" }); + assert.strictEqual(result.ok, true); + assert.ok(Array.isArray(result.webhooks)); + assert.ok(result.webhooks.length > 0); + assert.ok(result.webhooks[0].secret.includes("*")); + }); + + it("deletes a webhook", async () => { + const createResult = await webhookManagement({ + action: "create", + url: "https://example.com/hook", + secret: "mysecret", + }); + const deleteResult = await webhookManagement({ + action: "delete", + webhookId: createResult.webhookId, + }); + assert.strictEqual(deleteResult.ok, true); + }); + + it("rejects non-existent webhook deletion", async () => { + const result = await webhookManagement({ + action: "delete", + webhookId: "non-existent-id", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("verifies valid HMAC signature", async () => { + const createResult = await webhookManagement({ + action: "create", + url: "https://example.com/hook", + secret: "mysecret", + }); + const { createHash } = await import("node:crypto"); + const payload = JSON.stringify({ test: true }); + // The verify action hashes just the payload (not payload + secret) + const signature = createHash("sha256").update(payload).digest("hex"); + const result = await webhookManagement({ + action: "verify", + webhookId: createResult.webhookId, + payload, + signature, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.valid, true); + }); + + it("rejects invalid HMAC signature", async () => { + const createResult = await webhookManagement({ + action: "create", + url: "https://example.com/hook", + secret: "mysecret", + }); + const result = await webhookManagement({ + action: "verify", + webhookId: createResult.webhookId, + payload: JSON.stringify({ test: true }), + signature: "invalid-signature", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("rejects file:// webhook URLs", async () => { + const result = await webhookManagement({ + action: "create", + url: "file:///etc/passwd", + secret: "mysecret", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + it("has correct schema", () => { + assert.ok(WebhookSchema); + const shape = WebhookSchema.shape; + assert.ok(shape.action); + assert.ok(shape.url); + assert.ok(shape.secret); + assert.ok(shape.events); + assert.ok(shape.payload); + }); + + it("is exported correctly", () => { + assert.ok(webhookManagementTool); + assert.ok(webhookManagement); + }); +});