Skip to content
Closed
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 src/cli/config-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ function redact(value: unknown, key = ""): unknown {
return value;
}

function omitWebhookCredentials(value: unknown): unknown {
if (Array.isArray(value)) return value.map(omitWebhookCredentials);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([key]) => key.toLowerCase() !== "webhookurl")
.map(([key, child]) => [key, omitWebhookCredentials(child)]),
);
}

function pathSegments(path: string): string[] {
const segments = path.split(".").map(part => part.trim()).filter(Boolean);
if (segments.length === 0 || segments.some(part => BLOCKED_SEGMENTS.has(part))) throw new CliUsageError("invalid config path", USAGE);
Expand Down Expand Up @@ -195,7 +205,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
const path = args.shift();
if (!path) throw new CliUsageError("export path is required", USAGE);
rejectArgs(args, USAGE);
const content = `${JSON.stringify(readConfigDiagnostics().config, null, 2)}\n`;
const content = `${JSON.stringify(omitWebhookCredentials(readConfigDiagnostics().config), null, 2)}\n`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refuse exports over the active config

When <path> is the active config.json, this sanitized snapshot is written back over the live configuration, permanently deleting quotaResetNotify.webhookUrl and silently disabling webhook notifications after the next reload. Previously, exporting to that path preserved the full config; compare the resolved destination with getConfigPath() and reject an in-place sanitized export before writing.

AGENTS.md reference: src/AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

if (path === "-") process.stdout.write(content);
else { writeFileSync(path, content, { encoding: "utf8", mode: 0o600 }); console.log(`Exported config to ${path}.`); }
return;
Expand Down
64 changes: 55 additions & 9 deletions tests/usage/quota-reset-notify.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget";
Expand Down Expand Up @@ -366,12 +366,8 @@ describe("config integration", () => {
});

describe("webhookUrl is treated as a credential", () => {
test("ocx config show does not print it", async () => {
// For Slack and Discord the URL IS the authorization: anyone holding it can post to the
// channel. It matches none of the pre-existing secret-key patterns, so it had to be named
// explicitly — before that, `config show` printed it and `config export` wrote it to disk.
function configureWebhook(secret: string): { home: string; restore: () => void } {
const home = mkdtempSync(join(tmpdir(), "ocx-redact-"));
const secret = "https://hooks.slack.com/services/T00000/B00000/zzTOKENzz";
writeFileSync(join(home, "config.json"), JSON.stringify({
port: 10100,
defaultProvider: "openai",
Expand All @@ -380,9 +376,23 @@ describe("webhookUrl is treated as a credential", () => {
},
quotaResetNotify: { enabled: true, webhookUrl: secret },
}));

const previousHome = process.env["OPENCODEX_HOME"];
process.env["OPENCODEX_HOME"] = home;
return {
home,
restore: () => {
if (previousHome === undefined) delete process.env["OPENCODEX_HOME"];
else process.env["OPENCODEX_HOME"] = previousHome;
},
};
}

test("ocx config show does not print it", async () => {
// For Slack and Discord the URL IS the authorization: anyone holding it can post to the
// channel. It matches none of the pre-existing secret-key patterns, so it had to be named
// explicitly — before that, `config show` printed it and `config export` wrote it to disk.
const secret = "https://hooks.slack.com/services/T00000/B00000/zzTOKENzz";
const configured = configureWebhook(secret);
const written: string[] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => { written.push(args.map(String).join(" ")); };
Expand All @@ -395,8 +405,44 @@ describe("webhookUrl is treated as a credential", () => {
expect(output).toContain("********");
} finally {
console.log = originalLog;
if (previousHome === undefined) delete process.env["OPENCODEX_HOME"];
else process.env["OPENCODEX_HOME"] = previousHome;
configured.restore();
}
});

test("ocx config export omits it from stdout", async () => {
const secret = "https://hooks.slack.com/services/T00000/B00000/stdoutTOKEN";
const configured = configureWebhook(secret);
const written: string[] = [];
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
written.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
expect(await handleConfigCommand(["export", "-"])).toBe(0);
const exported = JSON.parse(written.join("")) as Record<string, unknown>;
expect(JSON.stringify(exported)).not.toContain(secret);
expect(exported["quotaResetNotify"]).toEqual({ enabled: true });
} finally {
process.stdout.write = originalWrite;
configured.restore();
}
});

test("ocx config export omits it from a file", async () => {
const secret = "https://hooks.discord.com/api/webhooks/fileTOKEN";
const configured = configureWebhook(secret);
const outputPath = join(configured.home, "export.json");
const originalLog = console.log;
console.log = () => {};
try {
expect(await handleConfigCommand(["export", outputPath])).toBe(0);
const exported = JSON.parse(readFileSync(outputPath, "utf8")) as Record<string, unknown>;
expect(JSON.stringify(exported)).not.toContain(secret);
expect(exported["quotaResetNotify"]).toEqual({ enabled: true });
} finally {
console.log = originalLog;
configured.restore();
}
});
});
Expand Down
Loading