From 217e765305fa642d3974e717a17e1801669d0c4e Mon Sep 17 00:00:00 2001 From: digua Date: Fri, 3 Jul 2026 22:23:54 +0800 Subject: [PATCH] feat(config): add CLI privacy gates --- packages/config/src/index.ts | 4 +- packages/config/src/loader.ts | 2 + packages/config/src/schema.ts | 9 ++ packages/config/src/set-config-field.test.ts | 153 +++++++++++++++++++ packages/config/src/set-config-field.ts | 109 +++++++++++++ 5 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 packages/config/src/set-config-field.test.ts create mode 100644 packages/config/src/set-config-field.ts diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index bd13fa4b..22979078 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -5,8 +5,10 @@ */ export { loadConfig, getConfigPath, getConfigDir, writeConfigField } from './loader' +export { setConfigField, ConfigSetError } from './set-config-field' +export type { ConfigSetResult, ConfigSetErrorReason } from './set-config-field' export { configSchema, DEFAULT_API_PORT } from './schema' -export type { ChatLabConfig, LlmConfig, DataConfig, ApiConfig, LocaleConfig, UiConfig } from './schema' +export type { ChatLabConfig, LlmConfig, DataConfig, ApiConfig, LocaleConfig, UiConfig, CliConfig } from './schema' export { loadAuthProfiles, getApiKeyByProfile, diff --git a/packages/config/src/loader.ts b/packages/config/src/loader.ts index fba0ea32..af8f2a0b 100644 --- a/packages/config/src/loader.ts +++ b/packages/config/src/loader.ts @@ -80,6 +80,7 @@ function loadConfigFile(): Record { * - CHATLAB_LLM_MODEL -> llm.model * - CHATLAB_LLM_BASE_URL -> llm.base_url * - CHATLAB_LOCALE_LANG -> locale.lang + * - CHATLAB_CLI_ALLOW_RAW -> cli.allow_raw ('1'/'true' enables) */ function loadEnvConfig(): Record { const result: Record> = {} @@ -92,6 +93,7 @@ function loadEnvConfig(): Record { { env: 'CHATLAB_LLM_MODEL', section: 'llm', key: 'model' }, { env: 'CHATLAB_LLM_BASE_URL', section: 'llm', key: 'base_url' }, { env: 'CHATLAB_LOCALE_LANG', section: 'locale', key: 'lang' }, + { env: 'CHATLAB_CLI_ALLOW_RAW', section: 'cli', key: 'allow_raw', transform: (v) => v === '1' || v === 'true' }, ] for (const { env, section, key, transform } of envMap) { diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index a0f9f90e..cc2592bc 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -36,12 +36,20 @@ export const uiConfigSchema = z.object({ summary_strategy: z.enum(['brief', 'standard']).default('standard'), }) +export const cliConfigSchema = z.object({ + /** Allow --raw output that bypasses privacy preprocessing (user-side opt-in). */ + allow_raw: z.boolean().default(false), + /** Allow the read-only `sql` query command. */ + allow_sql: z.boolean().default(true), +}) + export const configSchema = z.object({ llm: llmConfigSchema.default({}), data: dataConfigSchema.default({}), api: apiConfigSchema.default({}), locale: localeConfigSchema.default({}), ui: uiConfigSchema.default({}), + cli: cliConfigSchema.default({}), }) export type ChatLabConfig = z.infer @@ -50,3 +58,4 @@ export type DataConfig = z.infer export type ApiConfig = z.infer export type LocaleConfig = z.infer export type UiConfig = z.infer +export type CliConfig = z.infer diff --git a/packages/config/src/set-config-field.test.ts b/packages/config/src/set-config-field.test.ts new file mode 100644 index 00000000..44087799 --- /dev/null +++ b/packages/config/src/set-config-field.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { after, beforeEach, describe, it } from 'node:test' + +const tempHome = mkdtempSync(join(tmpdir(), 'chatlab-config-set-')) +process.env.HOME = tempHome + +const [{ setConfigField, ConfigSetError }, { loadConfig }] = await Promise.all([ + import('./set-config-field'), + import('./loader'), +]) + +const configToml = join(tempHome, '.chatlab', 'config.toml') + +beforeEach(() => { + rmSync(join(tempHome, '.chatlab'), { recursive: true, force: true }) + delete process.env.CHATLAB_CLI_ALLOW_RAW +}) + +after(() => { + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe('cli config section', () => { + it('defaults allow_raw=false and allow_sql=true', () => { + const config = loadConfig() + assert.equal(config.cli.allow_raw, false) + assert.equal(config.cli.allow_sql, true) + }) + + it('maps CHATLAB_CLI_ALLOW_RAW env var to cli.allow_raw', () => { + process.env.CHATLAB_CLI_ALLOW_RAW = '1' + assert.equal(loadConfig().cli.allow_raw, true) + + process.env.CHATLAB_CLI_ALLOW_RAW = 'true' + assert.equal(loadConfig().cli.allow_raw, true) + + process.env.CHATLAB_CLI_ALLOW_RAW = '0' + assert.equal(loadConfig().cli.allow_raw, false) + }) +}) + +describe('setConfigField', () => { + it('writes boolean value and round-trips via loadConfig', () => { + const result = setConfigField('cli.allow_raw', 'true') + assert.deepEqual(result, { section: 'cli', key: 'allow_raw', value: true }) + assert.equal(loadConfig().cli.allow_raw, true) + + setConfigField('cli.allow_raw', 'false') + assert.equal(loadConfig().cli.allow_raw, false) + }) + + it('parses number values by schema type', () => { + setConfigField('api.port', '8080') + assert.equal(loadConfig().api.port, 8080) + }) + + it('rejects unknown section or key without touching the file', () => { + assert.throws( + () => setConfigField('nope.key', 'x'), + (err: unknown) => { + assert.ok(err instanceof ConfigSetError) + assert.equal(err.reason, 'unknown_key') + return true + } + ) + assert.throws( + () => setConfigField('cli.nope', 'x'), + (err: unknown) => { + assert.ok(err instanceof ConfigSetError) + assert.equal(err.reason, 'unknown_key') + return true + } + ) + assert.throws( + () => setConfigField('cli', 'x'), + (err: unknown) => { + assert.ok(err instanceof ConfigSetError) + assert.equal(err.reason, 'unknown_key') + return true + } + ) + assert.equal(existsSync(configToml), false) + }) + + it('rejects values that do not parse as the schema type', () => { + assert.throws( + () => setConfigField('cli.allow_raw', 'yes'), + (err: unknown) => { + assert.ok(err instanceof ConfigSetError) + assert.equal(err.reason, 'invalid_value') + return true + } + ) + assert.throws( + () => setConfigField('api.port', 'not-a-number'), + (err: unknown) => { + assert.ok(err instanceof ConfigSetError) + assert.equal(err.reason, 'invalid_value') + return true + } + ) + assert.equal(existsSync(configToml), false) + }) + + it('rolls back when post-write validation fails', () => { + setConfigField('cli.allow_raw', 'true') + const original = readFileSync(configToml, 'utf-8') + + // typeof default is string, passes type parsing, but violates the zod enum on reload + assert.throws( + () => setConfigField('ui.default_session_tab', 'bogus'), + (err: unknown) => { + assert.ok(err instanceof ConfigSetError) + assert.equal(err.reason, 'invalid_config') + return true + } + ) + + assert.equal(readFileSync(configToml, 'utf-8'), original) + assert.equal(loadConfig().cli.allow_raw, true) + }) + + it('removes the file on rollback when it did not exist before', () => { + assert.throws( + () => setConfigField('ui.default_session_tab', 'bogus'), + (err: unknown) => { + assert.ok(err instanceof ConfigSetError) + assert.equal(err.reason, 'invalid_config') + return true + } + ) + assert.equal(existsSync(configToml), false) + }) + + it('refuses to overwrite an unparseable config file', () => { + mkdirSync(join(tempHome, '.chatlab'), { recursive: true }) + const corrupt = '[cli\nallow_raw =' + writeFileSync(configToml, corrupt, 'utf-8') + + assert.throws( + () => setConfigField('cli.allow_raw', 'true'), + (err: unknown) => { + assert.ok(err instanceof ConfigSetError) + assert.equal(err.reason, 'unreadable_config') + return true + } + ) + assert.equal(readFileSync(configToml, 'utf-8'), corrupt) + }) +}) diff --git a/packages/config/src/set-config-field.ts b/packages/config/src/set-config-field.ts new file mode 100644 index 00000000..1ad8191d --- /dev/null +++ b/packages/config/src/set-config-field.ts @@ -0,0 +1,109 @@ +/** + * Safe config field writer for `chatlab config set`. + * + * Wraps writeConfigField with guardrails: only schema-defined keys are accepted, + * values are parsed by schema type, and the file is validated after writing with + * rollback on failure — a broken config file is a user-data incident. + */ + +import * as fs from 'fs' +import * as path from 'path' +import { parse as parseToml } from 'smol-toml' +import { configSchema } from './schema' +import { loadConfig, writeConfigField, getConfigDir } from './loader' + +export type ConfigSetErrorReason = 'unknown_key' | 'invalid_value' | 'invalid_config' | 'unreadable_config' + +export class ConfigSetError extends Error { + readonly reason: ConfigSetErrorReason + + constructor(reason: ConfigSetErrorReason, message: string) { + super(message) + this.name = 'ConfigSetError' + this.reason = reason + } +} + +export interface ConfigSetResult { + section: string + key: string + value: string | number | boolean +} + +/** + * Set a config field addressed as `
.` (e.g. `cli.allow_raw`). + * Throws ConfigSetError without touching the file when the key or value is invalid; + * rolls the file back when the written config fails full validation. + */ +export function setConfigField(fieldPath: string, rawValue: string): ConfigSetResult { + const parts = fieldPath.split('.') + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new ConfigSetError('unknown_key', `Expected
., got "${fieldPath}"`) + } + const [section, key] = parts + + const defaults = configSchema.parse({}) as Record> + const sectionDefaults = defaults[section] + if (!sectionDefaults || typeof sectionDefaults !== 'object') { + throw new ConfigSetError('unknown_key', `Unknown config section "${section}"`) + } + if (!(key in sectionDefaults)) { + throw new ConfigSetError('unknown_key', `Unknown config key "${section}.${key}"`) + } + + const value = parseValueByType(section, key, typeof sectionDefaults[key], rawValue) + + const tomlPath = path.join(getConfigDir(), 'config.toml') + let original: string | null = null + if (fs.existsSync(tomlPath)) { + original = fs.readFileSync(tomlPath, 'utf-8') + try { + parseToml(original) + } catch { + throw new ConfigSetError( + 'unreadable_config', + `Existing config file is not valid TOML and will not be overwritten: ${tomlPath}` + ) + } + } + + writeConfigField(section, key, value) + + try { + loadConfig() + } catch (err) { + if (original === null) { + fs.unlinkSync(tomlPath) + } else { + fs.writeFileSync(tomlPath, original, 'utf-8') + } + const detail = err instanceof Error ? err.message : String(err) + throw new ConfigSetError( + 'invalid_config', + `Value "${rawValue}" for ${section}.${key} failed config validation; file rolled back. ${detail}` + ) + } + + return { section, key, value } +} + +function parseValueByType( + section: string, + key: string, + expectedType: string, + rawValue: string +): string | number | boolean { + if (expectedType === 'boolean') { + if (rawValue === 'true') return true + if (rawValue === 'false') return false + throw new ConfigSetError('invalid_value', `${section}.${key} expects "true" or "false", got "${rawValue}"`) + } + if (expectedType === 'number') { + const num = Number(rawValue) + if (!Number.isFinite(num) || rawValue.trim() === '') { + throw new ConfigSetError('invalid_value', `${section}.${key} expects a number, got "${rawValue}"`) + } + return num + } + return rawValue +}