From a72caacced702ffdff256505b7365a9d454dd2d1 Mon Sep 17 00:00:00 2001 From: F-e-u-e-r Date: Mon, 22 Jun 2026 20:50:05 +0800 Subject: [PATCH] fix(classifier): enforce AI enablement interlock Load config/ai.yaml by default, emit no jobs while classification is disabled, and reject attempted AI artifact publication in the provenance gate. Document the explicit opt-in and cover the behavior with CLI and config tests. --- config/ai.example.yaml | 5 ++- docs/P3.2-executor-runbook.md | 6 +++ packages/classifier/src/cli.ts | 18 +++++++- packages/classifier/src/config.ts | 17 ++++++- packages/classifier/tests/cli.test.ts | 56 ++++++++++++++++++++++++ packages/classifier/tests/config.test.ts | 37 ++++++++++++++-- 6 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 packages/classifier/tests/cli.test.ts diff --git a/config/ai.example.yaml b/config/ai.example.yaml index 9296564..451fd3d 100644 --- a/config/ai.example.yaml +++ b/config/ai.example.yaml @@ -5,8 +5,9 @@ # untrusted candidate producers. Their model label is informational only. ai: - # Master switch for future planner/executor runs. The deterministic validation - # and artifact commands remain safe to run while this is false. + # Operational interlock. While false, the planner writes an empty manifest and + # the provenance gate rejects attempted artifact publication. Deterministic + # validation commands remain safe to run. enabled: false # The manifest is bound to exactly one executor. Do not schedule Claude and diff --git a/docs/P3.2-executor-runbook.md b/docs/P3.2-executor-runbook.md index 80e1592..085d4a0 100644 --- a/docs/P3.2-executor-runbook.md +++ b/docs/P3.2-executor-runbook.md @@ -24,6 +24,12 @@ CLI are already on `main`. Then open a source-only PR, confirm the green `verify-agent-artifacts` as a required `main` status check. Only then enable one executor to open `claude/*` or `codex/*` artifact PRs. +Create and commit `config/ai.yaml` from `config/ai.example.yaml` before the first +executor run. It contains no secret. Set `ai.enabled: true`, select exactly one +`executor_kind`, and start with a small valid budget. Without that explicit opt-in +the planner writes an empty manifest and the provenance gate rejects any artifact +PR. + ## Deterministic command sequence Run with a GitHub token in `STAR_SYNC_TOKEN` or `GITHUB_TOKEN` (README discovery): diff --git a/packages/classifier/src/cli.ts b/packages/classifier/src/cli.ts index 4b89ed5..5f16c76 100644 --- a/packages/classifier/src/cli.ts +++ b/packages/classifier/src/cli.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { AiAnnotationsSchema, + buildClassificationManifest, ClassificationCandidatesSchema, ClassificationManifestSchema, serializeClassificationManifest, @@ -11,7 +12,7 @@ import { createGithubClient } from '@starred/github-client'; import { Command } from 'commander'; import { verifyAgentPullRequestFromGit } from './agent-gate'; import { assembleAiArtifacts, verifyAiArtifacts } from './assemble'; -import { loadAiConfig } from './config'; +import { assertAiClassificationEnabled, loadAiConfig } from './config'; import { loadCanonicalDataset } from './dataset'; import { reconcileRun } from './executor'; import { planClassification } from './planner'; @@ -95,6 +96,20 @@ program readFileSync(opts.stars, 'utf8'), readFileSync(opts.meta, 'utf8'), ); + if (!config.ai.enabled) { + const manifest = buildClassificationManifest({ + promptVersion: config.ai.prompt_version, + executionProfileVersion: config.ai.execution_profile.execution_profile_version, + executorKind: config.ai.executor_kind, + datasetSha256: dataset.datasetSha256, + jobs: [], + }); + writeText(opts.out, serializeClassificationManifest(manifest)); + process.stdout.write( + `AI classification disabled; wrote an empty manifest without README discovery: ${opts.out}\n`, + ); + return; + } const existingAnnotations = opts.current !== undefined && existsSync(opts.current) ? AiAnnotationsSchema.parse(readJson(opts.current)).annotations @@ -300,6 +315,7 @@ program return; } const config = loadAiConfig(program.opts<{ config?: string }>().config).ai; + assertAiClassificationEnabled(config); const token = process.env.STAR_SYNC_TOKEN ?? process.env.GITHUB_TOKEN; if (token === undefined || token === '') { throw new Error( diff --git a/packages/classifier/src/config.ts b/packages/classifier/src/config.ts index 64386bd..8f2e82d 100644 --- a/packages/classifier/src/config.ts +++ b/packages/classifier/src/config.ts @@ -9,6 +9,8 @@ import { import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; +export const DEFAULT_AI_CONFIG_PATH = 'config/ai.yaml'; + /** * Versioned agent-executor configuration. This contract intentionally contains * no API key, provider, model, or timeout: Claude Routines and Codex @@ -47,8 +49,8 @@ export const AiConfigSchema = z export type AiConfig = z.infer; -export function loadAiConfig(path?: string): AiConfig { - if (path !== undefined && existsSync(path)) { +export function loadAiConfig(path = DEFAULT_AI_CONFIG_PATH): AiConfig { + if (existsSync(path)) { const raw: unknown = parseYaml(readFileSync(path, 'utf8')) ?? {}; return AiConfigSchema.parse(raw); } @@ -62,3 +64,14 @@ export function loadAiConfig(path?: string): AiConfig { }, }); } + +/** + * `enabled` is an operational interlock, not an informational preference. The + * planner emits no jobs while disabled and the provenance gate rejects any + * attempted artifact publication. + */ +export function assertAiClassificationEnabled(config: AiConfig['ai']): void { + if (!config.enabled) { + throw new Error('AI classification is disabled; set ai.enabled=true in config/ai.yaml'); + } +} diff --git a/packages/classifier/tests/cli.test.ts b/packages/classifier/tests/cli.test.ts new file mode 100644 index 0000000..8b7a66c --- /dev/null +++ b/packages/classifier/tests/cli.test.ts @@ -0,0 +1,56 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { makeDataset, repo } from './helpers'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +describe('classifier CLI enablement', () => { + it('ENABLE-1: disabled classification writes an empty trusted manifest without GitHub access', () => { + const dir = mkdtempSync(join(tmpdir(), 'starledger-classifier-cli-')); + try { + const { starsText, metaText, datasetSha256 } = makeDataset([repo('one')]); + const stars = join(dir, 'stars.json'); + const meta = join(dir, 'dataset-meta.json'); + const config = join(dir, 'ai.yaml'); + const out = join(dir, 'manifest.json'); + writeFileSync(stars, starsText, 'utf8'); + writeFileSync(meta, metaText, 'utf8'); + writeFileSync(config, 'ai:\n enabled: false\n', 'utf8'); + + const stdout = execFileSync( + process.execPath, + [ + '--import', + 'tsx', + 'packages/classifier/src/cli.ts', + '--config', + config, + 'plan', + '--stars', + stars, + '--meta', + meta, + '--out', + out, + ], + { + cwd: root, + encoding: 'utf8', + env: { ...process.env, STAR_SYNC_TOKEN: '', GITHUB_TOKEN: '' }, + }, + ); + + expect(stdout).toContain('AI classification disabled'); + expect(JSON.parse(readFileSync(out, 'utf8'))).toMatchObject({ + dataset_sha256: datasetSha256, + jobs: [], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/classifier/tests/config.test.ts b/packages/classifier/tests/config.test.ts index b44e18b..b82902e 100644 --- a/packages/classifier/tests/config.test.ts +++ b/packages/classifier/tests/config.test.ts @@ -1,5 +1,13 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { AiConfigSchema, loadAiConfig } from '../src/config'; +import { + AiConfigSchema, + assertAiClassificationEnabled, + DEFAULT_AI_CONFIG_PATH, + loadAiConfig, +} from '../src/config'; describe('AiConfigSchema', () => { it('applies the documented defaults', () => { @@ -23,8 +31,31 @@ describe('AiConfigSchema', () => { ); }); - it('loads defaults when no config path is given', () => { - expect(loadAiConfig().ai.enabled).toBe(false); + it('loads defaults when the default config file is absent', () => { + expect(loadAiConfig(join(tmpdir(), 'starledger-no-ai-config.yaml')).ai.enabled).toBe(false); + }); + + it('loads config/ai.yaml by default when it exists', () => { + const cwd = process.cwd(); + const root = mkdtempSync(join(tmpdir(), 'starledger-ai-config-')); + try { + mkdirSync(join(root, 'config')); + writeFileSync(join(root, DEFAULT_AI_CONFIG_PATH), 'ai:\n enabled: true\n', 'utf8'); + process.chdir(root); + expect(loadAiConfig().ai.enabled).toBe(true); + } finally { + process.chdir(cwd); + rmSync(root, { recursive: true, force: true }); + } + }); + + it('treats enabled as an operational interlock', () => { + expect(() => assertAiClassificationEnabled(AiConfigSchema.parse({}).ai)).toThrow( + 'AI classification is disabled', + ); + expect(() => + assertAiClassificationEnabled(AiConfigSchema.parse({ ai: { enabled: true } }).ai), + ).not.toThrow(); }); it('rejects API-provider configuration; P3.0 uses executor-neutral contracts', () => {