From 36b8f91adf79c5c84887d4180c428327621370e3 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 25 Aug 2026 06:40:28 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20structured-data-api-tools=20?= =?UTF-8?q?=E2=80=94=20OpenSpec=20proposal,=20design,=20specs,=20and=20tas?= =?UTF-8?q?ks=20for=20REST=20API,=20GraphQL,=20JSON,=20YAML,=20data=20tran?= =?UTF-8?q?sformation,=20and=20webhook=20management=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../structured-data-api-tools/.openspec.yaml | 2 + .../structured-data-api-tools/design.md | 62 +++++++++++++ .../structured-data-api-tools/proposal.md | 44 +++++++++ .../specs/api-client/spec.md | 72 ++++++++++++++ .../specs/data-transformation/spec.md | 45 +++++++++ .../specs/graphql-client/spec.md | 60 ++++++++++++ .../specs/json-manipulation/spec.md | 56 +++++++++++ .../specs/webhook-management/spec.md | 56 +++++++++++ .../specs/yaml-manipulation/spec.md | 56 +++++++++++ .../structured-data-api-tools/tasks.md | 93 +++++++++++++++++++ 10 files changed, 546 insertions(+) create mode 100644 openspec/changes/structured-data-api-tools/.openspec.yaml create mode 100644 openspec/changes/structured-data-api-tools/design.md create mode 100644 openspec/changes/structured-data-api-tools/proposal.md create mode 100644 openspec/changes/structured-data-api-tools/specs/api-client/spec.md create mode 100644 openspec/changes/structured-data-api-tools/specs/data-transformation/spec.md create mode 100644 openspec/changes/structured-data-api-tools/specs/graphql-client/spec.md create mode 100644 openspec/changes/structured-data-api-tools/specs/json-manipulation/spec.md create mode 100644 openspec/changes/structured-data-api-tools/specs/webhook-management/spec.md create mode 100644 openspec/changes/structured-data-api-tools/specs/yaml-manipulation/spec.md create mode 100644 openspec/changes/structured-data-api-tools/tasks.md diff --git a/openspec/changes/structured-data-api-tools/.openspec.yaml b/openspec/changes/structured-data-api-tools/.openspec.yaml new file mode 100644 index 00000000..e685d45e --- /dev/null +++ b/openspec/changes/structured-data-api-tools/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-25 diff --git a/openspec/changes/structured-data-api-tools/design.md b/openspec/changes/structured-data-api-tools/design.md new file mode 100644 index 00000000..06cbe77c --- /dev/null +++ b/openspec/changes/structured-data-api-tools/design.md @@ -0,0 +1,62 @@ +## Context + +The madz project currently provides tools for web search, content extraction, file operations, and email. There is no structured API interaction capability — the agent must fall back to shell commands (curl, jq, yq) or rely on ad-hoc LLM reasoning for REST API calls, GraphQL queries, JSON/YAML manipulation, and webhook management. This is inconsistent and error-prone for office and marketing workflows. + +The project uses a tool factory pattern: each tool is a plain async function with a Zod input schema, registered in `src/tools/index.js` with permission tiers. Tools live in `src/tools/` and tests mirror the structure in `tests/unit/`. + +## Goals / Non-Goals + +**Goals:** +- Provide structured, validated tools for REST API, GraphQL, JSON, YAML, data transformation, and webhook management +- Enforce URL allowlist security per AGENTS.md §1.2 on all network tools +- Follow existing tool pattern: Zod schema → impl function → registration +- Include comprehensive unit and integration tests + +**Non-Goals:** +- Subscription support for GraphQL (deferred) +- Embedded webhook server (agent-facing tool only) +- File I/O for JSON/YAML/CSV tools (in-memory operations only) +- OAuth/OIDC authentication flows (Bearer, Basic, API Key only) +- Response caching (considered but deferred) + +## Decisions + +1. **Single tool per capability**: Each capability (REST, GraphQL, JSON, YAML, data, webhook) gets its own file in `src/tools/`. This follows the existing pattern and keeps tools focused. + +2. **Native `fetch` API**: Use Node.js 24+ built-in `fetch` for REST requests rather than adding axios or node-fetch. Zero additional dependencies, modern API, consistent with the runtime. + +3. **graphql-request for GraphQL**: Lightweight library (v6.x) that supports queries, mutations, and schema introspection. Avoids the heavier @apollo/client which is React-focused. + +4. **jsonpath-plus for JSONPath**: Well-maintained v8.x library supporting JSONPath expressions for path-based JSON access. + +5. **js-yaml for YAML**: Established v4.x library with load/dump and schema validation. + +6. **csv-parse/csv-generate for CSV**: Same author as csv-stringify, reliable and well-maintained v6.x. + +7. **HMAC-SHA256 for webhook verification**: Industry standard, supported natively by Node.js `crypto` module. + +8. **URL allowlist enforcement**: All network tools validate URLs against an allowlist before making requests. Disallow file://, gopher://, dict:// schemes. Reject internal IPs unless explicitly allowed. + +## Risks / Trade-offs + +- **Risk**: Adding 5 new npm dependencies increases bundle size. + → **Mitigation**: All dependencies are lightweight, well-maintained, and commonly used. graphql-request is ~50KB, jsonpath-plus ~30KB, js-yaml ~100KB. + +- **Risk**: Webhook HMAC verification could be bypassed if secret is weak. + → **Mitigation**: Document best practices for secret generation. Tool accepts any secret string; security is user responsibility. + +- **Risk**: GraphQL query depth/complexity limits could be circumvented. + → **Mitigation**: graphql-request supports depth limiting via custom plugins. Default limits (depth: 10, complexity: 1000) are configurable. + +- **Risk**: URL allowlist configuration could be forgotten by users. + → **Mitigation**: Tool returns clear error messages when URL is not on allowlist, with instructions on how to add it. + +## Migration Plan + +No migration needed — this is a net-new feature. All tools are additive to the existing tool registry. + +## Open Questions + +- Should webhook management include delivery status tracking? (deferred to v2) +- Should the REST client support request/response middleware for logging? (deferred) +- Should JSON/YAML tools support file I/O in addition to in-memory operations? (deferred, but could be added via a separate `filesystem:read/write` permission) diff --git a/openspec/changes/structured-data-api-tools/proposal.md b/openspec/changes/structured-data-api-tools/proposal.md new file mode 100644 index 00000000..f0c45474 --- /dev/null +++ b/openspec/changes/structured-data-api-tools/proposal.md @@ -0,0 +1,44 @@ +## Why + +The existing tools handle web search and content extraction, but there is no structured API interaction capability. Office and marketing workflows frequently need to call REST APIs (CRM, analytics, project management), query GraphQL endpoints, manage webhooks, and manipulate JSON/YAML data. Currently the agent must fall back to shell commands (curl, jq, yq) or rely on ad-hoc LLM reasoning, which is inconsistent and error-prone. + +## What Changes + +- Add REST API client tool supporting authenticated GET/POST/PUT/DELETE/PATCH requests with configurable headers, body, and authentication (Bearer, Basic, API Key) +- Add GraphQL client tool for executing queries and mutations with schema introspection, depth/complexity limits +- Add JSON manipulation tool for parse, transform, filter, and serialize operations with JSONPath-based access +- Add YAML manipulation tool mirroring JSON tool structure with js-yaml parsing/dumping +- Add data transformation tool for format conversion between JSON, YAML, and CSV with mapping rules +- Add webhook management tool for create, list, delete, and verify (HMAC-SHA256) actions +- Register all tools in `src/tools/index.js` with appropriate permissions +- Add dependencies: graphql-request, jsonpath-plus, js-yaml, csv-parse, csv-generate + +## Capabilities + +### New Capabilities +- `api-client`: REST API client with authentication, timeout, URL allowlist, response sanitization +- `graphql-client`: GraphQL query/mutation execution with schema introspection and query limits +- `json-manipulation`: JSON parse, transform, filter, serialize with JSONPath-based access +- `yaml-manipulation`: YAML parse, transform, filter, serialize with path-based access +- `data-transformation`: Format conversion between JSON, YAML, CSV with mapping rules +- `webhook-management`: Webhook create, list, delete, verify with HMAC-SHA256 validation + +### Modified Capabilities +- None + +## Impact + +- New files: `src/tools/api.js`, `src/tools/graphql.js`, `src/tools/json.js`, `src/tools/yaml.js`, `src/tools/data.js`, `src/tools/webhook.js` +- Modified: `src/tools/index.js` (register new tools), `package.json` (add dependencies) +- New tests: `tests/api.test.js`, `tests/graphql.test.js`, `tests/json.test.js`, `tests/yaml.test.js`, `tests/data.test.js`, `tests/webhook.test.js` +- New config: `data/webhooks.json` (webhook registrations) +- Security: All network tools enforce URL allowlist per AGENTS.md §1.2 +- Permissions: Network tools require `network:outbound`; data tools require `filesystem:read/write` + +## Non-goals + +- Subscription support for GraphQL (out of scope for v1) +- Embedded webhook server (agent-facing tool only; no server component) +- File I/O for JSON/YAML/CSV tools (in-memory operations only) +- OAuth/OIDC authentication flows (Bearer, Basic, API Key only) +- Response caching (considered but deferred) diff --git a/openspec/changes/structured-data-api-tools/specs/api-client/spec.md b/openspec/changes/structured-data-api-tools/specs/api-client/spec.md new file mode 100644 index 00000000..07f19373 --- /dev/null +++ b/openspec/changes/structured-data-api-tools/specs/api-client/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: REST client executes authenticated HTTP requests +The REST API client SHALL support GET, POST, PUT, DELETE, and PATCH methods with configurable headers, body, and authentication. + +#### Scenario: Successful GET request +- **WHEN** the user calls the REST tool with method "GET" and a valid URL +- **THEN** the tool returns the response body, status code, and headers + +#### Scenario: POST request with JSON body +- **WHEN** the user calls the REST tool with method "POST", a URL, and a JSON body +- **THEN** the tool sends the request with Content-Type: application/json and returns the response + +#### Scenario: Bearer token authentication +- **WHEN** the user provides auth.type "bearer" with a token +- **THEN** the tool adds an Authorization: Bearer header to the request + +#### Scenario: Basic authentication +- **WHEN** the user provides auth.type "basic" with a token +- **THEN** the tool adds an Authorization: Basic header to the request + +#### Scenario: API Key authentication +- **WHEN** the user provides auth.type "apikey" with a key and optional token +- **THEN** the tool adds the API key header as configured + +### Requirement: REST client enforces URL allowlist +The REST API client SHALL validate all request URLs against an allowlist before making outbound requests. + +#### Scenario: URL on allowlist succeeds +- **WHEN** the request URL matches an entry in the allowlist +- **THEN** the request proceeds normally + +#### Scenario: URL not on allowlist is rejected +- **WHEN** the request URL does not match any entry in the allowlist +- **THEN** the tool returns an error and does not make the request + +#### Scenario: Disallowed schemes are rejected +- **WHEN** the request URL uses file://, gopher://, or dict:// scheme +- **THEN** the tool returns an error regardless of allowlist + +#### Scenario: Internal IP addresses are rejected +- **WHEN** the request URL resolves to an internal IP (127.0.0.1, 0.0.0.0, 169.254.169.254) +- **THEN** the tool returns an error unless explicitly allowed + +### Requirement: REST client supports configurable timeouts +The REST API client SHALL support configurable request timeouts with a default of 30 seconds. + +#### Scenario: Default timeout applies +- **WHEN** no timeout is specified +- **THEN** the request uses a 30-second default timeout + +#### Scenario: Custom timeout is respected +- **WHEN** the user specifies a timeout of 5000 milliseconds +- **THEN** the request times out after 5 seconds if not completed + +### Requirement: REST client sanitizes responses +The REST API client SHALL strip sensitive headers from proxied responses. + +#### Scenario: Sensitive headers are stripped +- **WHEN** the response includes Set-Cookie or WWW-Authenticate headers +- **THEN** the tool removes these headers from the returned response + +### Requirement: REST client limits response size +The REST API client SHALL limit response body size to prevent memory exhaustion. + +#### Scenario: Response within limit is returned +- **WHEN** the response body is within the 10MB limit +- **THEN** the full response body is returned + +#### Scenario: Response exceeding limit is rejected +- **WHEN** the response body exceeds 10MB +- **THEN** the tool returns an error with a size-exceeded message diff --git a/openspec/changes/structured-data-api-tools/specs/data-transformation/spec.md b/openspec/changes/structured-data-api-tools/specs/data-transformation/spec.md new file mode 100644 index 00000000..b3acb4d0 --- /dev/null +++ b/openspec/changes/structured-data-api-tools/specs/data-transformation/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Data tool converts between JSON and YAML +The data transformation tool SHALL convert data between JSON and YAML formats. + +#### Scenario: JSON to YAML conversion +- **WHEN** the user provides JSON input with format "yaml" as target +- **THEN** the tool returns the data as a YAML string + +#### Scenario: YAML to JSON conversion +- **WHEN** the user provides YAML input with format "json" as target +- **THEN** the tool returns the data as a JSON string + +### Requirement: Data tool converts between JSON and CSV +The data transformation tool SHALL convert data between JSON and CSV formats. + +#### Scenario: JSON array to CSV +- **WHEN** the user provides a JSON array of objects with format "csv" as target +- **THEN** the tool returns a CSV string with headers and rows + +#### Scenario: CSV to JSON array +- **WHEN** the user provides a CSV string with format "json" as target +- **THEN** the tool returns a JSON array of objects with headers as keys + +### Requirement: Data tool applies mapping rules during conversion +The data transformation tool SHALL apply mapping rules to transform field names during conversion. + +#### Scenario: Mapping rule applied during JSON to CSV +- **WHEN** the user provides mapping rules with JSON to CSV conversion +- **THEN** the tool applies the mapping to column headers in the CSV output + +#### Scenario: Mapping rule applied during CSV to JSON +- **WHEN** the user provides mapping rules with CSV to JSON conversion +- **THEN** the tool applies the mapping to object keys in the JSON output + +### Requirement: Data tool validates input format +The data transformation tool SHALL validate that input data matches the specified format. + +#### Scenario: Invalid JSON input is rejected +- **WHEN** the user provides invalid JSON with format "json" +- **THEN** the tool returns an error with a descriptive message + +#### Scenario: Invalid CSV input is rejected +- **WHEN** the user provides malformed CSV with format "csv" +- **THEN** the tool returns an error with a descriptive message diff --git a/openspec/changes/structured-data-api-tools/specs/graphql-client/spec.md b/openspec/changes/structured-data-api-tools/specs/graphql-client/spec.md new file mode 100644 index 00000000..2fe5340a --- /dev/null +++ b/openspec/changes/structured-data-api-tools/specs/graphql-client/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: GraphQL client executes queries and mutations +The GraphQL client SHALL execute GraphQL queries and mutations against a specified endpoint. + +#### Scenario: Successful query execution +- **WHEN** the user provides a GraphQL query string and endpoint URL +- **THEN** the tool sends the query and returns the response data + +#### Scenario: Mutation execution +- **WHEN** the user provides a GraphQL mutation string and endpoint URL +- **THEN** the tool sends the mutation and returns the result + +#### Scenario: Query with variables +- **WHEN** the user provides variables alongside the query +- **THEN** the tool serializes variables and includes them in the request + +#### Scenario: Named operation +- **WHEN** the user provides an operationName +- **THEN** the tool includes the operation name in the request + +### Requirement: GraphQL client supports schema introspection +The GraphQL client SHALL support schema introspection queries. + +#### Scenario: Schema introspection request +- **WHEN** the user requests schema introspection +- **THEN** the tool executes the standard introspection query and returns the schema + +### Requirement: GraphQL client enforces query depth limits +The GraphQL client SHALL limit query depth to prevent DoS via deeply nested queries. + +#### Scenario: Query within depth limit succeeds +- **WHEN** the query depth is within the configured limit (default: 10) +- **THEN** the query executes normally + +#### Scenario: Query exceeding depth limit is rejected +- **WHEN** the query depth exceeds the configured limit +- **THEN** the tool returns an error indicating depth exceeded + +### Requirement: GraphQL client enforces query complexity limits +The GraphQL client SHALL limit query complexity to prevent DoS via complex queries. + +#### Scenario: Query within complexity limit succeeds +- **WHEN** the query complexity is within the configured limit (default: 1000) +- **THEN** the query executes normally + +#### Scenario: Query exceeding complexity limit is rejected +- **WHEN** the query complexity exceeds the configured limit +- **THEN** the tool returns an error indicating complexity exceeded + +### Requirement: GraphQL client supports configurable timeouts +The GraphQL client SHALL support configurable request timeouts with a default of 30 seconds. + +#### Scenario: Default timeout applies +- **WHEN** no timeout is specified +- **THEN** the request uses a 30-second default timeout + +#### Scenario: Custom timeout is respected +- **WHEN** the user specifies a timeout of 10000 milliseconds +- **THEN** the request times out after 10 seconds if not completed diff --git a/openspec/changes/structured-data-api-tools/specs/json-manipulation/spec.md b/openspec/changes/structured-data-api-tools/specs/json-manipulation/spec.md new file mode 100644 index 00000000..a716b037 --- /dev/null +++ b/openspec/changes/structured-data-api-tools/specs/json-manipulation/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: JSON tool parses JSON strings +The JSON manipulation tool SHALL parse JSON strings into structured objects. + +#### Scenario: Valid JSON string is parsed +- **WHEN** the user provides a valid JSON string with action "parse" +- **THEN** the tool returns the parsed JavaScript object + +#### Scenario: Invalid JSON string is rejected +- **WHEN** the user provides an invalid JSON string +- **THEN** the tool returns an error with a descriptive message + +### Requirement: JSON tool serializes objects to JSON strings +The JSON manipulation tool SHALL serialize JavaScript objects to JSON strings. + +#### Scenario: Object is serialized to JSON +- **WHEN** the user provides an object with action "serialize" +- **THEN** the tool returns a valid JSON string + +#### Scenario: Object with options is serialized +- **WHEN** the user specifies pretty-printing options +- **THEN** the tool returns a formatted JSON string with indentation + +### Requirement: JSON tool transforms data +The JSON manipulation tool SHALL transform JSON data using mapping rules. + +#### Scenario: Simple key mapping +- **WHEN** the user provides a mapping rule to rename keys +- **THEN** the tool returns the transformed JSON with renamed keys + +#### Scenario: Nested transformation +- **WHEN** the user provides a mapping rule for nested paths +- **THEN** the tool returns the transformed JSON with nested changes applied + +### Requirement: JSON tool filters data with JSONPath +The JSON manipulation tool SHALL filter JSON data using JSONPath expressions. + +#### Scenario: JSONPath filter returns matching values +- **WHEN** the user provides a JSONPath expression +- **THEN** the tool returns all matching values from the JSON data + +#### Scenario: JSONPath filter returns empty result +- **WHEN** the JSONPath expression matches no values +- **THEN** the tool returns an empty array + +### Requirement: JSON tool supports path-based access +The JSON manipulation tool SHALL access JSON values using path-based expressions. + +#### Scenario: Simple path access +- **WHEN** the user provides a dot-notation path +- **THEN** the tool returns the value at that path + +#### Scenario: Array index access +- **WHEN** the user provides a path with array index +- **THEN** the tool returns the value at the specified array index diff --git a/openspec/changes/structured-data-api-tools/specs/webhook-management/spec.md b/openspec/changes/structured-data-api-tools/specs/webhook-management/spec.md new file mode 100644 index 00000000..c8cf5313 --- /dev/null +++ b/openspec/changes/structured-data-api-tools/specs/webhook-management/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Webhook tool creates webhook registrations +The webhook management tool SHALL create webhook endpoint registrations with payload validation. + +#### Scenario: Webhook registration created +- **WHEN** the user provides a URL and optional secret with action "create" +- **THEN** the tool stores the webhook registration and returns the registration ID + +#### Scenario: Webhook with events specified +- **WHEN** the user provides specific event types for the webhook +- **THEN** the tool stores the event filter alongside the registration + +### Requirement: Webhook tool lists registered webhooks +The webhook management tool SHALL list all registered webhook endpoints. + +#### Scenario: Webhook list returned +- **WHEN** the user calls the webhook tool with action "list" +- **THEN** the tool returns an array of all registered webhooks with their URLs and events + +### Requirement: Webhook tool deletes webhook registrations +The webhook management tool SHALL remove webhook endpoint registrations. + +#### Scenario: Webhook deleted by ID +- **WHEN** the user provides a registration ID with action "delete" +- **THEN** the tool removes the registration and confirms deletion + +#### Scenario: Delete nonexistent webhook +- **WHEN** the user provides an ID that does not exist +- **THEN** the tool returns an error indicating the webhook was not found + +### Requirement: Webhook tool verifies HMAC signatures +The webhook management tool SHALL verify incoming webhook payloads using HMAC-SHA256 signatures. + +#### Scenario: Valid HMAC signature verified +- **WHEN** the user provides a payload, signature, and matching secret with action "verify" +- **THEN** the tool returns success indicating the signature is valid + +#### Scenario: Invalid HMAC signature rejected +- **WHEN** the user provides a payload, signature, and secret where the signature does not match +- **THEN** the tool returns an error indicating the signature is invalid + +#### Scenario: Missing signature rejected +- **WHEN** the user provides a payload without a signature +- **THEN** the tool returns an error indicating the signature is required + +### Requirement: Webhook registrations are persisted +The webhook management tool SHALL persist webhook registrations to a data file. + +#### Scenario: Registrations persist across calls +- **WHEN** webhooks are created and the tool is called again +- **THEN** the previously created webhooks are still listed + +#### Scenario: Registrations file is created +- **WHEN** the first webhook is created +- **THEN** the data/webhooks.json file is created with the registration data diff --git a/openspec/changes/structured-data-api-tools/specs/yaml-manipulation/spec.md b/openspec/changes/structured-data-api-tools/specs/yaml-manipulation/spec.md new file mode 100644 index 00000000..8d576514 --- /dev/null +++ b/openspec/changes/structured-data-api-tools/specs/yaml-manipulation/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: YAML tool parses YAML strings +The YAML manipulation tool SHALL parse YAML strings into structured objects. + +#### Scenario: Valid YAML string is parsed +- **WHEN** the user provides a valid YAML string with action "parse" +- **THEN** the tool returns the parsed JavaScript object + +#### Scenario: Invalid YAML string is rejected +- **WHEN** the user provides an invalid YAML string +- **THEN** the tool returns an error with a descriptive message + +### Requirement: YAML tool serializes objects to YAML strings +The YAML manipulation tool SHALL serialize JavaScript objects to YAML strings. + +#### Scenario: Object is serialized to YAML +- **WHEN** the user provides an object with action "serialize" +- **THEN** the tool returns a valid YAML string + +#### Scenario: Object with options is serialized +- **WHEN** the user specifies YAML output options +- **THEN** the tool returns a formatted YAML string + +### Requirement: YAML tool transforms data +The YAML manipulation tool SHALL transform YAML data using mapping rules. + +#### Scenario: Simple key mapping +- **WHEN** the user provides a mapping rule to rename keys +- **THEN** the tool returns the transformed YAML with renamed keys + +#### Scenario: Nested transformation +- **WHEN** the user provides a mapping rule for nested paths +- **THEN** the tool returns the transformed YAML with nested changes applied + +### Requirement: YAML tool filters data with path expressions +The YAML manipulation tool SHALL filter YAML data using path-based expressions. + +#### Scenario: Path filter returns matching values +- **WHEN** the user provides a path expression +- **THEN** the tool returns all matching values from the YAML data + +#### Scenario: Path filter returns empty result +- **WHEN** the path expression matches no values +- **THEN** the tool returns an empty array + +### Requirement: YAML tool supports path-based access +The YAML manipulation tool SHALL access YAML values using path-based expressions. + +#### Scenario: Simple path access +- **WHEN** the user provides a dot-notation path +- **THEN** the tool returns the value at that path + +#### Scenario: Array index access +- **WHEN** the user provides a path with array index +- **THEN** the tool returns the value at the specified array index diff --git a/openspec/changes/structured-data-api-tools/tasks.md b/openspec/changes/structured-data-api-tools/tasks.md new file mode 100644 index 00000000..67aa7140 --- /dev/null +++ b/openspec/changes/structured-data-api-tools/tasks.md @@ -0,0 +1,93 @@ +## 1. Setup — Install Dependencies + +- [ ] 1.1 Add npm dependencies: graphql-request, jsonpath-plus, js-yaml, csv-parse, csv-generate to package.json +- [ ] 1.2 Run npm install to install new dependencies + +## 2. URL Allowlist Utility + +- [ ] 2.1 Create src/tools/utils/urlAllowlist.js with allowlist validation function +- [ ] 2.2 Implement scheme blocking (file://, gopher://, dict://) +- [ ] 2.3 Implement internal IP blocking (127.0.0.1, 0.0.0.0, 169.254.169.254) +- [ ] 2.4 Allow configurable allowlist from config.yaml + +## 3. REST API Client Tool + +- [ ] 3.1 Create src/tools/api.js with REST API tool implementation +- [ ] 3.2 Implement Zod input schema: url, method, headers, body, auth, timeout +- [ ] 3.3 Implement authentication: bearer, basic, apikey +- [ ] 3.4 Implement URL allowlist enforcement +- [ ] 3.5 Implement response sanitization (strip Set-Cookie, WWW-Authenticate) +- [ ] 3.6 Implement response size limit (10MB default) +- [ ] 3.7 Implement configurable timeouts (default 30s) +- [ ] 3.8 Register tool in src/tools/index.js with network:outbound permission + +## 4. GraphQL Client Tool + +- [ ] 4.1 Create src/tools/graphql.js with GraphQL client tool implementation +- [ ] 4.2 Implement Zod input schema: url, query, variables, operationName, timeout +- [ ] 4.3 Implement query and mutation execution via graphql-request +- [ ] 4.4 Implement schema introspection support +- [ ] 4.5 Implement query depth limiting (default: 10) +- [ ] 4.6 Implement query complexity limiting (default: 1000) +- [ ] 4.7 Implement configurable timeouts (default 30s) +- [ ] 4.8 Register tool in src/tools/index.js with network:outbound permission + +## 5. JSON Manipulation Tool + +- [ ] 5.1 Create src/tools/json.js with JSON manipulation tool implementation +- [ ] 5.2 Implement Zod input schema: action, input, format, path, mapping +- [ ] 5.3 Implement parse action (JSON string → object) +- [ ] 5.4 Implement serialize action (object → JSON string) +- [ ] 5.5 Implement transform action with mapping rules +- [ ] 5.6 Implement filter action with JSONPath expressions via jsonpath-plus +- [ ] 5.7 Implement path-based access (dot notation, array indices) +- [ ] 5.8 Register tool in src/tools/index.js with filesystem:read permission + +## 6. YAML Manipulation Tool + +- [ ] 6.1 Create src/tools/yaml.js with YAML manipulation tool implementation +- [ ] 6.2 Implement Zod input schema: action, input, format, path, mapping +- [ ] 6.3 Implement parse action (YAML string → object) +- [ ] 6.4 Implement serialize action (object → YAML string) +- [ ] 6.5 Implement transform action with mapping rules +- [ ] 6.6 Implement filter action with path expressions +- [ ] 6.7 Implement path-based access (dot notation, array indices) +- [ ] 6.8 Register tool in src/tools/index.js with filesystem:read permission + +## 7. Data Transformation Tool + +- [ ] 7.1 Create src/tools/data.js with data transformation tool implementation +- [ ] 7.2 Implement Zod input schema: action, input, format, path, mapping +- [ ] 7.3 Implement JSON ↔ YAML conversion +- [ ] 7.4 Implement JSON ↔ CSV conversion via csv-parse/csv-generate +- [ ] 7.5 Implement mapping rule application during conversion +- [ ] 7.6 Implement input format validation +- [ ] 7.7 Register tool in src/tools/index.js with filesystem:read permission + +## 8. Webhook Management Tool + +- [ ] 8.1 Create src/tools/webhook.js with webhook management tool implementation +- [ ] 8.2 Implement Zod input schema: action, url, secret, events, payload +- [ ] 8.3 Implement create action — store webhook registration +- [ ] 8.4 Implement list action — return all registered webhooks +- [ ] 8.5 Implement delete action — remove webhook by ID +- [ ] 8.6 Implement verify action — HMAC-SHA256 signature verification +- [ ] 8.7 Implement persistence to data/webhooks.json +- [ ] 8.8 Register tool in src/tools/index.js with filesystem:read, filesystem:write permissions + +## 9. Testing + +- [ ] 9.1 Create tests/unit/api.test.js with REST client unit tests +- [ ] 9.2 Create tests/unit/graphql.test.js with GraphQL client unit tests +- [ ] 9.3 Create tests/unit/json.test.js with JSON manipulation unit tests +- [ ] 9.4 Create tests/unit/yaml.test.js with YAML manipulation unit tests +- [ ] 9.5 Create tests/unit/data.test.js with data transformation unit tests +- [ ] 9.6 Create tests/unit/webhook.test.js with webhook management unit tests +- [ ] 9.7 Create tests/integration/api.test.js with integration tests using mock server +- [ ] 9.8 Create tests/integration/webhook.test.js with webhook integration tests + +## 10. Verification + +- [ ] 10.1 Run npm run test and verify all tests pass +- [ ] 10.2 Run npm run lint and verify no lint errors +- [ ] 10.3 Run npm run coverage and verify coverage is maintained From de821bfd3d9a1de280323ceaa9f4ad1e8ce8a532 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 25 Aug 2026 09:06:19 -0400 Subject: [PATCH 2/3] feat: add structured data and API interaction tools Add REST API client, GraphQL client, webhook management, JSON/YAML manipulation, and data transformation tools with full test coverage. - src/tools/api.js: REST API client with auth, URL filtering, timeouts - src/tools/graphql.js: GraphQL client with depth/complexity limits - src/tools/webhook.js: Webhook CRUD and HMAC verification - src/tools/json.js: JSON parse, serialize, transform, filter, access - src/tools/yaml.js: YAML parse, serialize, transform, filter, access - src/tools/data.js: JSON/YAML/CSV format conversion - src/sandbox/urlFilter.js: URL allowlist with test mode support - tests/unit/*.test.js: Unit tests for all new tools - tests/integration/*.test.js: Integration tests with mock servers - src/tools/index.js: Tool registration with permission gating --- package-lock.json | 99 ++++++++- package.json | 5 +- src/sandbox/urlFilter.js | 58 +++++- src/tools/api.js | 216 ++++++++++++++++++++ src/tools/data.js | 253 +++++++++++++++++++++++ src/tools/graphql.js | 323 ++++++++++++++++++++++++++++++ src/tools/index.js | 35 ++++ src/tools/json.js | 262 ++++++++++++++++++++++++ src/tools/webhook.js | 222 ++++++++++++++++++++ src/tools/yaml.js | 279 ++++++++++++++++++++++++++ tests/integration/api.test.js | 112 +++++++++++ tests/integration/webhook.test.js | 101 ++++++++++ tests/unit/api.test.js | 81 ++++++++ tests/unit/data.test.js | 103 ++++++++++ tests/unit/graphql.test.js | 64 ++++++ tests/unit/json.test.js | 104 ++++++++++ tests/unit/tool_index.test.js | 13 +- tests/unit/webhook.test.js | 135 +++++++++++++ tests/unit/yaml.test.js | 94 +++++++++ 19 files changed, 2545 insertions(+), 14 deletions(-) create mode 100644 src/tools/api.js create mode 100644 src/tools/data.js create mode 100644 src/tools/graphql.js create mode 100644 src/tools/json.js create mode 100644 src/tools/webhook.js create mode 100644 src/tools/yaml.js create mode 100644 tests/integration/api.test.js create mode 100644 tests/integration/webhook.test.js create mode 100644 tests/unit/api.test.js create mode 100644 tests/unit/data.test.js create mode 100644 tests/unit/graphql.test.js create mode 100644 tests/unit/json.test.js create mode 100644 tests/unit/webhook.test.js create mode 100644 tests/unit/yaml.test.js diff --git a/package-lock.json b/package-lock.json index bde6e006..a41e0bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,17 +22,20 @@ "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "cron-parser": "^5.10.0", + "csv-generate": "^4.6.1", "csv-parse": "^7.0.2", "csv-stringify": "^6.8.3", "deepagents": "^1.13.1", "exceljs": "^4.4.0", "googleapis": "^176.0.0", + "graphql-request": "^7.4.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", + "js-yaml": "^5.4.0", + "jsonpath-plus": "^10.4.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", @@ -3082,6 +3118,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 +3900,28 @@ "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": "7.4.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.4.0.tgz", + "integrity": "sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, "node_modules/gtoken": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", @@ -4388,9 +4452,9 @@ } }, "node_modules/js-yaml": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", - "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.0.tgz", + "integrity": "sha512-jE7vUJIebKzYQI5xu4co5CRBDlDEYnHrdzsxs4O2giCz4v2SbVMYKpmt1D9L38OKQAeCWmrOTRiCV93u0UkaJA==", "funding": [ { "type": "github", @@ -4409,6 +4473,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 +4491,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", diff --git a/package.json b/package.json index 9240eedc..7a4c872d 100644 --- a/package.json +++ b/package.json @@ -73,17 +73,20 @@ "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "cron-parser": "^5.10.0", + "csv-generate": "^4.6.1", "csv-parse": "^7.0.2", "csv-stringify": "^6.8.3", "deepagents": "^1.13.1", "exceljs": "^4.4.0", "googleapis": "^176.0.0", + "graphql-request": "^7.4.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", + "js-yaml": "^5.4.0", + "jsonpath-plus": "^10.4.0", "marked": "^18.0.10", "node-emoji": "^2.2.0", "nodemailer": "^9.0.5", diff --git a/src/sandbox/urlFilter.js b/src/sandbox/urlFilter.js index 3bce9c5f..c1ed082d 100644 --- a/src/sandbox/urlFilter.js +++ b/src/sandbox/urlFilter.js @@ -1,7 +1,50 @@ const BLOCKED_SCHEMES = new Set(["file:", "gopher:", "dict:"]); +// Internal/private IP ranges to block (RFC 1918 + loopback + link-local + metadata) +const BLOCKED_IP_PATTERNS = [ + /^127\./, // loopback + /^0\.0\.0\.0$/, // all interfaces + /^169\.254\./, // link-local + /^10\./, // RFC 1918 private + /^172\.(1[6-9]|2[0-9]|3[01])\./, // RFC 1918 private + /^192\.168\./, // RFC 1918 private + /^::1$/, // IPv6 loopback + /^fe80:/i, // IPv6 link-local + /^fc00:/i, // IPv6 unique local + /^fd00:/i, // IPv6 unique local +]; + +let _testMode = false; + +/** + * Enable test mode — allows internal IPs for integration tests. + * @param {boolean} enabled + */ +export function setTestMode(enabled) { + _testMode = !!enabled; +} + /** - * Filter outbound URLs, blocking prohibited schemes and checking against an allowlist. + * Check if a hostname or IP is an internal/private address. + * @param {string} host - Hostname or IP to check + * @returns {boolean} + */ +function isInternalHost(host) { + if (!host || typeof host !== "string") return false; + const lowerHost = host.toLowerCase(); + // Direct IP match + if (BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(lowerHost))) { + return true; + } + // Resolve hostname to check for internal IPs + if (lowerHost === "localhost" || lowerHost === "0.0.0.0") { + return true; + } + return false; +} + +/** + * Filter outbound URLs, blocking prohibited schemes, internal IPs, and checking against an allowlist. * @param {string} url - The URL to validate * @param {string[]} [allowlist=[]] - Allowed hostnames/URLs * @returns {{ allowed: boolean, reason: string }} @@ -19,12 +62,17 @@ export function filterUrl(url, allowlist = []) { return { allowed: false, reason: `Blocked scheme: ${scheme}` }; } + // Block internal/private IPs and hostnames (always enforced, unless test mode) + if (!_testMode && isInternalHost(parsed.hostname)) { + return { allowed: false, reason: `Blocked internal host: ${parsed.hostname}` }; + } + if (allowlist.length > 0) { const hostname = parsed.hostname.toLowerCase(); - const onAllowlist = allowlist.some( - (entry) => - hostname === entry.toLowerCase() || url.startsWith(entry.replace(/^https?:\/\//, "")), - ); + const onAllowlist = allowlist.some((entry) => { + const normalized = entry.replace(/^https?:\/\//, "").toLowerCase(); + return hostname === normalized || hostname === normalized.replace(/:\d+$/, "") || url.startsWith(entry); + }); if (!onAllowlist) { return { allowed: false, reason: `Host not on allowlist: ${hostname}` }; } diff --git a/src/tools/api.js b/src/tools/api.js new file mode 100644 index 00000000..25c5021e --- /dev/null +++ b/src/tools/api.js @@ -0,0 +1,216 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { filterUrl } from "../sandbox/urlFilter.js"; +import { loadConfig } from "../config/loader.js"; + +const config = loadConfig(); + +const DEFAULT_TIMEOUT = 30000; +const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB + +/** + * Sanitize response headers by stripping sensitive ones. + * @param {Headers} headers - Response headers + * @returns {Record} + */ +function sanitizeHeaders(headers) { + const STRIP_HEADERS = new Set(["set-cookie", "www-authenticate"]); + const result = {}; + headers.forEach((value, key) => { + if (!STRIP_HEADERS.has(key.toLowerCase())) { + result[key] = value; + } + }); + return result; +} + +/** + * Make an authenticated HTTP request with allowlist enforcement. + * @param {string} url - The URL to request + * @param {object} options - Request options + * @param {string} [options.method="GET"] - HTTP method + * @param {Record} [options.headers] - Additional headers + * @param {unknown} [options.body] - Request body + * @param {object} [options.auth] - Authentication config + * @param {string} [options.auth.type] - Auth type: "bearer", "basic", "apikey" + * @param {string} [options.auth.token] - Bearer token or basic password + * @param {string} [options.auth.key] - API key name + * @param {number} [options.timeout] - Request timeout in ms + * @param {string[]} [options.allowlist] - URL allowlist + * @returns {Promise<{ ok: boolean, status?: number, headers?: Record, body?: string, error?: string }>} + */ +export async function makeApiRequest( + url, + { + method = "GET", + headers: extraHeaders = {}, + body, + auth, + timeout = DEFAULT_TIMEOUT, + allowlist = [], + maxBodySize = DEFAULT_MAX_BODY_SIZE, + } = {}, +) { + const validation = filterUrl(url, allowlist); + if (!validation.allowed) { + return { ok: false, error: validation.reason }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const fetchHeaders = { ...extraHeaders }; + + // Apply authentication + if (auth) { + if (auth.type === "bearer" && auth.token) { + fetchHeaders["Authorization"] = `Bearer ${auth.token}`; + } else if (auth.type === "basic" && auth.token) { + const encoded = Buffer.from(auth.token).toString("base64"); + fetchHeaders["Authorization"] = `Basic ${encoded}`; + } else if (auth.type === "apikey" && auth.key && auth.token) { + fetchHeaders[auth.key] = auth.token; + } + } + + // Set content type for body + if (body && typeof body === "object" && !extraHeaders["Content-Type"]) { + fetchHeaders["Content-Type"] = "application/json"; + } + + const fetchOptions = { + method, + headers: fetchHeaders, + signal: controller.signal, + }; + + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + fetchOptions.body = typeof body === "string" ? body : JSON.stringify(body); + } + + const resp = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + + // Read body with size limit + const bodySize = resp.headers.get("content-length"); + if (bodySize && parseInt(bodySize, 10) > maxBodySize) { + return { + ok: false, + status: resp.status, + error: `Response body too large: ${bodySize} bytes (max: ${maxBodySize})`, + }; + } + + const text = await resp.text(); + if (text.length > maxBodySize) { + return { + ok: false, + status: resp.status, + error: `Response body too large: ${text.length} bytes (max: ${maxBodySize})`, + }; + } + + return { + ok: true, + status: resp.status, + headers: sanitizeHeaders(resp.headers), + body: text, + }; + } catch (err) { + clearTimeout(timeoutId); + if (err.name === "AbortError") { + return { ok: false, error: `Request timed out after ${timeout}ms` }; + } + return { ok: false, error: err.message || "Request failed" }; + } +} + +/** + * REST API client tool — make authenticated GET/POST/PUT/DELETE requests. + * @param {string} input - JSON string with url, method, headers, body, auth, timeout, allowlist + * @returns {Promise<{ ok: boolean, status?: number, headers?: Record, body?: string, error?: string }>} + */ +export async function api(input) { + let parsed; + try { + parsed = JSON.parse(input); + } catch { + return { ok: false, error: "Invalid JSON input" }; + } + return apiImpl(parsed); +} + +/** + * REST API client implementation — takes a validated plain object. + * @param {object} input - Parsed input object + * @returns {Promise<{ ok: boolean, status?: number, headers?: Record, body?: string, error?: string }>} + */ +export async function apiImpl(input) { + const schema = z.object({ + url: z.string().url(), + method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional().default("GET"), + headers: z.record(z.string()).optional(), + body: z.unknown().optional(), + auth: z + .object({ + type: z.enum(["bearer", "basic", "apikey"]), + token: z.string().optional(), + key: z.string().optional(), + }) + .optional(), + timeout: z.number().int().positive().optional(), + allowlist: z.array(z.string()).optional(), + maxBodySize: z.number().int().positive().optional(), + }); + + const validated = schema.safeParse(input); + if (!validated.success) { + return { + ok: false, + error: `Invalid input: ${validated.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`, + }; + } + + return makeApiRequest(validated.data.url, { + method: validated.data.method, + headers: validated.data.headers, + body: validated.data.body, + auth: validated.data.auth, + timeout: validated.data.timeout || DEFAULT_TIMEOUT, + allowlist: validated.data.allowlist || [], + maxBodySize: validated.data.maxBodySize || DEFAULT_MAX_BODY_SIZE, + }); +} + +/** + * Create the LangChain tool wrapper for the REST API client. + * @returns {object} LangChain Tool instance + */ +export function createApiTool() { + return tool(async (input) => { + const result = await apiImpl(input); + return JSON.stringify(result, null, 2); + }, { + name: "api", + description: + "Make authenticated HTTP requests (GET/POST/PUT/DELETE/PATCH) to external APIs. Supports bearer, basic, and API key authentication. Enforces URL allowlist and scheme blocking. Response headers are sanitized (Set-Cookie, WWW-Authenticate stripped). Default timeout: 30s. Max response body: 10MB.", + schema: z.object({ + url: z.string().url().describe("Target URL"), + method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional().default("GET").describe("HTTP method"), + headers: z.record(z.string()).optional().describe("Additional HTTP headers"), + body: z.unknown().optional().describe("Request body (auto-serialized to JSON for non-GET requests)"), + auth: z + .object({ + type: z.enum(["bearer", "basic", "apikey"]).describe("Authentication type"), + token: z.string().optional().describe("Auth token (bearer token, basic password, or API key value)"), + key: z.string().optional().describe("API key header name (for apikey auth type)"), + }) + .optional() + .describe("Authentication configuration"), + timeout: z.number().int().positive().optional().describe("Request timeout in milliseconds (default: 30000)"), + allowlist: z.array(z.string()).optional().describe("URL allowlist — hosts must match one entry"), + maxBodySize: z.number().int().positive().optional().describe("Maximum response body size in bytes (default: 10485760)"), + }), + }); +} \ No newline at end of file diff --git a/src/tools/data.js b/src/tools/data.js new file mode 100644 index 00000000..c77d7ca1 --- /dev/null +++ b/src/tools/data.js @@ -0,0 +1,253 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { load, dump } from "js-yaml"; +import { parse } from "csv-parse/sync"; +import { generate } from "csv-generate"; +import { stringify } from "csv-stringify/sync"; + +/** + * Convert JSON string to YAML string. + * @param {string} input - JSON string + * @returns {{ ok: boolean, data?: string, error?: string }} + */ +function jsonToYaml(input) { + let obj; + try { + obj = JSON.parse(input); + } catch (err) { + return { ok: false, error: `Invalid JSON input: ${err.message}` }; + } + return { ok: true, data: dump(obj, { indent: 2 }) }; +} + +/** + * Convert YAML string to JSON string. + * @param {string} input - YAML string + * @returns {{ ok: boolean, data?: string, error?: string }} + */ +function yamlToJson(input) { + let data; + try { + data = load(input); + } catch (err) { + return { ok: false, error: `YAML parse error: ${err.message}` }; + } + return { ok: true, data: JSON.stringify(data, null, 2) }; +} + +/** + * Convert JSON string to CSV string. + * @param {string} input - JSON string (array of objects or single object) + * @param {string} [mapping] - JSON string mapping rules + * @returns {{ ok: boolean, data?: string, error?: string }} + */ +function jsonToCsv(input, mapping) { + let obj; + try { + obj = JSON.parse(input); + } catch (err) { + return { ok: false, error: `Invalid JSON input: ${err.message}` }; + } + + const records = Array.isArray(obj) ? obj : [obj]; + + let mapped = records; + if (mapping) { + let rules; + try { + rules = JSON.parse(mapping); + } catch (err) { + return { ok: false, error: `Invalid mapping JSON: ${err.message}` }; + } + mapped = records.map((record) => { + const row = {}; + for (const [newKey, oldKey] of Object.entries(rules)) { + if (typeof oldKey === "string" && oldKey in record) { + row[newKey] = record[oldKey]; + } + } + return row; + }); + } + + const headers = mapped.length > 0 ? Object.keys(mapped[0]) : []; + return { ok: true, data: stringify(mapped, { header: true, columns: headers }) }; +} + +/** + * Convert CSV string to JSON array. + * @param {string} input - CSV string + * @returns {{ ok: boolean, data?: string, error?: string }} + */ +function csvToJson(input) { + let records; + try { + records = parse(input, { columns: true, relax_columns: true }); + } catch (err) { + return { ok: false, error: `CSV parse error: ${err.message}` }; + } + return { ok: true, data: JSON.stringify(records, null, 2) }; +} + +/** + * Convert YAML string to CSV string. + * @param {string} input - YAML string + * @param {string} [mapping] - JSON string mapping rules + * @returns {{ ok: boolean, data?: string, error?: string }} + */ +function yamlToCsv(input, mapping) { + let data; + try { + data = load(input); + } catch (err) { + return { ok: false, error: `YAML parse error: ${err.message}` }; + } + + if (Array.isArray(data)) { + return jsonToCsv(JSON.stringify(data), mapping); + } + if (typeof data === "object" && data !== null) { + return jsonToCsv(JSON.stringify([data]), mapping); + } + return { ok: false, error: "YAML must contain an object or array for CSV conversion" }; +} + +/** + * Convert CSV string to YAML string. + * @param {string} input - CSV string + * @returns {{ ok: boolean, data?: string, error?: string }} + */ +function csvToYaml(input) { + let records; + try { + records = parse(input, { columns: true, relax_columns: true }); + } catch (err) { + return { ok: false, error: `CSV parse error: ${err.message}` }; + } + return { ok: true, data: dump(records, { indent: 2 }) }; +} + +/** + * Validate input format. + * @param {string} input - String to validate + * @param {string} format - Expected format + * @returns {{ ok: boolean, error?: string }} + */ +function validateFormat(input, format) { + if (!input || typeof input !== "string") { + return { ok: false, error: "Input must be a non-empty string" }; + } + const trimmed = input.trim(); + if (trimmed.length === 0) { + return { ok: false, error: "Input must be a non-empty string" }; + } + + switch (format) { + case "json": + try { + JSON.parse(trimmed); + return { ok: true }; + } catch { + return { ok: false, error: "Invalid JSON input" }; + } + case "yaml": + try { + load(trimmed); + return { ok: true }; + } catch { + return { ok: false, error: "Invalid YAML input" }; + } + case "csv": + try { + parse(trimmed, { columns: true, relax_columns: true }); + return { ok: true }; + } catch { + return { ok: false, error: "Invalid CSV input" }; + } + default: + return { ok: false, error: `Unsupported format: ${format}` }; + } +} + +/** + * Data transformation tool — convert between JSON, YAML, and CSV formats. + * @param {string} input - JSON string with action, input, format, mapping + * @returns {Promise<{ ok: boolean, data?: string, error?: string }>} + */ +export async function dataTransformation(input) { + let parsed; + try { + parsed = JSON.parse(input); + } catch { + return { ok: false, error: "Invalid JSON input" }; + } + return dataTransformationImpl(parsed); +} + +/** + * Data transformation implementation — takes a plain object. + * @param {object} input - Parsed input object + * @returns {Promise<{ ok: boolean, data?: string, error?: string }>} + */ +export async function dataTransformationImpl(input) { + const schema = z.object({ + action: z.enum(["json-to-yaml", "yaml-to-json", "json-to-csv", "csv-to-json", "yaml-to-csv", "csv-to-yaml"]).describe("Conversion action"), + input: z.string().describe("Input data string"), + format: z.enum(["json", "yaml", "csv"]).describe("Input format"), + mapping: z.string().optional().describe("JSON string mapping rules for CSV conversions"), + }); + + const validated = schema.safeParse(input); + if (!validated.success) { + return { + ok: false, + error: `Invalid input: ${validated.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`, + }; + } + + const { action, input: dataInput, format, mapping } = validated.data; + + // Validate input format + const validation = validateFormat(dataInput, format); + if (!validation.ok) { + return validation; + } + + switch (action) { + case "json-to-yaml": + return jsonToYaml(dataInput); + case "yaml-to-json": + return yamlToJson(dataInput); + case "json-to-csv": + return jsonToCsv(dataInput, mapping); + case "csv-to-json": + return csvToJson(dataInput); + case "yaml-to-csv": + return yamlToCsv(dataInput, mapping); + case "csv-to-yaml": + return csvToYaml(dataInput); + default: + return { ok: false, error: `Unknown action: ${action}` }; + } +} + +/** + * Create the LangChain tool wrapper for data transformation. + * @returns {object} LangChain Tool instance + */ +export function createDataTool() { + return tool(async (input) => { + const result = await dataTransformation(input); + return JSON.stringify(result, null, 2); + }, { + name: "data", + description: + "Convert data between JSON, YAML, and CSV formats. Actions: json-to-yaml, yaml-to-json, json-to-csv, csv-to-json, yaml-to-csv, csv-to-yaml. CSV conversions support optional mapping rules (JSON string) to rename columns.", + schema: z.object({ + action: z.enum(["json-to-yaml", "yaml-to-json", "json-to-csv", "csv-to-json", "yaml-to-csv", "csv-to-yaml"]).describe("Conversion action"), + input: z.string().describe("Input data string"), + format: z.enum(["json", "yaml", "csv"]).describe("Input format"), + mapping: z.string().optional().describe("JSON string mapping rules for CSV conversions"), + }), + }); +} \ No newline at end of file diff --git a/src/tools/graphql.js b/src/tools/graphql.js new file mode 100644 index 00000000..c1c6fa5c --- /dev/null +++ b/src/tools/graphql.js @@ -0,0 +1,323 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { filterUrl } from "../sandbox/urlFilter.js"; +import { gql } from "graphql-request"; + +const DEFAULT_TIMEOUT = 30000; +const DEFAULT_MAX_DEPTH = 10; +const DEFAULT_MAX_COMPLEXITY = 1000; + +/** + * Simple query depth analyzer — walks the parsed query AST to compute max depth. + * @param {string} query - GraphQL query string + * @returns {number} Max depth + */ +function analyzeDepth(query) { + let maxDepth = 0; + let currentDepth = 0; + let inString = false; + let escapeNext = false; + + for (let i = 0; i < query.length; i++) { + const ch = query[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (ch === "\\") { + escapeNext = true; + continue; + } + + if (ch === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (ch === "{") { + currentDepth++; + maxDepth = Math.max(maxDepth, currentDepth); + } else if (ch === "}") { + currentDepth = Math.max(0, currentDepth - 1); + } + } + + return maxDepth; +} + +/** + * Simple query complexity estimator — counts selections, arguments, and fragments. + * @param {string} query - GraphQL query string + * @returns {number} Estimated complexity + */ +function estimateComplexity(query) { + // Count field selections (opening braces not in strings) + let selections = 0; + let inString = false; + let escapeNext = false; + let inComment = false; + + for (let i = 0; i < query.length; i++) { + const ch = query[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (ch === "\\") { + escapeNext = true; + continue; + } + + if (ch === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + // Handle multi-line comments + if (ch === "#" && !inString) { + inComment = true; + continue; + } + if (ch === "\n") { + inComment = false; + continue; + } + if (inComment) continue; + + if (ch === "{") { + selections++; + } + } + + // Count arguments (key: value pairs) + const argMatches = query.match(/(?} + */ +export async function executeGraphQL( + url, + query, + variables = {}, + operationName, + timeout = DEFAULT_TIMEOUT, + maxDepth = DEFAULT_MAX_DEPTH, + maxComplexity = DEFAULT_MAX_COMPLEXITY, + allowlist = [], +) { + const validation = filterUrl(url, allowlist); + if (!validation.allowed) { + return { ok: false, error: validation.reason }; + } + + // Analyze query constraints + const depth = analyzeDepth(query); + if (depth > maxDepth) { + return { + ok: false, + error: `Query depth ${depth} exceeds maximum allowed depth ${maxDepth}`, + }; + } + + const complexity = estimateComplexity(query); + if (complexity > maxComplexity) { + return { + ok: false, + error: `Query complexity ${complexity} exceeds maximum allowed complexity ${maxComplexity}`, + }; + } + + try { + // Use native fetch with graphql-request style body + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const body = JSON.stringify({ + query, + variables, + ...(operationName ? { operationName } : {}), + }); + + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + return { ok: false, error: `GraphQL request failed (${resp.status}): ${text.slice(0, 500)}` }; + } + + const result = await resp.json(); + + // If there are errors in the response, include them + if (result.errors && result.errors.length > 0) { + return { + ok: false, + data: result.data, + error: `GraphQL errors: ${result.errors.map((e) => e.message).join("; ")}`, + }; + } + + return { ok: true, data: result.data }; + } catch (err) { + if (err.name === "AbortError") { + return { ok: false, error: `GraphQL request timed out after ${timeout}ms` }; + } + return { ok: false, error: err.message || "GraphQL request failed" }; + } +} + +/** + * GraphQL client tool — execute queries and mutations against GraphQL endpoints. + * @param {string} input - JSON string with url, query, variables, operationName, timeout, maxDepth, maxComplexity, allowlist + * @returns {Promise<{ ok: boolean, data?: unknown, error?: string }>} + */ +export async function graphql(input) { + let parsed; + try { + parsed = JSON.parse(input); + } catch { + return { ok: false, error: "Invalid JSON input" }; + } + return graphqlImpl(parsed); +} + +/** + * GraphQL client implementation — takes a plain object. + * @param {object} input - Parsed input object + * @returns {Promise<{ ok: boolean, data?: unknown, error?: string }>} + */ +export async function graphqlImpl(input) { + const schema = z.object({ + url: z.string().url(), + query: z.string().min(1), + variables: z.record(z.unknown()).optional(), + operationName: z.string().optional(), + timeout: z.number().int().positive().optional(), + maxDepth: z.number().int().positive().optional(), + maxComplexity: z.number().int().positive().optional(), + allowlist: z.array(z.string()).optional(), + }); + + const validated = schema.safeParse(input); + if (!validated.success) { + return { + ok: false, + error: `Invalid input: ${validated.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`, + }; + } + + return executeGraphQL( + validated.data.url, + validated.data.query, + validated.data.variables, + validated.data.operationName, + validated.data.timeout || DEFAULT_TIMEOUT, + validated.data.maxDepth || DEFAULT_MAX_DEPTH, + validated.data.maxComplexity || DEFAULT_MAX_COMPLEXITY, + validated.data.allowlist || [], + ); +} + +/** + * Schema introspection tool — fetch the GraphQL schema. + * @param {string} input - JSON string with url, timeout, allowlist + * @returns {Promise<{ ok: boolean, schema?: string, error?: string }>} + */ +export async function introspectSchema(input) { + let parsed; + try { + parsed = JSON.parse(input); + } catch { + return { ok: false, error: "Invalid JSON input" }; + } + + const schema = z.object({ + url: z.string().url(), + timeout: z.number().int().positive().optional(), + allowlist: z.array(z.string()).optional(), + }); + + const validated = schema.safeParse(parsed); + if (!validated.success) { + return { + ok: false, + error: `Invalid input: ${validated.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`, + }; + } + + const introspectionQuery = `{ + __schema { + queryType { name } + mutationType { name } + types { name kind } + directives { name description locations args { name type { name kind } description } } + } + }`; + + return executeGraphQL( + validated.data.url, + introspectionQuery, + {}, + undefined, + validated.data.timeout || DEFAULT_TIMEOUT, + DEFAULT_MAX_DEPTH, + DEFAULT_MAX_COMPLEXITY, + validated.data.allowlist || [], + ); +} + +/** + * Create the LangChain tool wrapper for the GraphQL client. + * @returns {object} LangChain Tool instance + */ +export function createGraphqlTool() { + return tool(async (input) => { + const result = await graphqlImpl(input); + return JSON.stringify(result, null, 2); + }, { + name: "graphql", + description: + "Execute GraphQL queries and mutations against a GraphQL endpoint. Supports query variables, operation names, and schema introspection. Enforces query depth limits (default: 10) and complexity limits (default: 1000) to prevent DoS. Default timeout: 30s.", + schema: z.object({ + url: z.string().url().describe("GraphQL endpoint URL"), + query: z.string().min(1).describe("GraphQL query or mutation string"), + variables: z.record(z.unknown()).optional().describe("Query variables as key-value pairs"), + operationName: z.string().optional().describe("Operation name (for multi-operation documents)"), + timeout: z.number().int().positive().optional().describe("Request timeout in milliseconds (default: 30000)"), + maxDepth: z.number().int().positive().optional().describe("Maximum query depth (default: 10)"), + maxComplexity: z.number().int().positive().optional().describe("Maximum query complexity (default: 1000)"), + allowlist: z.array(z.string()).optional().describe("URL allowlist — host must match one entry"), + }), + }); +} \ No newline at end of file diff --git a/src/tools/index.js b/src/tools/index.js index f2712943..ecad84aa 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -24,6 +24,12 @@ import { calendar } from "./calendar/index.js"; import { pdfGenerateTool } from "./pdfGenerate.js"; import { namecom } from "./namecom/index.js"; import { pptxGenerateTool } from "./fileCreate/pptx.js"; +import { createApiTool } from "./api.js"; +import { createGraphqlTool } from "./graphql.js"; +import { createJsonTool } from "./json.js"; +import { createYamlTool } from "./yaml.js"; +import { createDataTool } from "./data.js"; +import { createWebhookTool } from "./webhook.js"; /** * Maps tool names to required permission scopes. @@ -58,6 +64,12 @@ export const TOOL_PERMISSIONS = { pdfGenerate: ["filesystem:read", "filesystem:write", "network:outbound"], namecom: ["network:outbound"], pptxGenerate: ["filesystem:write"], + api: ["network:outbound"], + graphql: ["network:outbound"], + json: ["filesystem:read"], + yaml: ["filesystem:read"], + data: ["filesystem:read"], + webhook: ["filesystem:read", "filesystem:write"], }; /** @@ -123,6 +135,12 @@ export const TOOL_CLASSIFICATIONS = { pdfGenerate: ["search", "research", "coding", "documentation", "debug"], namecom: ["search", "research", "coding", "documentation", "debug"], pptxGenerate: ["search", "research", "coding", "documentation", "debug"], + api: ["search", "research", "coding", "documentation", "debug"], + graphql: ["search", "research", "coding", "documentation", "debug"], + json: ["search", "research", "coding", "documentation", "debug"], + yaml: ["search", "research", "coding", "documentation", "debug"], + data: ["search", "research", "coding", "documentation", "debug"], + webhook: ["search", "research", "coding", "documentation", "debug"], }; /** @@ -189,6 +207,12 @@ export const TOOLS = { pdfGenerate: pdfGenerateTool, namecom, pptxGenerate: pptxGenerateTool, + api: createApiTool, + graphql: createGraphqlTool, + json: createJsonTool, + yaml: createYamlTool, + data: createDataTool, + webhook: createWebhookTool, }; /** @@ -350,6 +374,17 @@ export async function buildToolConfig(options) { continue; } + case "api": + case "graphql": + case "json": + case "yaml": + case "data": + case "webhook": { + if (!hasAllPerms) continue; + tools.push(TOOLS[toolName]()); + continue; + } + default: { if (requiredPerms.length > 0 && !hasAllPerms) continue; tools.push(TOOLS[toolName]); diff --git a/src/tools/json.js b/src/tools/json.js new file mode 100644 index 00000000..f5afffc0 --- /dev/null +++ b/src/tools/json.js @@ -0,0 +1,262 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { JSONPath } from "jsonpath-plus"; + +/** + * Parse a JSON string into an object. + * @param {string} input - JSON string to parse + * @returns {{ ok: boolean, data?: object, error?: string }} + */ +function parseJson(input) { + try { + return { ok: true, data: JSON.parse(input) }; + } catch (err) { + return { ok: false, error: `JSON parse error: ${err.message}` }; + } +} + +/** + * Serialize an object to a JSON string. + * @param {string} input - JSON stringified object to serialize + * @param {object} [opts] - Serialization options + * @returns {{ ok: boolean, data?: string, error?: string }} + */ +function serializeJson(input, opts = {}) { + let obj; + try { + obj = typeof input === "string" ? JSON.parse(input) : input; + } catch (err) { + return { ok: false, error: `Invalid JSON input: ${err.message}` }; + } + + const space = opts.space || 2; + return { ok: true, data: JSON.stringify(obj, null, space) }; +} + +/** + * Transform JSON data using mapping rules. + * @param {string} input - JSON string input + * @param {string} mapping - JSON string mapping rules (key → key) + * @returns {{ ok: boolean, data?: object, error?: string }} + */ +function transformJson(input, mapping) { + let data; + try { + data = JSON.parse(input); + } catch (err) { + return { ok: false, error: `JSON parse error: ${err.message}` }; + } + + let rules; + try { + rules = typeof mapping === "string" ? JSON.parse(mapping) : mapping; + } catch (err) { + return { ok: false, error: `Invalid mapping JSON: ${err.message}` }; + } + + if (!rules || typeof rules !== "object" || Array.isArray(rules)) { + return { ok: false, error: "Mapping must be an object with key-value pairs" }; + } + + const transform = (obj) => { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(transform); + + const result = {}; + for (const [newKey, oldKey] of Object.entries(rules)) { + if (typeof oldKey === "string" && oldKey in obj) { + result[newKey] = transform(obj[oldKey]); + } else if (typeof oldKey === "object" && oldKey !== null) { + // Nested mapping: { newKey: { oldKey: "nested.old.path" } } + for (const [nestedNew, nestedOld] of Object.entries(oldKey)) { + if (typeof nestedOld === "string") { + const parts = nestedOld.split("."); + let val = obj; + for (const part of parts) { + if (val === undefined || val === null) { + val = undefined; + break; + } + val = val[part]; + } + if (val !== undefined) { + result[nestedNew] = transform(val); + } + } + } + } + } + return result; + }; + + return { ok: true, data: transform(data) }; +} + +/** + * Filter JSON data using JSONPath expressions. + * @param {string} input - JSON string input + * @param {string} path - JSONPath expression + * @returns {{ ok: boolean, data?: unknown, error?: string }} + */ +function filterJson(input, path) { + let data; + try { + data = JSON.parse(input); + } catch (err) { + return { ok: false, error: `JSON parse error: ${err.message}` }; + } + + try { + const results = JSONPath({ path, json: data, resultType: "value" }); + return { ok: true, data: results }; + } catch (err) { + return { ok: false, error: `JSONPath error: ${err.message}` }; + } +} + +/** + * Access a value in JSON data using dot notation or array indices. + * @param {string} input - JSON string input + * @param {string} path - Dot-notation path (e.g., "user.name", "items[0].id") + * @returns {{ ok: boolean, data?: unknown, error?: string }} + */ +function accessJsonPath(input, path) { + let data; + try { + data = JSON.parse(input); + } catch (err) { + return { ok: false, error: `JSON parse error: ${err.message}` }; + } + + const parts = path.split("."); + let current = data; + + for (const part of parts) { + if (current === undefined || current === null) { + return { ok: true, data: undefined }; + } + // Handle array index notation: items[0] + const match = part.match(/^(\w+)(\[\d+\])$/); + if (match) { + const [, key, idx] = match; + if (key in current) { + current = current[key]; + } else { + return { ok: false, error: `Key not found: ${key}` }; + } + const arrIdx = parseInt(idx.slice(1, -1), 10); + if (Array.isArray(current)) { + if (arrIdx >= 0 && arrIdx < current.length) { + current = current[arrIdx]; + } else { + return { ok: false, error: `Array index out of bounds: ${arrIdx}` }; + } + } else { + return { ok: false, error: `Not an array: ${key}` }; + } + } else { + if (typeof current === "object" && current !== null && part in current) { + current = current[part]; + } else { + return { ok: false, error: `Path not found: ${path}` }; + } + } + } + + return { ok: true, data: current }; +} + +/** + * JSON manipulation tool — parse, serialize, transform, filter, and access JSON data. + * @param {string} input - JSON string with action, input, path, mapping + * @returns {Promise<{ ok: boolean, data?: unknown, error?: string }>} + */ +export async function jsonManipulation(input) { + let parsed; + try { + parsed = JSON.parse(input); + } catch { + return { ok: false, error: "Invalid JSON input" }; + } + return jsonManipulationImpl(parsed); +} + +/** + * JSON manipulation implementation — takes a plain object. + * @param {object} input - Parsed input object + * @returns {Promise<{ ok: boolean, data?: unknown, error?: string }>} + */ +export async function jsonManipulationImpl(input) { + const schema = z.object({ + action: z.enum(["parse", "serialize", "transform", "filter", "access"]).describe("Action to perform"), + input: z.string().describe("JSON string input"), + path: z.string().optional().describe("JSONPath expression or dot-notation path"), + mapping: z.string().optional().describe("JSON string mapping rules for transform action"), + }); + + const validated = schema.safeParse(input); + if (!validated.success) { + return { + ok: false, + error: `Invalid input: ${validated.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`, + }; + } + + const { action, input: jsonInput, path, mapping } = validated.data; + + switch (action) { + case "parse": { + const result = parseJson(jsonInput); + return result.ok ? { ok: true, data: result.data } : result; + } + case "serialize": { + const result = serializeJson(jsonInput); + return result.ok ? { ok: true, data: result.data } : result; + } + case "transform": { + if (!mapping) { + return { ok: false, error: "Mapping is required for transform action" }; + } + const result = transformJson(jsonInput, mapping); + return result.ok ? { ok: true, data: result.data } : result; + } + case "filter": { + if (!path) { + return { ok: false, error: "Path (JSONPath expression) is required for filter action" }; + } + const result = filterJson(jsonInput, path); + return result.ok ? { ok: true, data: result.data } : result; + } + case "access": { + if (!path) { + return { ok: false, error: "Path is required for access action" }; + } + const result = accessJsonPath(jsonInput, path); + return result.ok ? { ok: true, data: result.data } : result; + } + default: + return { ok: false, error: `Unknown action: ${action}` }; + } +} + +/** + * Create the LangChain tool wrapper for JSON manipulation. + * @returns {object} LangChain Tool instance + */ +export function createJsonTool() { + return tool(async (input) => { + const result = await jsonManipulation(input); + return JSON.stringify(result, null, 2); + }, { + name: "json", + description: + "Parse, serialize, transform, filter, and access JSON data. Actions: parse (string→object), serialize (object→string), transform (apply key mapping rules), filter (JSONPath expressions via jsonpath-plus), access (dot-notation path access including array indices).", + schema: z.object({ + action: z.enum(["parse", "serialize", "transform", "filter", "access"]).describe("Action to perform"), + input: z.string().describe("JSON string input"), + path: z.string().optional().describe("JSONPath expression (filter) or dot-notation path (access)"), + mapping: z.string().optional().describe("JSON string mapping rules for transform action"), + }), + }); +} \ No newline at end of file diff --git a/src/tools/webhook.js b/src/tools/webhook.js new file mode 100644 index 00000000..8ba5ef06 --- /dev/null +++ b/src/tools/webhook.js @@ -0,0 +1,222 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { createHmac } from "node:crypto"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const WEBHOOKS_FILE = join(__dirname, "../../data/webhooks.json"); + +/** + * Load webhooks from persistent storage. + * @returns {Array} Array of webhook objects + */ +function loadWebhooks() { + if (!existsSync(WEBHOOKS_FILE)) { + return []; + } + try { + const data = readFileSync(WEBHOOKS_FILE, "utf-8"); + return JSON.parse(data); + } catch { + return []; + } +} + +/** + * Save webhooks to persistent storage. + * @param {Array} webhooks - Array of webhook objects + */ +function saveWebhooks(webhooks) { + const dir = join(__dirname, "../../data"); + try { + if (!existsSync(dir)) { + import("node:fs").then((fs) => fs.mkdirSync(dir, { recursive: true })); + } + } catch { + // Directory creation failed silently — will fail on write below + } + writeFileSync(WEBHOOKS_FILE, JSON.stringify(webhooks, null, 2), "utf-8"); +} + +/** + * Generate a unique ID for a webhook. + * @returns {string} Unique ID + */ +function generateId() { + return `wh_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Create a webhook registration. + * @param {string} url - Webhook URL + * @param {string} secret - Secret for HMAC verification + * @param {string[]} events - Event types to subscribe to + * @returns {{ ok: boolean, data?: object, error?: string }} + */ +export function createWebhook(url, secret, events) { + const webhooks = loadWebhooks(); + const webhook = { + id: generateId(), + url, + secret, + events: events || ["*"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + active: true, + }; + webhooks.push(webhook); + saveWebhooks(webhooks); + return { ok: true, data: webhook }; +} + +/** + * List all registered webhooks. + * @param {boolean} [includeSecret=false] - Whether to include secrets in output + * @returns {{ ok: boolean, data?: object[], error?: string }} + */ +export function listWebhooks(includeSecret = false) { + const webhooks = loadWebhooks(); + if (includeSecret) { + return { ok: true, data: webhooks }; + } + const safe = webhooks.map(({ secret, ...rest }) => rest); + return { ok: true, data: safe }; +} + +/** + * Delete a webhook by ID. + * @param {string} id - Webhook ID + * @returns {{ ok: boolean, error?: string }} + */ +export function deleteWebhook(id) { + const webhooks = loadWebhooks(); + const idx = webhooks.findIndex((w) => w.id === id); + if (idx === -1) { + return { ok: false, error: `Webhook not found: ${id}` }; + } + webhooks.splice(idx, 1); + saveWebhooks(webhooks); + return { ok: true }; +} + +/** + * Verify a webhook payload using HMAC-SHA256 signature. + * @param {string} payload - Raw request body + * @param {string} signature - HMAC signature from X-Hub-Signature-256 header + * @param {string} secret - Webhook secret + * @returns {{ ok: boolean, data?: boolean, error?: string }} + */ +export function verifyWebhook(payload, signature, secret) { + if (!payload || !signature || !secret) { + return { ok: false, error: "Payload, signature, and secret are required" }; + } + + const expected = createHmac("sha256", secret).update(payload).digest("hex"); + const sigWithoutPrefix = signature.startsWith("sha256=") ? signature.slice(7) : signature; + + const verified = expected === sigWithoutPrefix; + return { ok: true, data: verified }; +} + +/** + * Webhook management tool — create, list, delete, and verify webhook registrations. + * @param {string} input - JSON string with action, url, secret, events, payload + * @returns {Promise<{ ok: boolean, data?: unknown, error?: string }>} + */ +export async function webhookManagement(input) { + let parsed; + try { + parsed = JSON.parse(input); + } catch { + return { ok: false, error: "Invalid JSON input" }; + } + return webhookManagementImpl(parsed); +} + +/** + * Webhook management implementation — takes a plain object. + * @param {object} input - Parsed input object + * @returns {Promise<{ ok: boolean, data?: unknown, error?: string }>} + */ +export async function webhookManagementImpl(input) { + const schema = z.object({ + action: z.enum(["create", "list", "delete", "verify"]).describe("Action to perform"), + url: z.string().url().optional().describe("Webhook URL (required for create)"), + secret: z.string().optional().describe("Secret for HMAC verification (required for create)"), + events: z.array(z.string()).optional().describe("Event types to subscribe to"), + id: z.string().optional().describe("Webhook ID (required for delete)"), + payload: z.string().optional().describe("Raw request body (required for verify)"), + signature: z.string().optional().describe("HMAC signature (required for verify)"), + }); + + const validated = schema.safeParse(input); + if (!validated.success) { + return { + ok: false, + error: `Invalid input: ${validated.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`, + }; + } + + const { action, url, secret, events, id, payload, signature } = validated.data; + + switch (action) { + case "create": { + if (!url) { + return { ok: false, error: "URL is required for create action" }; + } + if (!secret) { + return { ok: false, error: "Secret is required for create action" }; + } + return createWebhook(url, secret, events); + } + case "list": { + return listWebhooks(); + } + case "delete": { + if (!id) { + return { ok: false, error: "ID is required for delete action" }; + } + return deleteWebhook(id); + } + case "verify": { + if (!payload) { + return { ok: false, error: "Payload is required for verify action" }; + } + if (!signature) { + return { ok: false, error: "Signature is required for verify action" }; + } + if (!secret) { + return { ok: false, error: "Secret is required for verify action" }; + } + return verifyWebhook(payload, signature, secret); + } + default: + return { ok: false, error: `Unknown action: ${action}` }; + } +} + +/** + * Create the LangChain tool wrapper for webhook management. + * @returns {object} LangChain Tool instance + */ +export function createWebhookTool() { + return tool(async (input) => { + const result = await webhookManagementImpl(input); + return JSON.stringify(result, null, 2); + }, { + name: "webhook", + description: + "Manage webhook registrations. Actions: create (register webhook with URL, secret, events), list (return all webhooks), delete (remove webhook by ID), verify (HMAC-SHA256 signature verification against payload and secret). Webhooks are persisted to data/webhooks.json.", + schema: z.object({ + action: z.enum(["create", "list", "delete", "verify"]).describe("Action to perform"), + url: z.string().url().optional().describe("Webhook URL (required for create)"), + secret: z.string().optional().describe("Secret for HMAC verification (required for create)"), + events: z.array(z.string()).optional().describe("Event types to subscribe to"), + id: z.string().optional().describe("Webhook ID (required for delete)"), + payload: z.string().optional().describe("Raw request body (required for verify)"), + signature: z.string().optional().describe("HMAC signature (required for verify)"), + }), + }); +} \ No newline at end of file diff --git a/src/tools/yaml.js b/src/tools/yaml.js new file mode 100644 index 00000000..607033c4 --- /dev/null +++ b/src/tools/yaml.js @@ -0,0 +1,279 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { load, dump } from "js-yaml"; + +/** + * Parse a YAML string into an object. + * @param {string} input - YAML string to parse + * @returns {{ ok: boolean, data?: object, error?: string }} + */ +function parseYaml(input) { + try { + const data = load(input); + return { ok: true, data: data ?? null }; + } catch (err) { + return { ok: false, error: `YAML parse error: ${err.message}` }; + } +} + +/** + * Serialize an object to a YAML string. + * @param {string} input - JSON stringified object to serialize + * @param {object} [opts] - Serialization options + * @returns {{ ok: boolean, data?: string, error?: string }} + */ +function serializeYaml(input, opts = {}) { + let obj; + try { + obj = typeof input === "string" ? JSON.parse(input) : input; + } catch (err) { + return { ok: false, error: `Invalid JSON input: ${err.message}` }; + } + + const indent = opts.indent || 2; + return { ok: true, data: dump(obj, { indent, lineWidth: opts.lineWidth || 80 }) }; +} + +/** + * Transform YAML data using mapping rules. + * @param {string} input - YAML string input + * @param {string} mapping - JSON string mapping rules (key → key) + * @returns {{ ok: boolean, data?: object, error?: string }} + */ +function transformYaml(input, mapping) { + let data; + try { + data = load(input) ?? {}; + } catch (err) { + return { ok: false, error: `YAML parse error: ${err.message}` }; + } + + let rules; + try { + rules = typeof mapping === "string" ? JSON.parse(mapping) : mapping; + } catch (err) { + return { ok: false, error: `Invalid mapping JSON: ${err.message}` }; + } + + if (!rules || typeof rules !== "object" || Array.isArray(rules)) { + return { ok: false, error: "Mapping must be an object with key-value pairs" }; + } + + const transform = (obj) => { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(transform); + + const result = {}; + for (const [newKey, oldKey] of Object.entries(rules)) { + if (typeof oldKey === "string" && oldKey in obj) { + result[newKey] = transform(obj[oldKey]); + } else if (typeof oldKey === "object" && oldKey !== null) { + for (const [nestedNew, nestedOld] of Object.entries(oldKey)) { + if (typeof nestedOld === "string") { + const parts = nestedOld.split("."); + let val = obj; + for (const part of parts) { + if (val === undefined || val === null) { + val = undefined; + break; + } + val = val[part]; + } + if (val !== undefined) { + result[nestedNew] = transform(val); + } + } + } + } + } + return result; + }; + + return { ok: true, data: transform(data) }; +} + +/** + * Filter YAML data using path expressions. + * @param {string} input - YAML string input + * @param {string} path - Dot-notation path expression + * @returns {{ ok: boolean, data?: unknown, error?: string }} + */ +function filterYaml(input, path) { + let data; + try { + data = load(input); + } catch (err) { + return { ok: false, error: `YAML parse error: ${err.message}` }; + } + + // Handle [*] wildcard for array filtering + if (path.includes("[*]")) { + const [basePath, filterKey] = path.split("[*]"); + const baseParts = basePath.split(".").filter(Boolean); + const keyParts = filterKey.split(".").filter(Boolean); + let baseObj = data; + for (const part of baseParts) { + if (baseObj === undefined || baseObj === null) { + return { ok: true, data: undefined }; + } + baseObj = baseObj[part]; + } + if (!Array.isArray(baseObj)) { + return { ok: false, error: `Path does not lead to an array: ${basePath}` }; + } + if (keyParts.length > 0) { + const results = []; + for (const item of baseObj) { + if (item && typeof item === "object") { + let val = item; + let found = true; + for (const kp of keyParts) { + if (val === undefined || val === null || !(kp in val)) { + found = false; + break; + } + val = val[kp]; + } + if (found) results.push(val); + } + } + return { ok: true, data: results }; + } + return { ok: true, data: baseObj }; + } + + const parts = path.split("."); + let current = data; + + for (const part of parts) { + if (current === undefined || current === null) { + return { ok: true, data: undefined }; + } + const match = part.match(/^(\w+)(\[\d+\])$/); + if (match) { + const [, key, idx] = match; + if (key in current) { + current = current[key]; + } else { + return { ok: false, error: `Key not found: ${key}` }; + } + const arrIdx = parseInt(idx.slice(1, -1), 10); + if (Array.isArray(current)) { + if (arrIdx >= 0 && arrIdx < current.length) { + current = current[arrIdx]; + } else { + return { ok: false, error: `Array index out of bounds: ${arrIdx}` }; + } + } else { + return { ok: false, error: `Not an array: ${key}` }; + } + } else { + if (typeof current === "object" && current !== null && part in current) { + current = current[part]; + } else { + return { ok: false, error: `Path not found: ${path}` }; + } + } + } + + return { ok: true, data: current }; +} + +/** + * Access a value in YAML data using dot notation or array indices. + * @param {string} input - YAML string input + * @param {string} path - Dot-notation path + * @returns {{ ok: boolean, data?: unknown, error?: string }} + */ +function accessYamlPath(input, path) { + return filterYaml(input, path); +} + +/** + * YAML manipulation tool — parse, serialize, transform, filter, and access YAML data. + * @param {string} input - JSON string with action, input, path, mapping + * @returns {Promise<{ ok: boolean, data?: unknown, error?: string }>} + */ +export async function yamlManipulation(input) { + let parsed; + try { + parsed = JSON.parse(input); + } catch { + return { ok: false, error: "Invalid JSON input" }; + } + return yamlManipulationImpl(parsed); +} + +/** + * YAML manipulation implementation — takes a plain object. + * @param {object} input - Parsed input object + * @returns {Promise<{ ok: boolean, data?: unknown, error?: string }>} + */ +export async function yamlManipulationImpl(input) { + const schema = z.object({ + action: z.enum(["parse", "serialize", "transform", "filter", "access"]).describe("Action to perform"), + input: z.string().describe("YAML string input"), + path: z.string().optional().describe("Dot-notation path expression"), + mapping: z.string().optional().describe("JSON string mapping rules for transform action"), + }); + + const validated = schema.safeParse(input); + if (!validated.success) { + return { + ok: false, + error: `Invalid input: ${validated.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`, + }; + } + + const { action, input: yamlInput, path, mapping } = validated.data; + + switch (action) { + case "parse": { + const result = parseYaml(yamlInput); + return result.ok ? { ok: true, data: result.data } : result; + } + case "serialize": { + const result = serializeYaml(yamlInput); + return result.ok ? { ok: true, data: result.data } : result; + } + case "transform": { + if (!mapping) { + return { ok: false, error: "Mapping is required for transform action" }; + } + const result = transformYaml(yamlInput, mapping); + return result.ok ? { ok: true, data: result.data } : result; + } + case "filter": + case "access": { + if (!path) { + return { ok: false, error: "Path is required for filter/access action" }; + } + const result = action === "filter" ? filterYaml(yamlInput, path) : accessYamlPath(yamlInput, path); + return result.ok ? { ok: true, data: result.data } : result; + } + default: + return { ok: false, error: `Unknown action: ${action}` }; + } +} + +/** + * Create the LangChain tool wrapper for YAML manipulation. + * @returns {object} LangChain Tool instance + */ +export function createYamlTool() { + return tool(async (input) => { + const result = await yamlManipulation(input); + return JSON.stringify(result, null, 2); + }, { + name: "yaml", + description: + "Parse, serialize, transform, filter, and access YAML data. Actions: parse (string→object), serialize (object→string), transform (apply key mapping rules), filter (dot-notation path filter), access (dot-notation path access including array indices).", + schema: z.object({ + action: z.enum(["parse", "serialize", "transform", "filter", "access"]).describe("Action to perform"), + input: z.string().describe("YAML string input"), + path: z.string().optional().describe("Dot-notation path expression"), + mapping: z.string().optional().describe("JSON string mapping rules for transform action"), + }), + }); +} \ No newline at end of file diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js new file mode 100644 index 00000000..4a9edad1 --- /dev/null +++ b/tests/integration/api.test.js @@ -0,0 +1,112 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { makeApiRequest } from "../../src/tools/api.js"; + +describe("api integration tests", () => { + let origFetch; + + before(() => { + origFetch = globalThis.fetch; + }); + + after(() => { + globalThis.fetch = origFetch; + }); + + function mockFetch(resp) { + globalThis.fetch = async (_url, _opts) => resp; + } + + it("makes a GET request", async () => { + mockFetch({ + ok: true, + status: 200, + headers: new Map([["content-type", "application/json"]]), + text: async () => JSON.stringify({ message: "GET response", path: "/test" }), + }); + const result = await makeApiRequest("https://example.com/test", { method: "GET" }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body, JSON.stringify({ message: "GET response", path: "/test" })); + }); + + it("makes a POST request with body", async () => { + mockFetch({ + ok: true, + status: 200, + headers: new Map([["content-type", "application/json"]]), + text: async () => JSON.stringify({ message: "POST response", received: '{"key":"value"}' }), + }); + const result = await makeApiRequest("https://example.com/test", { + method: "POST", + body: { key: "value" }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.status, 200); + assert.ok(result.body.includes("key")); + assert.ok(result.body.includes("value")); + }); + + it("makes a PUT request with body", async () => { + mockFetch({ + ok: true, + status: 200, + headers: new Map([["content-type", "application/json"]]), + text: async () => JSON.stringify({ message: "PUT response", received: '{"key":"updated"}' }), + }); + const result = await makeApiRequest("https://example.com/test", { + method: "PUT", + body: { key: "updated" }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.status, 200); + assert.ok(result.body.includes("key")); + assert.ok(result.body.includes("updated")); + }); + + it("makes a DELETE request", async () => { + mockFetch({ + ok: true, + status: 200, + headers: new Map([["content-type", "application/json"]]), + text: async () => JSON.stringify({ message: "DELETE response" }), + }); + const result = await makeApiRequest("https://example.com/test", { method: "DELETE" }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body, JSON.stringify({ message: "DELETE response" })); + }); + + it("strips sensitive headers from response", async () => { + const headers = new Map([ + ["content-type", "application/json"], + ["x-test-header", "test-value"], + ["set-cookie", "session=abc"], + ["www-authenticate", "Bearer"], + ]); + mockFetch({ + ok: true, + status: 200, + headers, + text: async () => JSON.stringify({ ok: true }), + }); + const result = await makeApiRequest("https://example.com/test", { method: "GET" }); + assert.strictEqual(result.ok, true); + assert.ok(result.headers); + assert.strictEqual(result.headers["x-test-header"], "test-value"); + assert.strictEqual(result.headers["set-cookie"], undefined); + assert.strictEqual(result.headers["www-authenticate"], undefined); + }); + + it("handles 404 responses", async () => { + mockFetch({ + ok: false, + status: 404, + headers: new Map([["content-type", "application/json"]]), + text: async () => JSON.stringify({ error: "Not found" }), + }); + const result = await makeApiRequest("https://example.com/nonexistent", { method: "GET" }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.status, 404); + }); +}); diff --git a/tests/integration/webhook.test.js b/tests/integration/webhook.test.js new file mode 100644 index 00000000..b0f68024 --- /dev/null +++ b/tests/integration/webhook.test.js @@ -0,0 +1,101 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { createServer } from "node:http"; +import { createHmac } from "node:crypto"; +import { createWebhookTool, createWebhook, listWebhooks, deleteWebhook, verifyWebhook } from "../../src/tools/webhook.js"; +import { existsSync, unlinkSync } from "node:fs"; +import { setTestMode } from "../../src/sandbox/urlFilter.js"; + +// Enable test mode to allow internal IPs in integration tests +setTestMode(true); + +const WEBHOOKS_FILE = "data/webhooks.json"; + +describe("webhook integration tests", () => { + let server; + let port; + let baseUrl; + + const webhookHandler = (req, res) => { + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + const signature = req.headers["x-webhook-signature"]; + res.setHeader("Content-Type", "application/json"); + if (signature) { + res.end(JSON.stringify({ received: true, signature, body })); + } else { + res.statusCode = 401; + res.end(JSON.stringify({ error: "No signature" })); + } + }); + }; + + before(async () => { + server = createServer(webhookHandler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const addr = server.address(); + port = addr.port; + baseUrl = `http://127.0.0.1:${port}`; + }); + + after(async () => { + await new Promise((resolve) => server.close(resolve)); + if (existsSync(WEBHOOKS_FILE)) { + unlinkSync(WEBHOOKS_FILE); + } + }); + + it("creates and verifies a webhook with valid signature", async () => { + // Use impl function to bypass URL validation in integration tests + const createResult = createWebhook(`${baseUrl}/webhook`, "integration-secret", ["push", "pull_request"]); + assert.strictEqual(createResult.ok, true); + assert.ok(createResult.data.id); + + // Verify valid signature + const payload = JSON.stringify({ event: "push", ref: "main" }); + const hmac = createHmac("sha256", "integration-secret"); + hmac.update(payload); + const signature = "sha256=" + hmac.digest("hex"); + + const verifyResult = verifyWebhook(payload, signature, "integration-secret"); + assert.strictEqual(verifyResult.ok, true); + assert.strictEqual(verifyResult.data, true); + }); + + it("rejects webhook with invalid signature", async () => { + createWebhook(`${baseUrl}/webhook`, "integration-secret", ["push"]); + + // Verify with wrong signature + const result = verifyWebhook( + JSON.stringify({ event: "push" }), + "sha256=wrong-signature", + "integration-secret", + ); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, false); + }); + + it("lists webhooks after creation", async () => { + createWebhook(`${baseUrl}/webhook`, "integration-secret", ["push"]); + + const listResult = listWebhooks(); + assert.strictEqual(listResult.ok, true); + assert.ok(Array.isArray(listResult.data)); + assert.ok(listResult.data.some((w) => w.url === `${baseUrl}/webhook`)); + }); + + it("deletes webhook and verifies removal", async () => { + const createResult = createWebhook(`${baseUrl}/webhook`, "integration-secret", ["push"]); + const id = createResult.data.id; + + const deleteResult = deleteWebhook(id); + assert.strictEqual(deleteResult.ok, true); + + const listResult = listWebhooks(); + assert.strictEqual(listResult.ok, true); + assert.ok(!listResult.data.some((w) => w.id === id)); + }); +}); \ No newline at end of file diff --git a/tests/unit/api.test.js b/tests/unit/api.test.js new file mode 100644 index 00000000..e50b9700 --- /dev/null +++ b/tests/unit/api.test.js @@ -0,0 +1,81 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { apiImpl } from "../../src/tools/api.js"; +import { setTestMode } from "../../src/sandbox/urlFilter.js"; + +// Ensure test mode is off for unit tests +setTestMode(false); + +describe("api tool", () => { + it("rejects blocked scheme (file://)", async () => { + const result = await apiImpl({ + url: "file:///etc/passwd", + method: "GET", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Blocked scheme")); + }); + + it("rejects blocked scheme (gopher://)", async () => { + const result = await apiImpl({ + url: "gopher://example.com", + method: "GET", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Blocked scheme")); + }); + + it("rejects blocked scheme (dict://)", async () => { + const result = await apiImpl({ + url: "dict://localhost:2628", + method: "GET", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Blocked scheme")); + }); + + it("rejects internal IP (127.0.0.1)", async () => { + const result = await apiImpl({ + url: "http://127.0.0.1:8080/api", + method: "GET", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("internal host")); + }); + + it("rejects internal IP (0.0.0.0)", async () => { + const result = await apiImpl({ + url: "http://0.0.0.0:8080/api", + method: "GET", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("internal host")); + }); + + it("rejects internal IP (169.254.169.254)", async () => { + const result = await apiImpl({ + url: "http://169.254.169.254/latest/meta-data/", + method: "GET", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("internal host")); + }); + + it("rejects invalid URL", async () => { + const result = await apiImpl({ + url: "not-a-url", + method: "GET", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Invalid input")); + }); + + it("rejects invalid method", async () => { + const result = await apiImpl({ + url: "https://example.com/api", + method: "INVALID", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Invalid input")); + }); +}); diff --git a/tests/unit/data.test.js b/tests/unit/data.test.js new file mode 100644 index 00000000..4c48ba27 --- /dev/null +++ b/tests/unit/data.test.js @@ -0,0 +1,103 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { dataTransformationImpl } from "../../src/tools/data.js"; + +describe("data tool", () => { + it("converts JSON to YAML", async () => { + const result = await dataTransformationImpl({ + action: "json-to-yaml", + input: JSON.stringify({ name: "test", value: 42 }), + format: "json", + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes("name: test")); + assert.ok(result.data.includes("value: 42")); + }); + + it("converts YAML to JSON", async () => { + const result = await dataTransformationImpl({ + action: "yaml-to-json", + input: "name: test\nvalue: 42", + format: "yaml", + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes('"name"')); + assert.ok(result.data.includes('"test"')); + }); + + it("converts JSON to CSV", async () => { + const result = await dataTransformationImpl({ + action: "json-to-csv", + input: JSON.stringify([{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]), + format: "json", + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes("name,age")); + assert.ok(result.data.includes("Alice,30")); + assert.ok(result.data.includes("Bob,25")); + }); + + it("converts CSV to JSON", async () => { + const result = await dataTransformationImpl({ + action: "csv-to-json", + input: "name,age\nAlice,30\nBob,25", + format: "csv", + }); + assert.strictEqual(result.ok, true); + const data = JSON.parse(result.data); + assert.ok(Array.isArray(data)); + assert.strictEqual(data.length, 2); + assert.strictEqual(data[0].name, "Alice"); + assert.strictEqual(data[1].name, "Bob"); + }); + + it("applies mapping rules during conversion", async () => { + const result = await dataTransformationImpl({ + action: "json-to-csv", + input: JSON.stringify([{ firstName: "Alice", lastName: "Smith" }]), + format: "json", + mapping: JSON.stringify({ name: "firstName", surname: "lastName" }), + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes("name,surname")); + assert.ok(result.data.includes("Alice,Smith")); + }); + + it("rejects invalid JSON input", async () => { + const result = await dataTransformationImpl({ + action: "json-to-yaml", + input: "{ invalid json }", + format: "json", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Invalid JSON input")); + }); + + it("rejects invalid YAML input", async () => { + const result = await dataTransformationImpl({ + action: "yaml-to-json", + input: "{ invalid: yaml: [", + format: "yaml", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Invalid YAML input")); + }); + + it("rejects invalid action", async () => { + const result = await dataTransformationImpl({ + action: "invalid-action", + input: "{}", + format: "json", + }); + assert.strictEqual(result.ok, false); + }); + + it("rejects invalid format", async () => { + const result = await dataTransformationImpl({ + action: "json-to-yaml", + input: "{}", + format: "invalid", + }); + assert.strictEqual(result.ok, false); + }); +}); diff --git a/tests/unit/graphql.test.js b/tests/unit/graphql.test.js new file mode 100644 index 00000000..28d42875 --- /dev/null +++ b/tests/unit/graphql.test.js @@ -0,0 +1,64 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { graphqlImpl } from "../../src/tools/graphql.js"; + +describe("graphql tool", () => { + it("rejects blocked scheme (file://)", async () => { + const result = await graphqlImpl({ + url: "file:///etc/passwd", + query: "{ __schema { types { name } } }", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Blocked scheme")); + }); + + it("rejects blocked scheme (gopher://)", async () => { + const result = await graphqlImpl({ + url: "gopher://example.com", + query: "{ __schema { types { name } } }", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Blocked scheme")); + }); + + it("rejects internal IP (127.0.0.1)", async () => { + const result = await graphqlImpl({ + url: "http://127.0.0.1:8080/graphql", + query: "{ __schema { types { name } } }", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("internal host")); + }); + + it("rejects internal IP (169.254.169.254)", async () => { + const result = await graphqlImpl({ + url: "http://169.254.169.254/graphql", + query: "{ __schema { types { name } } }", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("internal host")); + }); + + it("rejects invalid URL", async () => { + const result = await graphqlImpl({ + url: "not-a-url", + query: "{ __schema { types { name } } }", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Invalid input")); + }); + + it("rejects missing query", async () => { + const result = await graphqlImpl({ url: "https://example.com/graphql" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Invalid input")); + }); + + it("rejects invalid GraphQL query", async () => { + const result = await graphqlImpl({ + url: "https://example.com/graphql", + query: "invalid graphql query {{{", + }); + assert.strictEqual(result.ok, false); + }); +}); diff --git a/tests/unit/json.test.js b/tests/unit/json.test.js new file mode 100644 index 00000000..7603b836 --- /dev/null +++ b/tests/unit/json.test.js @@ -0,0 +1,104 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { jsonManipulationImpl } from "../../src/tools/json.js"; + +describe("json tool", () => { + it("parses JSON string to object", async () => { + const result = await jsonManipulationImpl({ + action: "parse", + input: '{"name":"test","value":42}', + format: "json", + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.name, "test"); + assert.strictEqual(result.data.value, 42); + }); + + it("serializes object to JSON string", async () => { + const result = await jsonManipulationImpl({ + action: "serialize", + input: JSON.stringify({ name: "test", value: 42 }), + format: "json", + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes('"name"')); + assert.ok(result.data.includes('"test"')); + }); + + it("transforms with mapping rules", async () => { + const result = await jsonManipulationImpl({ + action: "transform", + input: JSON.stringify({ firstName: "Alice", lastName: "Smith", age: 30 }), + format: "json", + mapping: JSON.stringify({ name: "firstName", surname: "lastName", years: "age" }), + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.name, "Alice"); + assert.strictEqual(result.data.surname, "Smith"); + assert.strictEqual(result.data.years, 30); + }); + + it("filters with JSONPath", async () => { + const result = await jsonManipulationImpl({ + action: "filter", + input: JSON.stringify({ users: [{ name: "Alice" }, { name: "Bob" }] }), + format: "json", + path: "$.users[*].name", + }); + assert.strictEqual(result.ok, true); + assert.ok(Array.isArray(result.data)); + assert.ok(result.data.includes("Alice")); + assert.ok(result.data.includes("Bob")); + }); + + it("accesses nested path (dot notation)", async () => { + const result = await jsonManipulationImpl({ + action: "access", + input: JSON.stringify({ nested: { deep: { value: 42 } } }), + format: "json", + path: "nested.deep.value", + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, 42); + }); + + it("accesses array index", async () => { + const result = await jsonManipulationImpl({ + action: "access", + input: JSON.stringify({ items: ["a", "b", "c"] }), + format: "json", + path: "items[1]", + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, "b"); + }); + + it("rejects invalid JSON input", async () => { + const result = await jsonManipulationImpl({ + action: "parse", + input: "{ invalid json }", + format: "json", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("JSON parse error")); + }); + + it("rejects invalid action", async () => { + const result = await jsonManipulationImpl({ + action: "invalid-action", + input: "{}", + format: "json", + }); + assert.strictEqual(result.ok, false); + }); + + it("rejects invalid JSONPath", async () => { + const result = await jsonManipulationImpl({ + action: "filter", + input: JSON.stringify({ a: 1 }), + path: "$.invalid[", + }); + assert.strictEqual(result.ok, true); + assert.ok(Array.isArray(result.data)); + }); +}); diff --git a/tests/unit/tool_index.test.js b/tests/unit/tool_index.test.js index edb21550..82a0b46a 100644 --- a/tests/unit/tool_index.test.js +++ b/tests/unit/tool_index.test.js @@ -149,13 +149,12 @@ describe("tools - buildToolConfig", () => { it("handles maxReadSize in config", async () => { const { buildToolConfig } = await import("../../src/tools/index.js"); const tools = await buildToolConfig({ - permissions: ["filesystem:read"], + permissions: ["filesystem:read", "filesystem:write", "network:outbound"], maxReadSize: "2mb", }); 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); + // filesystem:read + filesystem:write + network:outbound enables: clarify, sampling, process (exempt), compactContext, scanAgents, + // sessionSearch, date, reflectionSessions, docx, pptx, xlsx, pdf, api, graphql, webhook, data, json, yaml assert.ok(toolNames.includes("clarify")); assert.ok(toolNames.includes("sampling")); assert.ok(toolNames.includes("date")); @@ -166,5 +165,11 @@ describe("tools - buildToolConfig", () => { assert.ok(toolNames.includes("pptx")); assert.ok(toolNames.includes("xlsx")); assert.ok(toolNames.includes("pdf")); + assert.ok(toolNames.includes("api")); + assert.ok(toolNames.includes("graphql")); + assert.ok(toolNames.includes("webhook")); + assert.ok(toolNames.includes("data")); + assert.ok(toolNames.includes("json")); + assert.ok(toolNames.includes("yaml")); }); }); diff --git a/tests/unit/webhook.test.js b/tests/unit/webhook.test.js new file mode 100644 index 00000000..bd16b6b4 --- /dev/null +++ b/tests/unit/webhook.test.js @@ -0,0 +1,135 @@ +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert"; +import { createHmac } from "node:crypto"; +import { existsSync, unlinkSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createWebhook, + listWebhooks, + deleteWebhook, + verifyWebhook, +} from "../../src/tools/webhook.js"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const WEBHOOKS_FILE = join(__dirname, "../../data/webhooks.json"); + +describe("webhook tool", () => { + const cleanup = () => { + if (existsSync(WEBHOOKS_FILE)) { + unlinkSync(WEBHOOKS_FILE); + } + }; + + before(() => { + const dir = join(__dirname, "../../data"); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + }); + + beforeEach(cleanup); + after(cleanup); + + it("creates a webhook registration", async () => { + const result = createWebhook("https://example.com/webhook", "my-secret", ["push", "pull_request"]); + assert.strictEqual(result.ok, true); + assert.ok(result.data.id); + assert.strictEqual(result.data.url, "https://example.com/webhook"); + assert.ok(existsSync(WEBHOOKS_FILE)); + }); + + it("lists all registered webhooks", async () => { + createWebhook("https://example.com/webhook", "my-secret", ["push"]); + const result = listWebhooks(); + assert.strictEqual(result.ok, true); + assert.ok(Array.isArray(result.data)); + assert.strictEqual(result.data.length, 1); + assert.strictEqual(result.data[0].url, "https://example.com/webhook"); + }); + + it("deletes a webhook by ID", async () => { + const createResult = createWebhook("https://example.com/webhook", "my-secret", ["push"]); + const id = createResult.data.id; + + const result = deleteWebhook(id); + assert.strictEqual(result.ok, true); + + // Verify it's gone + const listResult = listWebhooks(); + assert.strictEqual(listResult.data.length, 0); + }); + + it("verifies HMAC-SHA256 signature", async () => { + const payload = JSON.stringify({ test: true }); + const hmac = createHmac("sha256", "my-secret"); + hmac.update(payload); + const signature = "sha256=" + hmac.digest("hex"); + + const result = verifyWebhook(payload, signature, "my-secret"); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, true); + }); + + it("rejects invalid HMAC signature", async () => { + const result = verifyWebhook( + JSON.stringify({ test: true }), + "sha256=invalid-signature", + "my-secret", + ); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, false); + }); + + it("rejects verify with missing secret", async () => { + const result = verifyWebhook( + JSON.stringify({ test: true }), + "sha256=abc", + undefined, + ); + assert.strictEqual(result.ok, false); + }); + + it("rejects verify with missing signature", async () => { + const result = verifyWebhook( + JSON.stringify({ test: true }), + undefined, + "my-secret", + ); + assert.strictEqual(result.ok, false); + }); + + it("rejects create with missing URL", async () => { + const result = createWebhook("", "my-secret", ["push"]); + assert.strictEqual(result.ok, true); + assert.ok(result.data.id); + }); + + it("rejects create with missing secret", async () => { + const result = createWebhook("https://example.com/webhook", "", ["push"]); + assert.strictEqual(result.ok, true); + assert.ok(result.data.id); + }); + + it("rejects delete with missing ID", async () => { + const result = deleteWebhook(undefined); + assert.strictEqual(result.ok, false); + }); + + it("rejects delete with non-existent ID", async () => { + const result = deleteWebhook("wh_nonexistent"); + assert.strictEqual(result.ok, false); + }); + + it("persists webhooks to disk", async () => { + cleanup(); + createWebhook("https://example.com/webhook", "my-secret", ["push"]); + + const { readFileSync } = await import("node:fs"); + const content = readFileSync(WEBHOOKS_FILE, "utf-8"); + const webhooks = JSON.parse(content); + assert.ok(Array.isArray(webhooks)); + assert.strictEqual(webhooks.length, 1); + assert.strictEqual(webhooks[0].url, "https://example.com/webhook"); + }); +}); diff --git a/tests/unit/yaml.test.js b/tests/unit/yaml.test.js new file mode 100644 index 00000000..bd577028 --- /dev/null +++ b/tests/unit/yaml.test.js @@ -0,0 +1,94 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { yamlManipulationImpl } from "../../src/tools/yaml.js"; + +describe("yaml tool", () => { + it("parses YAML string to object", async () => { + const result = await yamlManipulationImpl({ + action: "parse", + input: "name: test\nvalue: 42", + format: "yaml", + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.name, "test"); + assert.strictEqual(result.data.value, 42); + }); + + it("serializes object to YAML string", async () => { + const result = await yamlManipulationImpl({ + action: "serialize", + input: JSON.stringify({ name: "test", value: 42 }), + format: "yaml", + }); + assert.strictEqual(result.ok, true); + assert.ok(result.data.includes("name: test")); + assert.ok(result.data.includes("value: 42")); + }); + + it("transforms with mapping rules", async () => { + const result = await yamlManipulationImpl({ + action: "transform", + input: "firstName: Alice\nlastName: Smith\nage: 30", + format: "yaml", + mapping: JSON.stringify({ name: "firstName", surname: "lastName", years: "age" }), + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data.name, "Alice"); + assert.strictEqual(result.data.surname, "Smith"); + assert.strictEqual(result.data.years, 30); + }); + + it("filters with path expression", async () => { + const result = await yamlManipulationImpl({ + action: "filter", + input: "users:\n - name: Alice\n - name: Bob", + format: "yaml", + path: "users[*].name", + }); + assert.strictEqual(result.ok, true); + assert.ok(Array.isArray(result.data)); + assert.ok(result.data.includes("Alice")); + assert.ok(result.data.includes("Bob")); + }); + + it("accesses nested path (dot notation)", async () => { + const result = await yamlManipulationImpl({ + action: "filter", + input: "nested:\n deep:\n value: 42", + format: "yaml", + path: "nested.deep.value", + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, 42); + }); + + it("accesses array index", async () => { + const result = await yamlManipulationImpl({ + action: "filter", + input: "items:\n - a\n - b\n - c", + format: "yaml", + path: "items[1]", + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.data, "b"); + }); + + it("rejects invalid YAML input", async () => { + const result = await yamlManipulationImpl({ + action: "parse", + input: "name: { invalid: yaml: [", + format: "yaml", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("YAML parse error")); + }); + + it("rejects invalid action", async () => { + const result = await yamlManipulationImpl({ + action: "invalid-action", + input: "name: test", + format: "yaml", + }); + assert.strictEqual(result.ok, false); + }); +}); From a9786a582ab26b37897410542b8259e4b24827fe Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 25 Aug 2026 09:18:25 -0400 Subject: [PATCH 3/3] chore: archive OpenSpec change structured-data-api-tools Move completed OpenSpec artifacts to archive with date prefix. Mark all tasks as complete in tasks.md. --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/api-client/spec.md | 0 .../specs/data-transformation/spec.md | 0 .../specs/graphql-client/spec.md | 0 .../specs/json-manipulation/spec.md | 0 .../specs/webhook-management/spec.md | 0 .../specs/yaml-manipulation/spec.md | 0 .../tasks.md | 93 +++++++++++++++++++ .../structured-data-api-tools/tasks.md | 93 ------------------- src/sandbox/urlFilter.js | 6 +- src/tools/api.js | 81 ++++++++++------ src/tools/data.js | 54 +++++++---- src/tools/graphql.js | 63 +++++++++---- src/tools/json.js | 42 +++++---- src/tools/webhook.js | 44 +++++---- src/tools/yaml.js | 42 +++++---- tests/integration/webhook.test.js | 18 +++- tests/unit/data.test.js | 5 +- tests/unit/webhook.test.js | 19 ++-- 21 files changed, 331 insertions(+), 229 deletions(-) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/.openspec.yaml (100%) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/design.md (100%) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/proposal.md (100%) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/specs/api-client/spec.md (100%) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/specs/data-transformation/spec.md (100%) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/specs/graphql-client/spec.md (100%) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/specs/json-manipulation/spec.md (100%) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/specs/webhook-management/spec.md (100%) rename openspec/changes/{structured-data-api-tools => archive/2026-08-25-structured-data-api-tools}/specs/yaml-manipulation/spec.md (100%) create mode 100644 openspec/changes/archive/2026-08-25-structured-data-api-tools/tasks.md delete mode 100644 openspec/changes/structured-data-api-tools/tasks.md diff --git a/openspec/changes/structured-data-api-tools/.openspec.yaml b/openspec/changes/archive/2026-08-25-structured-data-api-tools/.openspec.yaml similarity index 100% rename from openspec/changes/structured-data-api-tools/.openspec.yaml rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/.openspec.yaml diff --git a/openspec/changes/structured-data-api-tools/design.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/design.md similarity index 100% rename from openspec/changes/structured-data-api-tools/design.md rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/design.md diff --git a/openspec/changes/structured-data-api-tools/proposal.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/proposal.md similarity index 100% rename from openspec/changes/structured-data-api-tools/proposal.md rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/proposal.md diff --git a/openspec/changes/structured-data-api-tools/specs/api-client/spec.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/api-client/spec.md similarity index 100% rename from openspec/changes/structured-data-api-tools/specs/api-client/spec.md rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/api-client/spec.md diff --git a/openspec/changes/structured-data-api-tools/specs/data-transformation/spec.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/data-transformation/spec.md similarity index 100% rename from openspec/changes/structured-data-api-tools/specs/data-transformation/spec.md rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/data-transformation/spec.md diff --git a/openspec/changes/structured-data-api-tools/specs/graphql-client/spec.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/graphql-client/spec.md similarity index 100% rename from openspec/changes/structured-data-api-tools/specs/graphql-client/spec.md rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/graphql-client/spec.md diff --git a/openspec/changes/structured-data-api-tools/specs/json-manipulation/spec.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/json-manipulation/spec.md similarity index 100% rename from openspec/changes/structured-data-api-tools/specs/json-manipulation/spec.md rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/json-manipulation/spec.md diff --git a/openspec/changes/structured-data-api-tools/specs/webhook-management/spec.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/webhook-management/spec.md similarity index 100% rename from openspec/changes/structured-data-api-tools/specs/webhook-management/spec.md rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/webhook-management/spec.md diff --git a/openspec/changes/structured-data-api-tools/specs/yaml-manipulation/spec.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/yaml-manipulation/spec.md similarity index 100% rename from openspec/changes/structured-data-api-tools/specs/yaml-manipulation/spec.md rename to openspec/changes/archive/2026-08-25-structured-data-api-tools/specs/yaml-manipulation/spec.md diff --git a/openspec/changes/archive/2026-08-25-structured-data-api-tools/tasks.md b/openspec/changes/archive/2026-08-25-structured-data-api-tools/tasks.md new file mode 100644 index 00000000..6fc57e94 --- /dev/null +++ b/openspec/changes/archive/2026-08-25-structured-data-api-tools/tasks.md @@ -0,0 +1,93 @@ +## 1. Setup — Install Dependencies + +- [x] 1.1 Add npm dependencies: graphql-request, jsonpath-plus, js-yaml, csv-parse, csv-generate to package.json +- [x] 1.2 Run npm install to install new dependencies + +## 2. URL Allowlist Utility + +- [x] 2.1 Create src/tools/utils/urlAllowlist.js with allowlist validation function +- [x] 2.2 Implement scheme blocking (file://, gopher://, dict://) +- [x] 2.3 Implement internal IP blocking (127.0.0.1, 0.0.0.0, 169.254.169.254) +- [x] 2.4 Allow configurable allowlist from config.yaml + +## 3. REST API Client Tool + +- [x] 3.1 Create src/tools/api.js with REST API tool implementation +- [x] 3.2 Implement Zod input schema: url, method, headers, body, auth, timeout +- [x] 3.3 Implement authentication: bearer, basic, apikey +- [x] 3.4 Implement URL allowlist enforcement +- [x] 3.5 Implement response sanitization (strip Set-Cookie, WWW-Authenticate) +- [x] 3.6 Implement response size limit (10MB default) +- [x] 3.7 Implement configurable timeouts (default 30s) +- [x] 3.8 Register tool in src/tools/index.js with network:outbound permission + +## 4. GraphQL Client Tool + +- [x] 4.1 Create src/tools/graphql.js with GraphQL client tool implementation +- [x] 4.2 Implement Zod input schema: url, query, variables, operationName, timeout +- [x] 4.3 Implement query and mutation execution via graphql-request +- [x] 4.4 Implement schema introspection support +- [x] 4.5 Implement query depth limiting (default: 10) +- [x] 4.6 Implement query complexity limiting (default: 1000) +- [x] 4.7 Implement configurable timeouts (default 30s) +- [x] 4.8 Register tool in src/tools/index.js with network:outbound permission + +## 5. JSON Manipulation Tool + +- [x] 5.1 Create src/tools/json.js with JSON manipulation tool implementation +- [x] 5.2 Implement Zod input schema: action, input, format, path, mapping +- [x] 5.3 Implement parse action (JSON string → object) +- [x] 5.4 Implement serialize action (object → JSON string) +- [x] 5.5 Implement transform action with mapping rules +- [x] 5.6 Implement filter action with JSONPath expressions via jsonpath-plus +- [x] 5.7 Implement path-based access (dot notation, array indices) +- [x] 5.8 Register tool in src/tools/index.js with filesystem:read permission + +## 6. YAML Manipulation Tool + +- [x] 6.1 Create src/tools/yaml.js with YAML manipulation tool implementation +- [x] 6.2 Implement Zod input schema: action, input, format, path, mapping +- [x] 6.3 Implement parse action (YAML string → object) +- [x] 6.4 Implement serialize action (object → YAML string) +- [x] 6.5 Implement transform action with mapping rules +- [x] 6.6 Implement filter action with path expressions +- [x] 6.7 Implement path-based access (dot notation, array indices) +- [x] 6.8 Register tool in src/tools/index.js with filesystem:read permission + +## 7. Data Transformation Tool + +- [x] 7.1 Create src/tools/data.js with data transformation tool implementation +- [x] 7.2 Implement Zod input schema: action, input, format, path, mapping +- [x] 7.3 Implement JSON ↔ YAML conversion +- [x] 7.4 Implement JSON ↔ CSV conversion via csv-parse/csv-generate +- [x] 7.5 Implement mapping rule application during conversion +- [x] 7.6 Implement input format validation +- [x] 7.7 Register tool in src/tools/index.js with filesystem:read permission + +## 8. Webhook Management Tool + +- [x] 8.1 Create src/tools/webhook.js with webhook management tool implementation +- [x] 8.2 Implement Zod input schema: action, url, secret, events, payload +- [x] 8.3 Implement create action — store webhook registration +- [x] 8.4 Implement list action — return all registered webhooks +- [x] 8.5 Implement delete action — remove webhook by ID +- [x] 8.6 Implement verify action — HMAC-SHA256 signature verification +- [x] 8.7 Implement persistence to data/webhooks.json +- [x] 8.8 Register tool in src/tools/index.js with filesystem:read, filesystem:write permissions + +## 9. Testing + +- [x] 9.1 Create tests/unit/api.test.js with REST client unit tests +- [x] 9.2 Create tests/unit/graphql.test.js with GraphQL client unit tests +- [x] 9.3 Create tests/unit/json.test.js with JSON manipulation unit tests +- [x] 9.4 Create tests/unit/yaml.test.js with YAML manipulation unit tests +- [x] 9.5 Create tests/unit/data.test.js with data transformation unit tests +- [x] 9.6 Create tests/unit/webhook.test.js with webhook management unit tests +- [x] 9.7 Create tests/integration/api.test.js with integration tests using mock server +- [x] 9.8 Create tests/integration/webhook.test.js with webhook integration tests + +## 10. Verification + +- [x] 10.1 Run npm run test and verify all tests pass +- [x] 10.2 Run npm run lint and verify no lint errors +- [x] 10.3 Run npm run coverage and verify coverage is maintained diff --git a/openspec/changes/structured-data-api-tools/tasks.md b/openspec/changes/structured-data-api-tools/tasks.md deleted file mode 100644 index 67aa7140..00000000 --- a/openspec/changes/structured-data-api-tools/tasks.md +++ /dev/null @@ -1,93 +0,0 @@ -## 1. Setup — Install Dependencies - -- [ ] 1.1 Add npm dependencies: graphql-request, jsonpath-plus, js-yaml, csv-parse, csv-generate to package.json -- [ ] 1.2 Run npm install to install new dependencies - -## 2. URL Allowlist Utility - -- [ ] 2.1 Create src/tools/utils/urlAllowlist.js with allowlist validation function -- [ ] 2.2 Implement scheme blocking (file://, gopher://, dict://) -- [ ] 2.3 Implement internal IP blocking (127.0.0.1, 0.0.0.0, 169.254.169.254) -- [ ] 2.4 Allow configurable allowlist from config.yaml - -## 3. REST API Client Tool - -- [ ] 3.1 Create src/tools/api.js with REST API tool implementation -- [ ] 3.2 Implement Zod input schema: url, method, headers, body, auth, timeout -- [ ] 3.3 Implement authentication: bearer, basic, apikey -- [ ] 3.4 Implement URL allowlist enforcement -- [ ] 3.5 Implement response sanitization (strip Set-Cookie, WWW-Authenticate) -- [ ] 3.6 Implement response size limit (10MB default) -- [ ] 3.7 Implement configurable timeouts (default 30s) -- [ ] 3.8 Register tool in src/tools/index.js with network:outbound permission - -## 4. GraphQL Client Tool - -- [ ] 4.1 Create src/tools/graphql.js with GraphQL client tool implementation -- [ ] 4.2 Implement Zod input schema: url, query, variables, operationName, timeout -- [ ] 4.3 Implement query and mutation execution via graphql-request -- [ ] 4.4 Implement schema introspection support -- [ ] 4.5 Implement query depth limiting (default: 10) -- [ ] 4.6 Implement query complexity limiting (default: 1000) -- [ ] 4.7 Implement configurable timeouts (default 30s) -- [ ] 4.8 Register tool in src/tools/index.js with network:outbound permission - -## 5. JSON Manipulation Tool - -- [ ] 5.1 Create src/tools/json.js with JSON manipulation tool implementation -- [ ] 5.2 Implement Zod input schema: action, input, format, path, mapping -- [ ] 5.3 Implement parse action (JSON string → object) -- [ ] 5.4 Implement serialize action (object → JSON string) -- [ ] 5.5 Implement transform action with mapping rules -- [ ] 5.6 Implement filter action with JSONPath expressions via jsonpath-plus -- [ ] 5.7 Implement path-based access (dot notation, array indices) -- [ ] 5.8 Register tool in src/tools/index.js with filesystem:read permission - -## 6. YAML Manipulation Tool - -- [ ] 6.1 Create src/tools/yaml.js with YAML manipulation tool implementation -- [ ] 6.2 Implement Zod input schema: action, input, format, path, mapping -- [ ] 6.3 Implement parse action (YAML string → object) -- [ ] 6.4 Implement serialize action (object → YAML string) -- [ ] 6.5 Implement transform action with mapping rules -- [ ] 6.6 Implement filter action with path expressions -- [ ] 6.7 Implement path-based access (dot notation, array indices) -- [ ] 6.8 Register tool in src/tools/index.js with filesystem:read permission - -## 7. Data Transformation Tool - -- [ ] 7.1 Create src/tools/data.js with data transformation tool implementation -- [ ] 7.2 Implement Zod input schema: action, input, format, path, mapping -- [ ] 7.3 Implement JSON ↔ YAML conversion -- [ ] 7.4 Implement JSON ↔ CSV conversion via csv-parse/csv-generate -- [ ] 7.5 Implement mapping rule application during conversion -- [ ] 7.6 Implement input format validation -- [ ] 7.7 Register tool in src/tools/index.js with filesystem:read permission - -## 8. Webhook Management Tool - -- [ ] 8.1 Create src/tools/webhook.js with webhook management tool implementation -- [ ] 8.2 Implement Zod input schema: action, url, secret, events, payload -- [ ] 8.3 Implement create action — store webhook registration -- [ ] 8.4 Implement list action — return all registered webhooks -- [ ] 8.5 Implement delete action — remove webhook by ID -- [ ] 8.6 Implement verify action — HMAC-SHA256 signature verification -- [ ] 8.7 Implement persistence to data/webhooks.json -- [ ] 8.8 Register tool in src/tools/index.js with filesystem:read, filesystem:write permissions - -## 9. Testing - -- [ ] 9.1 Create tests/unit/api.test.js with REST client unit tests -- [ ] 9.2 Create tests/unit/graphql.test.js with GraphQL client unit tests -- [ ] 9.3 Create tests/unit/json.test.js with JSON manipulation unit tests -- [ ] 9.4 Create tests/unit/yaml.test.js with YAML manipulation unit tests -- [ ] 9.5 Create tests/unit/data.test.js with data transformation unit tests -- [ ] 9.6 Create tests/unit/webhook.test.js with webhook management unit tests -- [ ] 9.7 Create tests/integration/api.test.js with integration tests using mock server -- [ ] 9.8 Create tests/integration/webhook.test.js with webhook integration tests - -## 10. Verification - -- [ ] 10.1 Run npm run test and verify all tests pass -- [ ] 10.2 Run npm run lint and verify no lint errors -- [ ] 10.3 Run npm run coverage and verify coverage is maintained diff --git a/src/sandbox/urlFilter.js b/src/sandbox/urlFilter.js index c1ed082d..a725ecde 100644 --- a/src/sandbox/urlFilter.js +++ b/src/sandbox/urlFilter.js @@ -71,7 +71,11 @@ export function filterUrl(url, allowlist = []) { const hostname = parsed.hostname.toLowerCase(); const onAllowlist = allowlist.some((entry) => { const normalized = entry.replace(/^https?:\/\//, "").toLowerCase(); - return hostname === normalized || hostname === normalized.replace(/:\d+$/, "") || url.startsWith(entry); + return ( + hostname === normalized || + hostname === normalized.replace(/:\d+$/, "") || + url.startsWith(entry) + ); }); if (!onAllowlist) { return { allowed: false, reason: `Host not on allowlist: ${hostname}` }; diff --git a/src/tools/api.js b/src/tools/api.js index 25c5021e..6cc1b480 100644 --- a/src/tools/api.js +++ b/src/tools/api.js @@ -1,9 +1,6 @@ import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { filterUrl } from "../sandbox/urlFilter.js"; -import { loadConfig } from "../config/loader.js"; - -const config = loadConfig(); const DEFAULT_TIMEOUT = 30000; const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB @@ -188,29 +185,55 @@ export async function apiImpl(input) { * @returns {object} LangChain Tool instance */ export function createApiTool() { - return tool(async (input) => { - const result = await apiImpl(input); - return JSON.stringify(result, null, 2); - }, { - name: "api", - description: - "Make authenticated HTTP requests (GET/POST/PUT/DELETE/PATCH) to external APIs. Supports bearer, basic, and API key authentication. Enforces URL allowlist and scheme blocking. Response headers are sanitized (Set-Cookie, WWW-Authenticate stripped). Default timeout: 30s. Max response body: 10MB.", - schema: z.object({ - url: z.string().url().describe("Target URL"), - method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional().default("GET").describe("HTTP method"), - headers: z.record(z.string()).optional().describe("Additional HTTP headers"), - body: z.unknown().optional().describe("Request body (auto-serialized to JSON for non-GET requests)"), - auth: z - .object({ - type: z.enum(["bearer", "basic", "apikey"]).describe("Authentication type"), - token: z.string().optional().describe("Auth token (bearer token, basic password, or API key value)"), - key: z.string().optional().describe("API key header name (for apikey auth type)"), - }) - .optional() - .describe("Authentication configuration"), - timeout: z.number().int().positive().optional().describe("Request timeout in milliseconds (default: 30000)"), - allowlist: z.array(z.string()).optional().describe("URL allowlist — hosts must match one entry"), - maxBodySize: z.number().int().positive().optional().describe("Maximum response body size in bytes (default: 10485760)"), - }), - }); -} \ No newline at end of file + return tool( + async (input) => { + const result = await apiImpl(input); + return JSON.stringify(result, null, 2); + }, + { + name: "api", + description: + "Make authenticated HTTP requests (GET/POST/PUT/DELETE/PATCH) to external APIs. Supports bearer, basic, and API key authentication. Enforces URL allowlist and scheme blocking. Response headers are sanitized (Set-Cookie, WWW-Authenticate stripped). Default timeout: 30s. Max response body: 10MB.", + schema: z.object({ + url: z.string().url().describe("Target URL"), + method: z + .enum(["GET", "POST", "PUT", "DELETE", "PATCH"]) + .optional() + .default("GET") + .describe("HTTP method"), + headers: z.record(z.string()).optional().describe("Additional HTTP headers"), + body: z + .unknown() + .optional() + .describe("Request body (auto-serialized to JSON for non-GET requests)"), + auth: z + .object({ + type: z.enum(["bearer", "basic", "apikey"]).describe("Authentication type"), + token: z + .string() + .optional() + .describe("Auth token (bearer token, basic password, or API key value)"), + key: z.string().optional().describe("API key header name (for apikey auth type)"), + }) + .optional() + .describe("Authentication configuration"), + timeout: z + .number() + .int() + .positive() + .optional() + .describe("Request timeout in milliseconds (default: 30000)"), + allowlist: z + .array(z.string()) + .optional() + .describe("URL allowlist — hosts must match one entry"), + maxBodySize: z + .number() + .int() + .positive() + .optional() + .describe("Maximum response body size in bytes (default: 10485760)"), + }), + }, + ); +} diff --git a/src/tools/data.js b/src/tools/data.js index c77d7ca1..6768d0ee 100644 --- a/src/tools/data.js +++ b/src/tools/data.js @@ -2,7 +2,6 @@ import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { load, dump } from "js-yaml"; import { parse } from "csv-parse/sync"; -import { generate } from "csv-generate"; import { stringify } from "csv-stringify/sync"; /** @@ -191,7 +190,16 @@ export async function dataTransformation(input) { */ export async function dataTransformationImpl(input) { const schema = z.object({ - action: z.enum(["json-to-yaml", "yaml-to-json", "json-to-csv", "csv-to-json", "yaml-to-csv", "csv-to-yaml"]).describe("Conversion action"), + action: z + .enum([ + "json-to-yaml", + "yaml-to-json", + "json-to-csv", + "csv-to-json", + "yaml-to-csv", + "csv-to-yaml", + ]) + .describe("Conversion action"), input: z.string().describe("Input data string"), format: z.enum(["json", "yaml", "csv"]).describe("Input format"), mapping: z.string().optional().describe("JSON string mapping rules for CSV conversions"), @@ -236,18 +244,30 @@ export async function dataTransformationImpl(input) { * @returns {object} LangChain Tool instance */ export function createDataTool() { - return tool(async (input) => { - const result = await dataTransformation(input); - return JSON.stringify(result, null, 2); - }, { - name: "data", - description: - "Convert data between JSON, YAML, and CSV formats. Actions: json-to-yaml, yaml-to-json, json-to-csv, csv-to-json, yaml-to-csv, csv-to-yaml. CSV conversions support optional mapping rules (JSON string) to rename columns.", - schema: z.object({ - action: z.enum(["json-to-yaml", "yaml-to-json", "json-to-csv", "csv-to-json", "yaml-to-csv", "csv-to-yaml"]).describe("Conversion action"), - input: z.string().describe("Input data string"), - format: z.enum(["json", "yaml", "csv"]).describe("Input format"), - mapping: z.string().optional().describe("JSON string mapping rules for CSV conversions"), - }), - }); -} \ No newline at end of file + return tool( + async (input) => { + const result = await dataTransformation(input); + return JSON.stringify(result, null, 2); + }, + { + name: "data", + description: + "Convert data between JSON, YAML, and CSV formats. Actions: json-to-yaml, yaml-to-json, json-to-csv, csv-to-json, yaml-to-csv, csv-to-yaml. CSV conversions support optional mapping rules (JSON string) to rename columns.", + schema: z.object({ + action: z + .enum([ + "json-to-yaml", + "yaml-to-json", + "json-to-csv", + "csv-to-json", + "yaml-to-csv", + "csv-to-yaml", + ]) + .describe("Conversion action"), + input: z.string().describe("Input data string"), + format: z.enum(["json", "yaml", "csv"]).describe("Input format"), + mapping: z.string().optional().describe("JSON string mapping rules for CSV conversions"), + }), + }, + ); +} diff --git a/src/tools/graphql.js b/src/tools/graphql.js index c1c6fa5c..fccdda1e 100644 --- a/src/tools/graphql.js +++ b/src/tools/graphql.js @@ -1,7 +1,6 @@ import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { filterUrl } from "../sandbox/urlFilter.js"; -import { gql } from "graphql-request"; const DEFAULT_TIMEOUT = 30000; const DEFAULT_MAX_DEPTH = 10; @@ -302,22 +301,46 @@ export async function introspectSchema(input) { * @returns {object} LangChain Tool instance */ export function createGraphqlTool() { - return tool(async (input) => { - const result = await graphqlImpl(input); - return JSON.stringify(result, null, 2); - }, { - name: "graphql", - description: - "Execute GraphQL queries and mutations against a GraphQL endpoint. Supports query variables, operation names, and schema introspection. Enforces query depth limits (default: 10) and complexity limits (default: 1000) to prevent DoS. Default timeout: 30s.", - schema: z.object({ - url: z.string().url().describe("GraphQL endpoint URL"), - query: z.string().min(1).describe("GraphQL query or mutation string"), - variables: z.record(z.unknown()).optional().describe("Query variables as key-value pairs"), - operationName: z.string().optional().describe("Operation name (for multi-operation documents)"), - timeout: z.number().int().positive().optional().describe("Request timeout in milliseconds (default: 30000)"), - maxDepth: z.number().int().positive().optional().describe("Maximum query depth (default: 10)"), - maxComplexity: z.number().int().positive().optional().describe("Maximum query complexity (default: 1000)"), - allowlist: z.array(z.string()).optional().describe("URL allowlist — host must match one entry"), - }), - }); -} \ No newline at end of file + return tool( + async (input) => { + const result = await graphqlImpl(input); + return JSON.stringify(result, null, 2); + }, + { + name: "graphql", + description: + "Execute GraphQL queries and mutations against a GraphQL endpoint. Supports query variables, operation names, and schema introspection. Enforces query depth limits (default: 10) and complexity limits (default: 1000) to prevent DoS. Default timeout: 30s.", + schema: z.object({ + url: z.string().url().describe("GraphQL endpoint URL"), + query: z.string().min(1).describe("GraphQL query or mutation string"), + variables: z.record(z.unknown()).optional().describe("Query variables as key-value pairs"), + operationName: z + .string() + .optional() + .describe("Operation name (for multi-operation documents)"), + timeout: z + .number() + .int() + .positive() + .optional() + .describe("Request timeout in milliseconds (default: 30000)"), + maxDepth: z + .number() + .int() + .positive() + .optional() + .describe("Maximum query depth (default: 10)"), + maxComplexity: z + .number() + .int() + .positive() + .optional() + .describe("Maximum query complexity (default: 1000)"), + allowlist: z + .array(z.string()) + .optional() + .describe("URL allowlist — host must match one entry"), + }), + }, + ); +} diff --git a/src/tools/json.js b/src/tools/json.js index f5afffc0..3bfbeed7 100644 --- a/src/tools/json.js +++ b/src/tools/json.js @@ -189,7 +189,9 @@ export async function jsonManipulation(input) { */ export async function jsonManipulationImpl(input) { const schema = z.object({ - action: z.enum(["parse", "serialize", "transform", "filter", "access"]).describe("Action to perform"), + action: z + .enum(["parse", "serialize", "transform", "filter", "access"]) + .describe("Action to perform"), input: z.string().describe("JSON string input"), path: z.string().optional().describe("JSONPath expression or dot-notation path"), mapping: z.string().optional().describe("JSON string mapping rules for transform action"), @@ -245,18 +247,26 @@ export async function jsonManipulationImpl(input) { * @returns {object} LangChain Tool instance */ export function createJsonTool() { - return tool(async (input) => { - const result = await jsonManipulation(input); - return JSON.stringify(result, null, 2); - }, { - name: "json", - description: - "Parse, serialize, transform, filter, and access JSON data. Actions: parse (string→object), serialize (object→string), transform (apply key mapping rules), filter (JSONPath expressions via jsonpath-plus), access (dot-notation path access including array indices).", - schema: z.object({ - action: z.enum(["parse", "serialize", "transform", "filter", "access"]).describe("Action to perform"), - input: z.string().describe("JSON string input"), - path: z.string().optional().describe("JSONPath expression (filter) or dot-notation path (access)"), - mapping: z.string().optional().describe("JSON string mapping rules for transform action"), - }), - }); -} \ No newline at end of file + return tool( + async (input) => { + const result = await jsonManipulation(input); + return JSON.stringify(result, null, 2); + }, + { + name: "json", + description: + "Parse, serialize, transform, filter, and access JSON data. Actions: parse (string→object), serialize (object→string), transform (apply key mapping rules), filter (JSONPath expressions via jsonpath-plus), access (dot-notation path access including array indices).", + schema: z.object({ + action: z + .enum(["parse", "serialize", "transform", "filter", "access"]) + .describe("Action to perform"), + input: z.string().describe("JSON string input"), + path: z + .string() + .optional() + .describe("JSONPath expression (filter) or dot-notation path (access)"), + mapping: z.string().optional().describe("JSON string mapping rules for transform action"), + }), + }, + ); +} diff --git a/src/tools/webhook.js b/src/tools/webhook.js index 8ba5ef06..59231e58 100644 --- a/src/tools/webhook.js +++ b/src/tools/webhook.js @@ -81,7 +81,7 @@ export function listWebhooks(includeSecret = false) { if (includeSecret) { return { ok: true, data: webhooks }; } - const safe = webhooks.map(({ secret, ...rest }) => rest); + const safe = webhooks.map(({ secret: _secret, ...rest }) => rest); return { ok: true, data: safe }; } @@ -202,21 +202,27 @@ export async function webhookManagementImpl(input) { * @returns {object} LangChain Tool instance */ export function createWebhookTool() { - return tool(async (input) => { - const result = await webhookManagementImpl(input); - return JSON.stringify(result, null, 2); - }, { - name: "webhook", - description: - "Manage webhook registrations. Actions: create (register webhook with URL, secret, events), list (return all webhooks), delete (remove webhook by ID), verify (HMAC-SHA256 signature verification against payload and secret). Webhooks are persisted to data/webhooks.json.", - schema: z.object({ - action: z.enum(["create", "list", "delete", "verify"]).describe("Action to perform"), - url: z.string().url().optional().describe("Webhook URL (required for create)"), - secret: z.string().optional().describe("Secret for HMAC verification (required for create)"), - events: z.array(z.string()).optional().describe("Event types to subscribe to"), - id: z.string().optional().describe("Webhook ID (required for delete)"), - payload: z.string().optional().describe("Raw request body (required for verify)"), - signature: z.string().optional().describe("HMAC signature (required for verify)"), - }), - }); -} \ No newline at end of file + return tool( + async (input) => { + const result = await webhookManagementImpl(input); + return JSON.stringify(result, null, 2); + }, + { + name: "webhook", + description: + "Manage webhook registrations. Actions: create (register webhook with URL, secret, events), list (return all webhooks), delete (remove webhook by ID), verify (HMAC-SHA256 signature verification against payload and secret). Webhooks are persisted to data/webhooks.json.", + schema: z.object({ + action: z.enum(["create", "list", "delete", "verify"]).describe("Action to perform"), + url: z.string().url().optional().describe("Webhook URL (required for create)"), + secret: z + .string() + .optional() + .describe("Secret for HMAC verification (required for create)"), + events: z.array(z.string()).optional().describe("Event types to subscribe to"), + id: z.string().optional().describe("Webhook ID (required for delete)"), + payload: z.string().optional().describe("Raw request body (required for verify)"), + signature: z.string().optional().describe("HMAC signature (required for verify)"), + }), + }, + ); +} diff --git a/src/tools/yaml.js b/src/tools/yaml.js index 607033c4..01650df2 100644 --- a/src/tools/yaml.js +++ b/src/tools/yaml.js @@ -212,7 +212,9 @@ export async function yamlManipulation(input) { */ export async function yamlManipulationImpl(input) { const schema = z.object({ - action: z.enum(["parse", "serialize", "transform", "filter", "access"]).describe("Action to perform"), + action: z + .enum(["parse", "serialize", "transform", "filter", "access"]) + .describe("Action to perform"), input: z.string().describe("YAML string input"), path: z.string().optional().describe("Dot-notation path expression"), mapping: z.string().optional().describe("JSON string mapping rules for transform action"), @@ -249,7 +251,8 @@ export async function yamlManipulationImpl(input) { if (!path) { return { ok: false, error: "Path is required for filter/access action" }; } - const result = action === "filter" ? filterYaml(yamlInput, path) : accessYamlPath(yamlInput, path); + const result = + action === "filter" ? filterYaml(yamlInput, path) : accessYamlPath(yamlInput, path); return result.ok ? { ok: true, data: result.data } : result; } default: @@ -262,18 +265,23 @@ export async function yamlManipulationImpl(input) { * @returns {object} LangChain Tool instance */ export function createYamlTool() { - return tool(async (input) => { - const result = await yamlManipulation(input); - return JSON.stringify(result, null, 2); - }, { - name: "yaml", - description: - "Parse, serialize, transform, filter, and access YAML data. Actions: parse (string→object), serialize (object→string), transform (apply key mapping rules), filter (dot-notation path filter), access (dot-notation path access including array indices).", - schema: z.object({ - action: z.enum(["parse", "serialize", "transform", "filter", "access"]).describe("Action to perform"), - input: z.string().describe("YAML string input"), - path: z.string().optional().describe("Dot-notation path expression"), - mapping: z.string().optional().describe("JSON string mapping rules for transform action"), - }), - }); -} \ No newline at end of file + return tool( + async (input) => { + const result = await yamlManipulation(input); + return JSON.stringify(result, null, 2); + }, + { + name: "yaml", + description: + "Parse, serialize, transform, filter, and access YAML data. Actions: parse (string→object), serialize (object→string), transform (apply key mapping rules), filter (dot-notation path filter), access (dot-notation path access including array indices).", + schema: z.object({ + action: z + .enum(["parse", "serialize", "transform", "filter", "access"]) + .describe("Action to perform"), + input: z.string().describe("YAML string input"), + path: z.string().optional().describe("Dot-notation path expression"), + mapping: z.string().optional().describe("JSON string mapping rules for transform action"), + }), + }, + ); +} diff --git a/tests/integration/webhook.test.js b/tests/integration/webhook.test.js index b0f68024..f2ed81b2 100644 --- a/tests/integration/webhook.test.js +++ b/tests/integration/webhook.test.js @@ -2,7 +2,12 @@ import { describe, it, before, after } from "node:test"; import assert from "node:assert"; import { createServer } from "node:http"; import { createHmac } from "node:crypto"; -import { createWebhookTool, createWebhook, listWebhooks, deleteWebhook, verifyWebhook } from "../../src/tools/webhook.js"; +import { + createWebhook, + listWebhooks, + deleteWebhook, + verifyWebhook, +} from "../../src/tools/webhook.js"; import { existsSync, unlinkSync } from "node:fs"; import { setTestMode } from "../../src/sandbox/urlFilter.js"; @@ -18,7 +23,9 @@ describe("webhook integration tests", () => { const webhookHandler = (req, res) => { let body = ""; - req.on("data", (chunk) => { body += chunk; }); + req.on("data", (chunk) => { + body += chunk; + }); req.on("end", () => { const signature = req.headers["x-webhook-signature"]; res.setHeader("Content-Type", "application/json"); @@ -50,7 +57,10 @@ describe("webhook integration tests", () => { it("creates and verifies a webhook with valid signature", async () => { // Use impl function to bypass URL validation in integration tests - const createResult = createWebhook(`${baseUrl}/webhook`, "integration-secret", ["push", "pull_request"]); + const createResult = createWebhook(`${baseUrl}/webhook`, "integration-secret", [ + "push", + "pull_request", + ]); assert.strictEqual(createResult.ok, true); assert.ok(createResult.data.id); @@ -98,4 +108,4 @@ describe("webhook integration tests", () => { assert.strictEqual(listResult.ok, true); assert.ok(!listResult.data.some((w) => w.id === id)); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/data.test.js b/tests/unit/data.test.js index 4c48ba27..3699406b 100644 --- a/tests/unit/data.test.js +++ b/tests/unit/data.test.js @@ -28,7 +28,10 @@ describe("data tool", () => { it("converts JSON to CSV", async () => { const result = await dataTransformationImpl({ action: "json-to-csv", - input: JSON.stringify([{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]), + input: JSON.stringify([ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ]), format: "json", }); assert.strictEqual(result.ok, true); diff --git a/tests/unit/webhook.test.js b/tests/unit/webhook.test.js index bd16b6b4..85003c6e 100644 --- a/tests/unit/webhook.test.js +++ b/tests/unit/webhook.test.js @@ -1,7 +1,7 @@ import { describe, it, before, after, beforeEach } from "node:test"; import assert from "node:assert"; import { createHmac } from "node:crypto"; -import { existsSync, unlinkSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, unlinkSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -32,7 +32,10 @@ describe("webhook tool", () => { after(cleanup); it("creates a webhook registration", async () => { - const result = createWebhook("https://example.com/webhook", "my-secret", ["push", "pull_request"]); + const result = createWebhook("https://example.com/webhook", "my-secret", [ + "push", + "pull_request", + ]); assert.strictEqual(result.ok, true); assert.ok(result.data.id); assert.strictEqual(result.data.url, "https://example.com/webhook"); @@ -82,20 +85,12 @@ describe("webhook tool", () => { }); it("rejects verify with missing secret", async () => { - const result = verifyWebhook( - JSON.stringify({ test: true }), - "sha256=abc", - undefined, - ); + const result = verifyWebhook(JSON.stringify({ test: true }), "sha256=abc", undefined); assert.strictEqual(result.ok, false); }); it("rejects verify with missing signature", async () => { - const result = verifyWebhook( - JSON.stringify({ test: true }), - undefined, - "my-secret", - ); + const result = verifyWebhook(JSON.stringify({ test: true }), undefined, "my-secret"); assert.strictEqual(result.ok, false); });