feat(config): add CLI privacy gates

This commit is contained in:
digua committed 2026-07-04 15:12:50 +08:00
1 parent 32e0868f65
commit 217e765305
5 files changed
+276 -1

No files matched your search

+3 -1
View File
@@ -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,
+2
View File
@@ -80,6 +80,7 @@ function loadConfigFile(): Record<string, unknown> {
* - 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<string, unknown> {
const result: Record<string, Record<string, unknown>> = {}
@@ -92,6 +93,7 @@ function loadEnvConfig(): Record<string, unknown> {
{ 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) {
+9
View File
@@ -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<typeof configSchema>
@@ -50,3 +58,4 @@ export type DataConfig = z.infer<typeof dataConfigSchema>
export type ApiConfig = z.infer<typeof apiConfigSchema>
export type LocaleConfig = z.infer<typeof localeConfigSchema>
export type UiConfig = z.infer<typeof uiConfigSchema>
export type CliConfig = z.infer<typeof cliConfigSchema>
@@ -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)
})
})
+109
View File
@@ -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 `<section>.<key>` (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 <section>.<key>, got "${fieldPath}"`)
}
const [section, key] = parts
const defaults = configSchema.parse({}) as Record<string, Record<string, unknown>>
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
}