diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e188275..af18252c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. - Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237. - TUI: migrate key handling to the updated pi-tui Key matcher API. +- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns). - macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable). - macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. - macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets. diff --git a/docs/configuration.md b/docs/configuration.md index 534e90d6b..a7d53405a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -141,6 +141,9 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). - Console output can be tuned separately via: - `logging.consoleLevel` (defaults to `info`, bumps to `debug` when `--verbose`) - `logging.consoleStyle` (`pretty` | `compact` | `json`) +- Tool summaries can be redacted to avoid leaking secrets: + - `logging.redactSensitive` (`off` | `tools`, default: `tools`) + - `logging.redactPatterns` (array of regex strings; overrides defaults) ```json5 { @@ -148,7 +151,13 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). level: "info", file: "/tmp/clawdbot/clawdbot.log", consoleLevel: "info", - consoleStyle: "pretty" + consoleStyle: "pretty", + redactSensitive: "tools", + redactPatterns: [ + // Example: override defaults with your own rules. + "\\bTOKEN\\b\\s*[=:]\\s*([\"']?)([^\\s\"']+)\\1", + "/\\bsk-[A-Za-z0-9_-]{8,}\\b/gi" + ] } } ``` diff --git a/docs/logging.md b/docs/logging.md index fdc7ad251..89ffab3a7 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -42,6 +42,17 @@ You can tune console verbosity independently via: - `logging.consoleLevel` (default `info`) - `logging.consoleStyle` (`pretty` | `compact` | `json`) +## Tool summary redaction + +Verbose tool summaries (e.g. `🛠️ bash: ...`) can mask sensitive tokens before they hit the +console stream. This is **tools-only** and does not alter file logs. + +- `logging.redactSensitive`: `off` | `tools` (default: `tools`) +- `logging.redactPatterns`: array of regex strings (overrides defaults) + - Use raw regex strings (auto `gi`), or `/pattern/flags` if you need custom flags. + - Matches are masked by keeping the first 6 + last 4 chars (length >= 18), otherwise `***`. + - Defaults cover common key assignments, CLI flags, JSON fields, bearer headers, PEM blocks, and popular token prefixes. + ## Gateway WebSocket logs The gateway prints WebSocket protocol logs in two modes: diff --git a/src/agents/tool-display.ts b/src/agents/tool-display.ts index 6975675f5..1d531e8c5 100644 --- a/src/agents/tool-display.ts +++ b/src/agents/tool-display.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { redactToolDetail } from "../logging/redact.js"; import { shortenHomeInString } from "../utils.js"; type ToolDisplayActionSpec = { @@ -193,7 +194,7 @@ export function resolveToolDisplay(params: { export function formatToolDetail(display: ToolDisplay): string | undefined { const parts: string[] = []; if (display.verb) parts.push(display.verb); - if (display.detail) parts.push(display.detail); + if (display.detail) parts.push(redactToolDetail(display.detail)); if (parts.length === 0) return undefined; return parts.join(" · "); } diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 725d71695..2fb0eba8d 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -142,6 +142,19 @@ export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig { }; } +export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig { + const logging = cfg.logging; + if (!logging) return cfg; + if (logging.redactSensitive) return cfg; + return { + ...cfg, + logging: { + ...logging, + redactSensitive: "tools", + }, + }; +} + export function resetSessionDefaultsWarningForTests() { defaultWarnState = { warned: false }; } diff --git a/src/config/io.ts b/src/config/io.ts index ee4fb4cf5..ca04943b1 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -10,6 +10,7 @@ import { } from "../infra/shell-env.js"; import { applyIdentityDefaults, + applyLoggingDefaults, applyModelAliasDefaults, applySessionDefaults, applyTalkApiKey, @@ -115,7 +116,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } const cfg = applyModelAliasDefaults( applySessionDefaults( - applyIdentityDefaults(validated.data as ClawdbotConfig), + applyLoggingDefaults( + applyIdentityDefaults(validated.data as ClawdbotConfig), + ), ), ); @@ -201,7 +204,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: parsedRes.parsed, valid: true, config: applyTalkApiKey( - applyModelAliasDefaults(applySessionDefaults(validated.config)), + applyModelAliasDefaults( + applySessionDefaults(applyLoggingDefaults(validated.config)), + ), ), issues: [], legacyIssues, diff --git a/src/config/types.ts b/src/config/types.ts index 3db517bf1..29633cb6c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -44,6 +44,10 @@ export type LoggingConfig = { | "debug" | "trace"; consoleStyle?: "pretty" | "compact" | "json"; + /** Redact sensitive tokens in tool summaries. Default: "tools". */ + redactSensitive?: "off" | "tools"; + /** Regex patterns used to redact sensitive tokens (defaults apply when unset). */ + redactPatterns?: string[]; }; export type WebReconnectConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c047b920e..f7b671c79 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -330,6 +330,8 @@ export const ClawdbotSchema = z.object({ consoleStyle: z .union([z.literal("pretty"), z.literal("compact"), z.literal("json")]) .optional(), + redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(), + redactPatterns: z.array(z.string()).optional(), }) .optional(), browser: z diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts new file mode 100644 index 000000000..7783751d1 --- /dev/null +++ b/src/logging/redact.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { getDefaultRedactPatterns, redactSensitiveText } from "./redact.js"; + +const defaults = getDefaultRedactPatterns(); + +describe("redactSensitiveText", () => { + it("masks env assignments while keeping the key", () => { + const input = "OPENAI_API_KEY=sk-1234567890abcdef"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("OPENAI_API_KEY=sk-123…cdef"); + }); + + it("masks CLI flags", () => { + const input = "curl --token abcdef1234567890ghij https://api.test"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("curl --token abcdef…ghij https://api.test"); + }); + + it("masks JSON fields", () => { + const input = '{"token":"abcdef1234567890ghij"}'; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe('{"token":"abcdef…ghij"}'); + }); + + it("masks bearer tokens", () => { + const input = "Authorization: Bearer abcdef1234567890ghij"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("Authorization: Bearer abcdef…ghij"); + }); + + it("masks Telegram-style tokens", () => { + const input = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("123456…cdef"); + }); + + it("redacts short tokens fully", () => { + const input = "TOKEN=shortvalue"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("TOKEN=***"); + }); + + it("redacts private key blocks", () => { + const input = [ + "-----BEGIN PRIVATE KEY-----", + "ABCDEF1234567890", + "ZYXWVUT987654321", + "-----END PRIVATE KEY-----", + ].join("\n"); + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe( + [ + "-----BEGIN PRIVATE KEY-----", + "…redacted…", + "-----END PRIVATE KEY-----", + ].join("\n"), + ); + }); + + it("honors custom patterns with flags", () => { + const input = "token=abcdef1234567890ghij"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: ["/token=([A-Za-z0-9]+)/i"], + }); + expect(output).toBe("token=abcdef…ghij"); + }); + + it("skips redaction when mode is off", () => { + const input = "OPENAI_API_KEY=sk-1234567890abcdef"; + const output = redactSensitiveText(input, { + mode: "off", + patterns: defaults, + }); + expect(output).toBe(input); + }); +}); diff --git a/src/logging/redact.ts b/src/logging/redact.ts new file mode 100644 index 000000000..be43177e4 --- /dev/null +++ b/src/logging/redact.ts @@ -0,0 +1,128 @@ +import { loadConfig } from "../config/config.js"; +import type { LoggingConfig } from "../config/types.js"; + +export type RedactSensitiveMode = "off" | "tools"; + +const DEFAULT_REDACT_MODE: RedactSensitiveMode = "tools"; +const DEFAULT_REDACT_MIN_LENGTH = 18; +const DEFAULT_REDACT_KEEP_START = 6; +const DEFAULT_REDACT_KEEP_END = 4; + +const DEFAULT_REDACT_PATTERNS: string[] = [ + // ENV-style assignments. + String.raw`\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1`, + // JSON fields. + String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"`, + // CLI flags. + String.raw`--(?:api[-_]?key|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1`, + // Authorization headers. + String.raw`Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)`, + String.raw`\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b`, + // PEM blocks. + String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----`, + // Common token prefixes. + String.raw`\b(sk-[A-Za-z0-9_-]{8,})\b`, + String.raw`\b(ghp_[A-Za-z0-9]{20,})\b`, + String.raw`\b(github_pat_[A-Za-z0-9_]{20,})\b`, + String.raw`\b(xox[baprs]-[A-Za-z0-9-]{10,})\b`, + String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`, + String.raw`\b(gsk_[A-Za-z0-9_-]{10,})\b`, + String.raw`\b(AIza[0-9A-Za-z\-_]{20,})\b`, + String.raw`\b(pplx-[A-Za-z0-9_-]{10,})\b`, + String.raw`\b(npm_[A-Za-z0-9]{10,})\b`, + String.raw`\b(\d{6,}:[A-Za-z0-9_-]{20,})\b`, +]; + +type RedactOptions = { + mode?: RedactSensitiveMode; + patterns?: string[]; +}; + +function normalizeMode(value?: string): RedactSensitiveMode { + return value === "off" ? "off" : DEFAULT_REDACT_MODE; +} + +function parsePattern(raw: string): RegExp | null { + if (!raw.trim()) return null; + const match = raw.match(/^\/(.+)\/([gimsuy]*)$/); + try { + if (match) { + const flags = match[2].includes("g") ? match[2] : `${match[2]}g`; + return new RegExp(match[1], flags); + } + return new RegExp(raw, "gi"); + } catch { + return null; + } +} + +function resolvePatterns(value?: string[]): RegExp[] { + const source = value?.length ? value : DEFAULT_REDACT_PATTERNS; + return source.map(parsePattern).filter((re): re is RegExp => Boolean(re)); +} + +function maskToken(token: string): string { + if (token.length < DEFAULT_REDACT_MIN_LENGTH) return "***"; + const start = token.slice(0, DEFAULT_REDACT_KEEP_START); + const end = token.slice(-DEFAULT_REDACT_KEEP_END); + return `${start}…${end}`; +} + +function redactPemBlock(block: string): string { + const lines = block.split(/\r?\n/).filter(Boolean); + if (lines.length < 2) return "***"; + return `${lines[0]}\n…redacted…\n${lines[lines.length - 1]}`; +} + +function redactMatch(match: string, groups: string[]): string { + if (match.includes("PRIVATE KEY-----")) return redactPemBlock(match); + const token = + groups + .filter((value) => typeof value === "string" && value.length > 0) + .at(-1) ?? match; + const masked = maskToken(token); + if (token === match) return masked; + return match.replace(token, masked); +} + +function redactText(text: string, patterns: RegExp[]): string { + let next = text; + for (const pattern of patterns) { + next = next.replace( + pattern, + (...args: string[]) => + redactMatch(args[0], args.slice(1, args.length - 2)), + ); + } + return next; +} + +function resolveConfigRedaction(): RedactOptions { + const cfg = loadConfig().logging; + return { + mode: normalizeMode(cfg?.redactSensitive), + patterns: cfg?.redactPatterns, + }; +} + +export function redactSensitiveText( + text: string, + options?: RedactOptions, +): string { + if (!text) return text; + const resolved = options ?? resolveConfigRedaction(); + if (normalizeMode(resolved.mode) === "off") return text; + const patterns = resolvePatterns(resolved.patterns); + if (!patterns.length) return text; + return redactText(text, patterns); +} + +export function redactToolDetail(detail: string): string { + const resolved = resolveConfigRedaction(); + if (normalizeMode(resolved.mode) !== "tools") return detail; + return redactSensitiveText(detail, resolved); +} + +export function getDefaultRedactPatterns(): string[] { + return [...DEFAULT_REDACT_PATTERNS]; +}