diff --git a/.env.example b/.env.example index 698850db..9468c363 100644 --- a/.env.example +++ b/.env.example @@ -19,9 +19,19 @@ PORT=3000 HARNESS_MCP_ALLOWED_HOSTS= # Security and transport controls -# HARNESS_BASE_URL must be HTTPS unless HARNESS_ALLOW_HTTP=true. +# HARNESS_BASE_URL and HARNESS_FME_BASE_URL must be HTTPS unless HARNESS_ALLOW_HTTP=true. HARNESS_ALLOW_HTTP=false +# Override the base URL for Harness FME (Split.io) requests. Defaults to +# https://api.split.io. Set this for self-managed/staging FME environments. +HARNESS_FME_BASE_URL=https://api.split.io + +# Debug-only: log raw request/response bodies without redaction. Defaults to +# false — sensitive fields (token, secret, password, apiKey, credential, etc.) +# are redacted from debug logs. Set to true ONLY for local debugging; never in +# shared environments. +HARNESS_LOG_UNSAFE_BODIES=false + # Read-only mode blocks create/update/delete/execute operations. HARNESS_READ_ONLY=false diff --git a/README.md b/README.md index 76a7c72c..76bcb4c0 100644 --- a/README.md +++ b/README.md @@ -522,19 +522,23 @@ The server automatically loads environment variables from a `.env` file in the p | `HARNESS_TOOLSETS` | No | *(defaults)* | Comma-separated toolset list. Empty loads default toolsets and excludes opt-in toolsets such as `ai-evals`. Supports `+name` to add opt-in toolsets and `-name` to remove defaults (see [Toolset Filtering](#toolset-filtering)) | | `HARNESS_READ_ONLY` | No | `false` | Block all mutating operations (create, update, delete, execute). Only list and get are allowed. Useful for shared/demo environments | | `HARNESS_SKIP_ELICITATION` | No | `false` | Skip all elicitation confirmation prompts. When `true`, write and delete operations proceed without user approval — enabling fully autonomous agent workflows. See [Elicitation](#elicitation) | -| `HARNESS_ALLOW_HTTP` | No | `false` | Allow non-HTTPS `HARNESS_BASE_URL`. By default, the server enforces HTTPS for security. Set to `true` only for local development against a non-TLS Harness instance | +| `HARNESS_ALLOW_HTTP` | No | `false` | Allow non-HTTPS `HARNESS_BASE_URL` and `HARNESS_FME_BASE_URL`. By default, the server enforces HTTPS for security. Set to `true` only for local development against a non-TLS instance | +| `HARNESS_FME_BASE_URL` | No | `https://api.split.io` | Base URL for Harness FME (Split.io) requests. Override for self-managed/staging FME environments. Must be HTTPS unless `HARNESS_ALLOW_HTTP=true` | +| `HARNESS_LOG_UNSAFE_BODIES` | No | `false` | Debug-only: log raw request/response bodies without redaction. By default, sensitive fields (`token`, `secret`, `password`, `apiKey`, `credential`, `webhook`, etc.) are redacted from debug logs. Set to `true` ONLY for local debugging — never in shared environments | | `HARNESS_PIPELINE_VERSION` | No | `0` | **(Alpha)** Pipeline YAML version. `0` loads the `pipeline` resource type and excludes `pipeline_v1`; `1` loads `pipeline_v1` and excludes `pipeline`. HTTP sessions can override this at initialize time with `x-harness-pipeline-version: 0` or `1` | | `HARNESS_MCP_ALLOWED_HOSTS` | No | -- | Comma-separated hostnames allowed by HTTP transport Host-header validation. `mcp.harness.io` is allowed by default for localhost binds; add proxy/custom domains here | | `HARNESS_MCP_LOG_FILE` | No | `~/.claude/harness-mcp.log` | File used for stdio disconnect/crash diagnostics when stderr may no longer be available | ### HTTPS Enforcement -`HARNESS_BASE_URL` must use HTTPS by default. If you set a non-HTTPS URL (e.g. `http://localhost:8080`), the server will refuse to start with: +`HARNESS_BASE_URL` and `HARNESS_FME_BASE_URL` must use HTTPS by default. If you set a non-HTTPS URL (e.g. `http://localhost:8080`), the server will refuse to start with: ``` HARNESS_BASE_URL must use HTTPS (got "http://..."). If you need HTTP for local development, set HARNESS_ALLOW_HTTP=true. ``` +The same enforcement applies to `HARNESS_FME_BASE_URL`. Setting `HARNESS_ALLOW_HTTP=true` relaxes the check for both URLs. + ### Audit Logging All write operations (`harness_create`, `harness_update`, `harness_delete`, `harness_execute`) emit structured audit log entries to stderr. Each entry includes the tool name, resource type, operation, identifiers, and timestamp. This provides an audit trail without requiring external logging infrastructure. @@ -1709,6 +1713,7 @@ The Harness MCP server pairs well with **[Harness Skills](https://github.com/har | `Operation declined by user` | User declined the elicitation confirmation dialog | The user chose not to proceed — verify the operation details and retry if intended | | `body.template_yaml (or body.yaml) is required` for template create/update | Template APIs expect full YAML payload | Provide full `template_yaml` string in `body`; for deletes, pass `version_label` to delete one version (omit to delete all versions) | | `HARNESS_BASE_URL must use HTTPS` on startup | `HARNESS_BASE_URL` is set to an HTTP URL | Use HTTPS, or set `HARNESS_ALLOW_HTTP=true` for local development | +| `HARNESS_FME_BASE_URL must use HTTPS` on startup | `HARNESS_FME_BASE_URL` is set to an HTTP URL | Use HTTPS, or set `HARNESS_ALLOW_HTTP=true` for local development | ## License diff --git a/tests/client/harness-client.test.ts b/tests/client/harness-client.test.ts index e678abf8..fd725852 100644 --- a/tests/client/harness-client.test.ts +++ b/tests/client/harness-client.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { HarnessClient } from "../../src/client/harness-client.js"; import { HarnessApiError } from "../../src/utils/errors.js"; +import { setLogLevel } from "../../src/utils/logger.js"; import type { Config } from "../../src/config.js"; function makeConfig(overrides: Partial = {}): Config { @@ -126,6 +127,31 @@ describe("HarnessClient", () => { expect(url).not.toContain("app.harness.io"); }); + it("omits accountIdentifier query param for product='fme'", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + const client = new HarnessClient(makeConfig()); + + await client.request({ + path: "/internal/api/v2/splits/ws/ws-123", + baseUrl: "https://api.split.io", + product: "fme", + }); + + const url = new URL(fetchSpy.mock.calls[0][0] as string); + expect(url.searchParams.has("accountIdentifier")).toBe(false); + expect(url.searchParams.has("accountID")).toBe(false); + }); + + it("still injects accountIdentifier for non-fme product (default Harness)", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + const client = new HarnessClient(makeConfig()); + + await client.request({ path: "/ng/api/projects" }); + + const url = new URL(fetchSpy.mock.calls[0][0] as string); + expect(url.searchParams.get("accountIdentifier")).toBe("test-account"); + }); + it("omits undefined and empty params", async () => { fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); const client = new HarnessClient(makeConfig()); @@ -176,6 +202,20 @@ describe("HarnessClient", () => { expect(headers["Harness-Account"]).toBe("resolved-account"); }); + it("omits Harness-Account header for product='fme' (Split.io API)", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + const client = new HarnessClient(makeConfig()); + + await client.request({ + path: "/internal/api/v2/splits/ws/ws-123", + baseUrl: "https://api.split.io", + product: "fme", + }); + + const headers = fetchSpy.mock.calls[0][1]?.headers as Record; + expect(headers["Harness-Account"]).toBeUndefined(); + }); + it("sets Content-Type to application/json for object body", async () => { fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); const client = new HarnessClient(makeConfig()); @@ -556,4 +596,72 @@ describe("HarnessClient", () => { expect(url.searchParams.get("inputSetIdentifiers")).toBe("mcp_default_runtime_inputs"); }); }); + + describe("debug log redaction (HARNESS_LOG_UNSAFE_BODIES toggle)", () => { + let stderrSpy: ReturnType; + let originalLogLevel: string | undefined; + + beforeEach(() => { + // Logger writes via console.error; vitest's vi.spyOn handles capture. + stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + originalLogLevel = process.env.LOG_LEVEL; + setLogLevel("debug"); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + setLogLevel((originalLogLevel as "debug" | "info" | "warn" | "error") ?? "error"); + }); + + function getLoggedBodies(): string[] { + return stderrSpy.mock.calls + .map((c) => String(c[0] ?? "")) + .filter((line) => line.includes('"msg":"Request body"') || line.includes('"msg":"Response body"')); + } + + it("redacts sensitive request body fields by default", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ status: "SUCCESS" }), { status: 200 })); + const client = new HarnessClient(makeConfig({ HARNESS_LOG_UNSAFE_BODIES: false })); + + await client.request({ + method: "POST", + path: "/ng/api/connectors", + body: { name: "my-connector", token: "ghp_supersecret123", apiKey: "ak_xyz" }, + }); + + const logged = getLoggedBodies().join("\n"); + expect(logged).toContain("[REDACTED]"); + expect(logged).not.toContain("ghp_supersecret123"); + expect(logged).not.toContain("ak_xyz"); + expect(logged).toContain("my-connector"); // non-sensitive fields preserved + }); + + it("redacts sensitive response body fields by default", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ data: { name: "got", password: "leaked-pw" } }), { status: 200 }), + ); + const client = new HarnessClient(makeConfig({ HARNESS_LOG_UNSAFE_BODIES: false })); + + await client.request({ path: "/ng/api/test" }); + + const logged = getLoggedBodies().join("\n"); + expect(logged).toContain("[REDACTED]"); + expect(logged).not.toContain("leaked-pw"); + }); + + it("logs raw request body when HARNESS_LOG_UNSAFE_BODIES=true", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ status: "SUCCESS" }), { status: 200 })); + const client = new HarnessClient(makeConfig({ HARNESS_LOG_UNSAFE_BODIES: true })); + + await client.request({ + method: "POST", + path: "/ng/api/connectors", + body: { name: "my-connector", token: "ghp_rawvalue123" }, + }); + + const logged = getLoggedBodies().join("\n"); + expect(logged).toContain("ghp_rawvalue123"); + expect(logged).not.toContain("[REDACTED]"); + }); + }); });