/** * OST Scanner — Output-style validation (v5.6 C) * * Output styles are live (the standalone `/output-style` command was removed in * v2.1.91; styles are now managed via `/config`). They are the most surprising * steering surface because they rewrite the system prompt: * * CA-OST-001 A custom (user/project) output style that does NOT set * `keep-coding-instructions: true` → when active, Claude Code * REMOVES its built-in software-engineering instructions (how to * scope changes, write comments, verify work) and keeps only the * style's text. `keep-coding-instructions` defaults to false, so * this is the headline footgun. Severity medium. * * CA-OST-002 A PLUGIN output style with `force-for-plugin: true` → Claude * Code auto-applies it whenever the plugin is enabled, OVERRIDING * the user's selected `outputStyle`. If several enabled plugins set * it, the first loaded wins. Severity low (awareness). Note: * `force-for-plugin` is plugin-styles-only per the docs, so this * keys on `source === 'plugin'` — a user/project style cannot * trigger the override (it would simply be ignored). * * CA-OST-003 A settings `outputStyle` value that matches no built-in and no * discovered custom style → dead config: Claude Code falls back to * the default style, so the configured behavior is silently not * applied. Severity medium. * * Every claim traces to a CONFIRMED row of docs/v5.5-steering-model-plan.md * (V9/V10/V11/V12), verified against code.claude.com/docs/en/output-styles and * .../plugins-reference. The scanner is fixture-gated: with no output styles and * no `outputStyle` setting it emits nothing (keeps the SC-5 snapshot byte-stable). * * Zero external dependencies. */ import { readFile } from 'node:fs/promises'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { readActiveConfig } from './lib/active-config-reader.mjs'; import { parseFrontmatter, parseJson } from './lib/yaml-parser.mjs'; const SCANNER = 'OST'; // Built-in output styles, verified against code.claude.com/docs/en/output-styles. // Compared case-insensitively so OST-003 never false-flags a valid built-in. const BUILTIN_STYLES = ['default', 'explanatory', 'learning', 'proactive']; /** * Read + parse the frontmatter of each enumerated output style once. * @param {Array} styles - readActiveConfig().outputStyles entries */ async function withFrontmatter(styles) { const out = []; for (const s of styles) { let frontmatter = null; try { frontmatter = parseFrontmatter(await readFile(s.path, 'utf-8')).frontmatter; } catch { /* unreadable → treat as no frontmatter */ } out.push({ ...s, frontmatter }); } return out; } /** * Resolve the effective `outputStyle` setting from the cascade (user → project → * local; later scope wins). Returns null when unset everywhere. * @param {object} activeConfig * @returns {Promise<{value:string, scope:string, path:string} | null>} */ async function resolveOutputStyleSetting(activeConfig) { const cascade = (activeConfig.settings && activeConfig.settings.cascade) || []; let resolved = null; for (const entry of cascade) { if (!entry.exists || !entry.path) continue; let json = null; try { json = parseJson(await readFile(entry.path, 'utf-8')); } catch { continue; } if (json && typeof json.outputStyle === 'string' && json.outputStyle.trim()) { resolved = { value: json.outputStyle.trim(), scope: entry.scope, path: entry.path }; } } return resolved; } /** * Main scanner entry point. * @param {string} targetPath - repo root to scan * @param {object} _discovery - unused (OST reads the active config cascade itself) */ export async function scan(targetPath, _discovery) { const start = Date.now(); const findings = []; const activeConfig = await readActiveConfig(targetPath); const styles = await withFrontmatter(activeConfig.outputStyles || []); // CA-OST-001 — user/project custom style missing keep-coding-instructions:true. for (const s of styles) { if (s.source !== 'project' && s.source !== 'user') continue; const kci = s.frontmatter ? s.frontmatter.keep_coding_instructions : undefined; if (kci === true) continue; findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Custom output style removes built-in coding instructions', description: `The ${s.source} output style "${s.name}" does not set ` + '`keep-coding-instructions: true`. While this style is active, Claude Code ' + 'drops its built-in software-engineering instructions — how to scope changes, ' + 'write comments, and verify work — and keeps only this style\'s text. The ' + 'frontmatter flag defaults to false, so the strip is easy to miss.', file: s.path, evidence: `output_style="${s.name}"; source=${s.source}; ` + `keep-coding-instructions=${kci === undefined ? 'unset (default false)' : String(kci)}`, recommendation: 'To keep Claude Code\'s software-engineering behavior while applying this style, ' + 'add `keep-coding-instructions: true` to the frontmatter. If the strip is ' + 'intentional (a non-coding persona), no change is needed.', category: 'output-styles', })); } // CA-OST-002 — plugin output style with force-for-plugin:true (overrides user choice). for (const s of styles) { if (s.source !== 'plugin') continue; const ffp = s.frontmatter ? s.frontmatter.force_for_plugin : undefined; if (ffp !== true) continue; findings.push(finding({ scanner: SCANNER, severity: SEVERITY.low, title: 'Plugin output style overrides your selected output style', description: `The plugin "${s.pluginName}" ships an output style "${s.name}" with ` + '`force-for-plugin: true`, so Claude Code applies it automatically whenever the ' + 'plugin is enabled — overriding whatever `outputStyle` you selected. When more ' + 'than one enabled plugin does this, the first one loaded wins.', file: s.path, evidence: `output_style="${s.name}"; source=plugin:${s.pluginName}; force-for-plugin=true`, recommendation: 'If you did not expect this style, disable the plugin or remove ' + '`force-for-plugin: true` from its output style. This is awareness only — the ' + 'plugin is behaving as designed.', category: 'output-styles', })); } // CA-OST-003 — settings outputStyle resolving to a non-existent style (dead config). const resolved = await resolveOutputStyleSetting(activeConfig); if (resolved) { const known = new Set([ ...BUILTIN_STYLES, ...styles.map(s => String(s.name).toLowerCase()), ]); if (!known.has(resolved.value.toLowerCase())) { const customNames = styles.map(s => s.name); findings.push(finding({ scanner: SCANNER, severity: SEVERITY.medium, title: 'Configured output style does not exist', description: `Your ${resolved.scope} settings set \`outputStyle: "${resolved.value}"\`, but no ` + 'built-in or discovered custom style has that name. Claude Code falls back to the ' + 'default style, so the output behavior you configured is silently never applied.', file: resolved.path, evidence: `outputStyle="${resolved.value}"; scope=${resolved.scope}; ` + `builtins=[${BUILTIN_STYLES.join(', ')}]; ` + `known_custom=[${customNames.join(', ')}]`, recommendation: 'Fix the value to match an existing style (built-ins: Default, Explanatory, ' + 'Learning, Proactive), create the missing style under `.claude/output-styles/`, ' + 'or remove the `outputStyle` setting.', category: 'output-styles', })); } } return scannerResult(SCANNER, 'ok', findings, styles.length, Date.now() - start); }