Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions tests/client/harness-client.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): Config {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<string, string>;
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());
Expand Down Expand Up @@ -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<typeof vi.spyOn>;
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]");
});
});
});