Skip to content
Merged
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
9 changes: 5 additions & 4 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import { expandHomePath } from "./os-jank.ts";
import { makeServerPushBus } from "./wsServer/pushBus.ts";
import { makeServerReadiness } from "./wsServer/readiness.ts";
import { decodeJsonResult, formatSchemaError } from "@okcode/shared/schemaJson";
import { redactSensitiveText, redactSensitiveValue } from "@okcode/shared/redaction";
import { PrReview } from "./prReview/Services/PrReview.ts";
import { GitHub } from "./github/Services/GitHub.ts";
import { GitActionExecutionError } from "./git/Errors.ts";
Expand Down Expand Up @@ -1685,17 +1686,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
squashed instanceof GitActionExecutionError
) {
return {
message: squashed.failure.summary,
message: redactSensitiveText(squashed.failure.summary),
code: "git_action_failed",
data: squashed.failure,
data: redactSensitiveValue(squashed.failure),
};
}

if (squashed instanceof Error) {
return { message: squashed.message };
return { message: redactSensitiveText(squashed.message) };
}

return { message: Cause.pretty(cause) };
return { message: redactSensitiveText(Cause.pretty(cause)) };
};

const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/providerStatusPresentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ServerProviderStatus } from "@okcode/contracts";
import { redactSensitiveText } from "@okcode/shared/redaction";

export type ProviderSetupPhase = "install" | "authenticate" | "verify" | "ready";

Expand Down Expand Up @@ -43,7 +44,7 @@ export function getProviderStatusHeading(status: ServerProviderStatus): string {

export function getProviderStatusDescription(status: ServerProviderStatus): string {
if (status.message) {
return status.message;
return redactSensitiveText(status.message);
}

const label = getProviderLabel(status.provider);
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/chat/threadError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ describe("humanizeThreadError", () => {
});
});

it("redacts secret-like values before presenting thread errors", () => {
expect(
humanizeThreadError(
"Git command failed in GitCore.createWorktree: OPENAI_API_KEY=sk-proj-secret (/repo) - token=abc123",
),
).toEqual({
title: "Worktree thread could not start",
description: "token=[REDACTED]",
technicalDetails:
"Git command failed in GitCore.createWorktree: OPENAI_API_KEY=[REDACTED] (/repo) - token=[REDACTED]",
});
});

it("detects provider authentication failures", () => {
expect(
isAuthenticationThreadError(
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/threadError.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { redactSensitiveText } from "@okcode/shared/redaction";

export interface ThreadErrorPresentation {
title: string | null;
description: string;
Expand Down Expand Up @@ -36,7 +38,7 @@ export function isAuthenticationThreadError(error: string | null | undefined): b
}

export function humanizeThreadError(error: string): ThreadErrorPresentation {
const trimmed = error.trim();
const trimmed = redactSensitiveText(error).trim();
const worktreeDetail = extractWorktreeDetail(trimmed);
if (worktreeDetail) {
return {
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/wsTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,49 @@ describe("WsTransport", () => {
transport.dispose();
});

it("redacts secret-like values from rejected websocket errors", async () => {
const transport = new WsTransport("ws://localhost:3020");
const socket = getSocket();
socket.open();

const requestPromise = transport.request("git.runStackedAction", { cwd: "/repo" });
const sent = socket.sent.at(-1);
if (!sent) {
throw new Error("Expected request envelope to be sent");
}

const requestEnvelope = JSON.parse(sent) as { id: string };
socket.serverMessage(
JSON.stringify({
id: requestEnvelope.id,
error: {
message: "Push failed for sk-proj-secret",
code: "git_action_failed",
data: {
code: "unknown",
phase: "push",
title: "Push failed",
summary: "Push failed for sk-proj-secret",
detail: "token=abc123 OPENAI_API_KEY=sk-proj-secret",
nextSteps: ["Unset OPENAI_API_KEY=sk-proj-secret"],
},
},
}),
);

await expect(requestPromise).rejects.toMatchObject({
message: "Push failed for [REDACTED]",
code: "git_action_failed",
data: {
summary: "Push failed for [REDACTED]",
detail: "token=[REDACTED] OPENAI_API_KEY=[REDACTED]",
nextSteps: ["Unset OPENAI_API_KEY=[REDACTED]"],
},
});

transport.dispose();
});

it("drops malformed envelopes without crashing transport", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const transport = new WsTransport("ws://localhost:3020");
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/wsTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type WsResponse as WsResponseMessage,
WsResponse as WsResponseSchema,
} from "@okcode/contracts";
import { redactSensitiveText, redactSensitiveValue } from "@okcode/shared/redaction";
import { decodeUnknownJsonResult, formatSchemaError } from "@okcode/shared/schemaJson";
import { Result, Schema } from "effect";
import { resolveRuntimeWsUrl } from "./lib/runtimeBridge";
Expand Down Expand Up @@ -72,10 +73,10 @@ export class WsRequestError<T = unknown> extends Error {
readonly data: T | undefined;

constructor(input: WebSocketErrorPayload) {
super(input.message);
super(redactSensitiveText(input.message));
this.name = "WsRequestError";
this.code = input.code;
this.data = input.data as T | undefined;
this.data = redactSensitiveValue(input.data) as T | undefined;
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
"./brand": {
"types": "./src/brand.ts",
"import": "./src/brand.ts"
},
"./redaction": {
"types": "./src/redaction.ts",
"import": "./src/redaction.ts"
}
},
"scripts": {
Expand Down
50 changes: 50 additions & 0 deletions packages/shared/src/redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";

import { redactSensitiveText, redactSensitiveValue } from "./redaction";

describe("redactSensitiveText", () => {
it("redacts OpenAI-style secret keys", () => {
expect(redactSensitiveText("OpenAI failed with sk-proj-abc123_secret-token"))
.toBe("OpenAI failed with [REDACTED]");
});

it("redacts environment variable assignments", () => {
expect(
redactSensitiveText(
"Command failed with OPENAI_API_KEY=sk-proj-abc123 SECRET_TOKEN=hunter2 PATH=/tmp/bin",
),
).toBe(
"Command failed with OPENAI_API_KEY=[REDACTED] SECRET_TOKEN=[REDACTED] PATH=[REDACTED]",
);
});

it("redacts sensitive JSON-like fields and query params", () => {
expect(
redactSensitiveText(
'Request failed: {"token":"abc123","password":"hunter2"} https://x.test?token=abc123&ok=1',
),
).toBe(
'Request failed: {"token":"[REDACTED]","password":"[REDACTED]"} https://x.test?token=[REDACTED]&ok=1',
);
});
});

describe("redactSensitiveValue", () => {
it("redacts nested structured payloads", () => {
expect(
redactSensitiveValue({
summary: "Push failed for sk-proj-abc123",
nextSteps: ["Unset OPENAI_API_KEY=sk-proj-abc123"],
nested: {
detail: "Authorization: Bearer topsecret",
},
}),
).toEqual({
summary: "Push failed for [REDACTED]",
nextSteps: ["Unset OPENAI_API_KEY=[REDACTED]"],
nested: {
detail: "Authorization: Bearer [REDACTED]",
},
});
});
});
50 changes: 50 additions & 0 deletions packages/shared/src/redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const REDACTED = "[REDACTED]";

const SECRET_PREFIX_PATTERN = /\bsk-[A-Za-z0-9][A-Za-z0-9_-]*\b/g;
const BEARER_TOKEN_PATTERN = /\b(Bearer\s+)([^\s,;]+)/gi;
const SENSITIVE_QUERY_PARAM_PATTERN =
/([?&](?:access[_-]?token|api[_-]?key|auth(?:orization)?|client[_-]?secret|password|refresh[_-]?token|secret|session[_-]?token|token)=)([^&#\s]+)/gi;
const SENSITIVE_FIELD_PATTERN =
/((?:"|')?(?:access[_-]?token|api[_-]?key|auth(?:orization)?|client[_-]?secret|password|refresh[_-]?token|secret|session[_-]?token|token)(?:"|')?\s*[:=]\s*)(["'`]?)([^"'`\s,}]+)(\2)/gi;
const PROCESS_ENV_PATTERN =
/\b((?:process\.)?env\.[A-Za-z_][A-Za-z0-9_]*\s*(?:=|:)\s*)(["'`]?)([^"'`\s,}]+)(\2)/g;
const ENV_ASSIGNMENT_PATTERN =
/\b([A-Z][A-Z0-9_]{1,63}\s*=\s*)(["'`]?)([^"'`\s]+)(\2)/g;

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}

export function redactSensitiveText(text: string): string {
return text
.replace(SECRET_PREFIX_PATTERN, REDACTED)
.replace(BEARER_TOKEN_PATTERN, `$1${REDACTED}`)
.replace(SENSITIVE_QUERY_PARAM_PATTERN, `$1${REDACTED}`)
.replace(SENSITIVE_FIELD_PATTERN, `$1$2${REDACTED}$4`)
.replace(PROCESS_ENV_PATTERN, `$1$2${REDACTED}$4`)
.replace(ENV_ASSIGNMENT_PATTERN, `$1$2${REDACTED}$4`);
}

export function redactSensitiveValue<T>(value: T): T {
if (typeof value === "string") {
return redactSensitiveText(value) as T;
}

if (Array.isArray(value)) {
return value.map((item) => redactSensitiveValue(item)) as T;
}

if (isPlainObject(value)) {
const entries = Object.entries(value).map(([key, entryValue]) => [
key,
redactSensitiveValue(entryValue),
]);
return Object.fromEntries(entries) as T;
}

return value;
}
Loading