feat: 移除 Codex Fast mode 与 AutoReview 后台分析,模型改为配置驱动

移除 pi-extension-codex-fast-mode:
- 删除整个扩展目录;同步清理根 package.json 的 files / pi.extensions /
  dependencies 条目、package-lock.json、tests/extension-load.test.ts 断言
  与文档章节。
- pi-minimal-footer 的 Fast on 渲染是该扩展的唯一消费者。扩展移除后 accent
  分支不再有生产者,collectFooterStatusItems 简化为直接返回非空状态字符串,
  去掉 FooterStatusItem 与 accent 标志,避免留下死代码。

移除 500 次裁决后的后台分析体系:
- 删除 analysis-agent.ts(192 行)与 ledger.ts(296 行),以及对应的
  auto-review-runtime / auto-review-ledger 测试。
- runtime.ts 由 177 行收敛到 85 行:安装选项缩为 { service, config };删除
  handleAgentSettled 与 agent_settled 注册;保留 session_start / turn_start
  (denial circuit breaker 属安全语义)/ session_shutdown。
- 删除 analysis 配置键:config、extension-config、config-schema、生成的
  permissions.schema.json 与种子配置。
- 删除 reviewer 的 onDecision 回调(唯一消费者是 ledger)。
- 删除迁移机制 scripts/migrate-permission-policy.mjs、其测试与 update.sh 中
  46 行 migrate_permission_policy 接线。种子部署保持“目标缺失才写入”,已存在
  的 policy.jsonc 永不被覆盖。

AutoReview 模型改为纯配置驱动:
- 删除 DEFAULT_PROVIDER / DEFAULT_MODEL 常量与 findCodexTemplate 合成逻辑;
  model.ts 由 65 行收敛到 48 行。
- provider / model 任一缺失时返回新增失败类别 model-unconfigured 并 defer 到
  人工审批;provider 未注册或模型未发布同样 defer。
- reasoning 不再有默认常量,完全由配置决定。
- session_start 在 auto-review 已列入 authorizerChain 且 provider/model 未配齐
  时 ui.notify 提示一次,避免不使用 AutoReview 的用户被打扰。
- 种子 autoReview 只保留 enabled,provider/model/reasoning 由用户按本机实际
  可用的模型填写。

破坏性变更:analysis 键已从 strictObject schema 删除,任何仍带该键的
policy.jsonc 会使整份配置被拒(fail-closed 到全 ask)。这是刻意的变更,已由
config-loader 测试固定,防止将来无声放宽。

测试:
- 新增 auto-review-model.test.ts(7 例),覆盖此前完全缺失的模型解析:真实
  命中、未配置、provider 未知、模型未发布,以及不再合成替代模型。
- 重写 auto-review-runtime.test.ts(6 例):authorizer 注册、未配置时提示
  一次、已配置 / disabled / 无 UI / 不在 chain 时静默。
- config-loader.test.ts 新增 2 例:带 analysis 的旧配置被拒且 fail-closed、
  autoReview 逐字段合并。
- composition-root.test.ts 的 EXPECTED_HANDLERS 移除 agent_settled。

文档:AGENTS.md、README.md、pi-permission-system 的 README / CHANGELOG /
configuration.md / architecture.md / auto-review UPSTREAM.md。

验证:pi-permission-system 的 typecheck、2577 个测试与 build 全部通过;根 50
个测试通过;schema 重新生成无漂移;新种子通过 schema 校验;5 个脚本 sh -n
通过;git diff --check 无空白问题。未安装 shellcheck;未执行真实 pi install /
update(属外部写操作)。
This commit is contained in:
云服务部-叶林立
2026-10-14 08:15:13 -07:00
parent 73f3117a7e
commit cdd91b3f8b
45 changed files with 325 additions and 2357 deletions
@@ -1,192 +0,0 @@
import {
createAgentSession,
DefaultResourceLoader,
SessionManager,
SettingsManager,
type ModelRegistry,
} from "@earendil-works/pi-coding-agent";
import { z } from "zod";
import type { AutoReviewAnalysisConfig, AutoReviewConfig } from "./config.js";
import type { AutoReviewAnalysisBatch, AutoReviewLedgerRecord } from "./ledger.js";
import { resolveReviewModel } from "./model.js";
const candidateSchema = z.strictObject({
surface: z.string().min(1).max(100),
toolName: z.string().min(1).max(100).optional(),
pattern: z.string().min(1).max(240),
reason: z.string().min(1).max(1_000),
evidenceCount: z.number().int().positive(),
});
const proposalSchema = z.strictObject({
summary: z.string().min(1).max(4_000),
allowCandidates: z.array(candidateSchema).max(50),
denyCandidates: z.array(candidateSchema).max(50),
keepReview: z.array(candidateSchema).max(100),
hardGuardCandidates: z.array(candidateSchema).max(25),
});
export type AutoReviewAnalysisProposal = z.infer<typeof proposalSchema>;
interface Aggregate {
surface: string;
toolName?: string;
pattern: string;
total: number;
allow: number;
deny: number;
authorizationDependent: number;
low: number;
medium: number;
high: number;
critical: number;
}
function aggregateRecords(records: AutoReviewLedgerRecord[]): Aggregate[] {
const groups = new Map<string, Aggregate>();
for (const record of records) {
const key = record.fingerprint;
const current = groups.get(key) ?? {
surface: record.surface,
...(record.toolName ? { toolName: record.toolName } : {}),
pattern: record.normalizedPattern,
total: 0,
allow: 0,
deny: 0,
authorizationDependent: 0,
low: 0,
medium: 0,
high: 0,
critical: 0,
};
current.total += 1;
current[record.outcome] += 1;
current[record.riskLevel] += 1;
if (record.authorizationDependent) current.authorizationDependent += 1;
groups.set(key, current);
}
return [...groups.values()]
.sort((left, right) => right.total - left.total)
.slice(0, 250);
}
function parseJsonObject(text: string): unknown {
try { return JSON.parse(text); }
catch {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start < 0 || end <= start) throw new Error("analysis response was not JSON");
return JSON.parse(text.slice(start, end + 1));
}
}
function assistantText(messages: readonly unknown[]): string {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index] as { role?: string; content?: unknown };
if (message?.role !== "assistant") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.flatMap((block) => {
if (typeof block === "string") return [block];
if (typeof block === "object" && block !== null && "text" in block) {
const text = (block as { text?: unknown }).text;
return typeof text === "string" ? [text] : [];
}
return [];
})
.join("\n");
}
}
throw new Error("analysis agent returned no assistant text");
}
function keyOf(value: { surface: string; toolName?: string; pattern: string }): string {
return `${value.surface}\u0000${value.toolName ?? ""}\u0000${value.pattern}`;
}
function validateProposal(
proposal: AutoReviewAnalysisProposal,
aggregates: Aggregate[],
): AutoReviewAnalysisProposal {
const evidence = new Map(aggregates.map((item) => [keyOf(item), item]));
const validateList = (
values: AutoReviewAnalysisProposal["allowCandidates"],
kind: "allow" | "deny" | "review" | "hard",
) => values.filter((candidate) => {
const item = evidence.get(keyOf(candidate));
if (!item || candidate.evidenceCount > item.total) return false;
if (kind === "allow") {
return item.deny === 0 && item.authorizationDependent === 0 && item.high === 0 && item.critical === 0;
}
if (kind === "deny") return item.allow === 0;
if (kind === "hard") return item.allow === 0 && item.high + item.critical > 0;
return true;
});
return {
...proposal,
allowCandidates: validateList(proposal.allowCandidates, "allow"),
denyCandidates: validateList(proposal.denyCandidates, "deny"),
keepReview: validateList(proposal.keepReview, "review"),
hardGuardCandidates: validateList(proposal.hardGuardCandidates, "hard"),
};
}
export async function runAutoReviewAnalysisAgent(options: {
batch: AutoReviewAnalysisBatch;
registry: Pick<ModelRegistry, "find" | "getAll" | "getApiKeyAndHeaders" | "getProvider">;
autoReview: AutoReviewConfig;
analysis: AutoReviewAnalysisConfig;
stateDir: string;
agentDir: string;
signal?: AbortSignal;
}): Promise<AutoReviewAnalysisProposal> {
const resolved = resolveReviewModel(options.registry, options.autoReview);
if (!resolved.ok) throw new Error(`analysis model unavailable: ${resolved.category}`);
const aggregates = aggregateRecords(options.batch.records);
const systemPrompt = `You are a read-only permission-policy analyst. Analyze aggregated automatic-review decisions and return one JSON object only. Never suggest an unconditional allow when authorizationDependent is non-zero, any denial exists, or high/critical risk exists. Do not modify files or execute tools. Candidate keys must exactly copy surface, toolName (when present), and pattern from the evidence. The schema is: {summary:string,allowCandidates:Candidate[],denyCandidates:Candidate[],keepReview:Candidate[],hardGuardCandidates:Candidate[]}; Candidate is {surface:string,toolName?:string,pattern:string,reason:string,evidenceCount:integer}.`;
const loader = new DefaultResourceLoader({
cwd: options.stateDir,
agentDir: options.agentDir,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
systemPrompt,
appendSystemPrompt: [],
});
await loader.reload();
const { session } = await createAgentSession({
cwd: options.stateDir,
agentDir: options.agentDir,
model: resolved.value.model,
thinkingLevel: options.autoReview.reasoning,
noTools: "all",
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
settingsManager: SettingsManager.inMemory(),
});
const timeout = setTimeout(() => session.abort(), options.analysis.timeoutMs);
const abort = () => session.abort();
options.signal?.addEventListener("abort", abort, { once: true });
try {
await session.prompt(JSON.stringify({
batch: {
fromSequence: options.batch.fromSequence,
toSequence: options.batch.toSequence,
policyHash: options.batch.policyHash,
},
aggregates,
}));
const parsed = proposalSchema.parse(parseJsonObject(assistantText(session.state.messages)));
return validateProposal(parsed, aggregates);
} finally {
clearTimeout(timeout);
options.signal?.removeEventListener("abort", abort);
session.dispose();
}
}
+11 -41
View File
@@ -1,10 +1,7 @@
import { z } from "zod";
export const DEFAULT_PROVIDER = "openai-codex";
export const DEFAULT_MODEL = "codex-auto-review";
/** Total retry budget for one review call. */
export const DEFAULT_TIMEOUT_MS = 90_000;
export const DEFAULT_ANALYSIS_THRESHOLD = 500;
export const DEFAULT_RETAIN_ANALYZED_BATCHES = 2;
export const REASONING_LEVELS = [
"off",
@@ -36,49 +33,31 @@ export const autoReviewConfigFileSchema = z
}
});
export const analysisConfigFileSchema = z.strictObject({
enabled: z.boolean().optional(),
threshold: z.number().int().min(1).max(100_000).optional(),
retainAnalyzedBatches: z.number().int().min(0).max(20).optional(),
timeoutMs: z.number().int().positive().max(300_000).optional(),
});
export type AutoReviewConfigFile = z.infer<typeof autoReviewConfigFileSchema>;
export type AutoReviewAnalysisConfigFile = z.infer<typeof analysisConfigFileSchema>;
/**
* Effective reviewer configuration.
*
* `provider` and `model` have no built-in default and no synthesized fallback:
* the review model is whatever the operator names in the config file. When
* either is absent the reviewer cannot run and defers to the human prompt.
*/
export interface AutoReviewConfig {
enabled: boolean;
provider: string;
model: string;
reasoning: (typeof REASONING_LEVELS)[number];
provider?: string;
model?: string;
reasoning?: (typeof REASONING_LEVELS)[number];
timeoutMs: number;
includeBaselinePolicy: boolean;
additionalPolicy?: string;
}
export interface AutoReviewAnalysisConfig {
enabled: boolean;
threshold: number;
retainAnalyzedBatches: number;
timeoutMs: number;
}
export const DEFAULT_AUTO_REVIEW_CONFIG: AutoReviewConfig = {
enabled: true,
provider: DEFAULT_PROVIDER,
model: DEFAULT_MODEL,
reasoning: "low",
timeoutMs: DEFAULT_TIMEOUT_MS,
includeBaselinePolicy: true,
};
export const DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG: AutoReviewAnalysisConfig = {
enabled: true,
threshold: DEFAULT_ANALYSIS_THRESHOLD,
retainAnalyzedBatches: DEFAULT_RETAIN_ANALYZED_BATCHES,
timeoutMs: DEFAULT_TIMEOUT_MS,
};
export function normalizeAutoReviewConfig(
value?: AutoReviewConfigFile,
): AutoReviewConfig {
@@ -87,12 +66,3 @@ export function normalizeAutoReviewConfig(
...value,
};
}
export function normalizeAutoReviewAnalysisConfig(
value?: AutoReviewAnalysisConfigFile,
): AutoReviewAnalysisConfig {
return {
...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG,
...value,
};
}
@@ -1,296 +0,0 @@
import { createHash } from "node:crypto";
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type { PromptPermissionDetails } from "../service.js";
import type { AutoReviewAnalysisConfig, AutoReviewConfig } from "./config.js";
import type { ReviewAssessment } from "./verdict.js";
const DIRECTORY_MODE = 0o700;
const FILE_MODE = 0o600;
const MAX_PATTERN_LENGTH = 240;
interface LedgerState {
version: 1;
completedSequence: number;
lastAnalyzedSequence: number;
proposalSequence: number;
analysisPending: boolean;
}
export interface AutoReviewLedgerRecord {
version: 1;
sequence: number;
timestamp: string;
requestId: string;
source: string;
agentName: string;
surface: string;
toolName?: string;
normalizedPattern: string;
fingerprint: string;
outcome: "allow" | "deny";
riskLevel: ReviewAssessment["riskLevel"];
userAuthorization: ReviewAssessment["userAuthorization"];
authorizationDependent: boolean;
policyRevision: string;
policyHash: string;
provider: string;
model: string;
}
export interface AutoReviewAnalysisBatch {
fromSequence: number;
toSequence: number;
records: AutoReviewLedgerRecord[];
policyHash: string;
}
export interface StoredAnalysisProposal {
version: 1;
proposalId: string;
createdAt: string;
fromSequence: number;
toSequence: number;
policyHash: string;
proposal: unknown;
}
function initialState(): LedgerState {
return {
version: 1,
completedSequence: 0,
lastAnalyzedSequence: 0,
proposalSequence: 0,
analysisPending: false,
};
}
function hash(value: unknown): string {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function bounded(value: string): string {
return value.length <= MAX_PATTERN_LENGTH
? value
: `${value.slice(0, MAX_PATTERN_LENGTH - 1)}…`;
}
const SAFE_SUBCOMMAND_COMMANDS = new Set([
"bun", "cargo", "docker", "git", "go", "kubectl", "npm", "pnpm",
"sudo", "systemctl", "yarn",
]);
const SAFE_PATH_PREFIXES = new Set([
"app", "apps", "config", "docs", "etc", "lib", "opt", "packages",
"private", "src", "test", "tests", "tmp", "Users", "var", "workspace",
"workspaces",
]);
function normalizedExecutable(value: string): string {
const executable = value.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? "";
return /^[A-Za-z0-9._+-]{1,64}$/u.test(executable) ? executable : "<command>";
}
function normalizeCommand(command: string): string {
const words = command.trim().replace(/\s+/g, " ").split(" ").filter(Boolean);
if (words.length === 0) return "<empty-command>";
const executable = normalizedExecutable(words[0]!);
const subcommand = words[1];
if (
subcommand !== undefined &&
SAFE_SUBCOMMAND_COMMANDS.has(executable) &&
/^[A-Za-z0-9._+-]{1,64}$/u.test(subcommand)
) {
return bounded(`${executable} ${subcommand} *`);
}
return bounded(words.length === 1 ? executable : `${executable} *`);
}
function normalizePath(value: string): string {
const normalized = value.trim().replace(/\\/g, "/");
if (normalized.length === 0) return "<empty-path>";
if (normalized === "~" || normalized.startsWith("~/")) return "~/*";
const absolute = normalized.startsWith("/");
const segments = normalized.split("/").filter(Boolean);
const first = segments[0];
if (first && SAFE_PATH_PREFIXES.has(first)) {
return bounded(`${absolute ? "/" : ""}${first}/*`);
}
if (!absolute && segments.length === 1) {
const extension = normalized.match(/\.[A-Za-z0-9]{1,10}$/u)?.[0];
if (extension) return `*${extension.toLowerCase()}`;
}
return absolute ? "/<path>/*" : "<relative>/*";
}
function derivePattern(details: PromptPermissionDetails): string {
if (typeof details.command === "string") return normalizeCommand(details.command);
if (typeof details.path === "string") return normalizePath(details.path);
const values = details.accessIntent?.matchValues;
if (Array.isArray(values) && typeof values[0] === "string") return normalizePath(values[0]);
if (details.toolName) return bounded(details.toolName);
return bounded(details.surface ?? "unknown");
}
function readJson<T>(path: string): T | undefined {
if (!existsSync(path)) return undefined;
try {
return JSON.parse(readFileSync(path, "utf8")) as T;
} catch {
return undefined;
}
}
export class AutoReviewLedger {
private readonly ledgerDir: string;
private readonly proposalsDir: string;
private readonly statePath: string;
private readonly decisionsPath: string;
private state: LedgerState;
constructor(rootDir: string) {
this.ledgerDir = join(rootDir, "ledger");
this.proposalsDir = join(rootDir, "proposals");
this.statePath = join(rootDir, "state.json");
this.decisionsPath = join(this.ledgerDir, "decisions.jsonl");
for (const dir of [rootDir, this.ledgerDir, this.proposalsDir]) {
mkdirSync(dir, { recursive: true, mode: DIRECTORY_MODE });
try { chmodSync(dir, DIRECTORY_MODE); } catch { /* best effort on non-POSIX */ }
}
this.state = readJson<LedgerState>(this.statePath) ?? initialState();
this.persistState();
}
recordDecision(event: {
details: PromptPermissionDetails;
assessment: ReviewAssessment;
policyRevision: string;
config: AutoReviewConfig;
analysis: AutoReviewAnalysisConfig;
}): AutoReviewLedgerRecord {
const sequence = this.state.completedSequence + 1;
const pattern = derivePattern(event.details);
const surface = bounded(event.details.accessIntent?.surface ?? event.details.surface ?? "unknown");
const toolName = event.details.toolName ? bounded(event.details.toolName) : undefined;
const record: AutoReviewLedgerRecord = {
version: 1,
sequence,
timestamp: new Date().toISOString(),
requestId: bounded(event.details.requestId),
source: bounded(event.details.source),
agentName: bounded(event.details.agentName ?? "main"),
surface,
...(toolName ? { toolName } : {}),
normalizedPattern: pattern,
fingerprint: hash({ surface, toolName, pattern }),
outcome: event.assessment.outcome,
riskLevel: event.assessment.riskLevel,
userAuthorization: event.assessment.userAuthorization,
authorizationDependent: event.assessment.userAuthorization !== "unknown",
policyRevision: bounded(event.policyRevision),
policyHash: hash(event.config),
provider: bounded(event.config.provider),
model: bounded(event.config.model),
};
appendFileSync(this.decisionsPath, `${JSON.stringify(record)}\n`, {
encoding: "utf8",
mode: FILE_MODE,
});
try { chmodSync(this.decisionsPath, FILE_MODE); } catch { /* best effort */ }
this.state.completedSequence = sequence;
if (
event.analysis.enabled &&
sequence - this.state.lastAnalyzedSequence >= event.analysis.threshold
) {
this.state.analysisPending = true;
}
this.persistState();
return record;
}
hasPendingAnalysis(): boolean {
return this.state.analysisPending;
}
prepareBatch(config: AutoReviewAnalysisConfig): AutoReviewAnalysisBatch | undefined {
const records = this.readRecords();
const fromSequence = this.state.lastAnalyzedSequence + 1;
const eligible = records.filter((record) => record.sequence >= fromSequence);
if (eligible.length < config.threshold) return undefined;
const selected = eligible.slice(0, config.threshold);
const policyHash = selected[0]?.policyHash ?? "";
return {
fromSequence,
toSequence: selected.at(-1)!.sequence,
records: selected,
policyHash,
};
}
saveProposal(batch: AutoReviewAnalysisBatch, proposal: unknown): StoredAnalysisProposal {
const next = this.state.proposalSequence + 1;
const proposalId = `proposal-${String(next).padStart(6, "0")}`;
const stored: StoredAnalysisProposal = {
version: 1,
proposalId,
createdAt: new Date().toISOString(),
fromSequence: batch.fromSequence,
toSequence: batch.toSequence,
policyHash: batch.policyHash,
proposal,
};
this.writeAtomic(join(this.proposalsDir, `${proposalId}.json`), stored);
this.state.proposalSequence = next;
this.state.lastAnalyzedSequence = batch.toSequence;
this.state.analysisPending =
this.state.completedSequence - this.state.lastAnalyzedSequence > 0;
this.persistState();
return stored;
}
compact(config: AutoReviewAnalysisConfig): void {
const keep = Math.max(config.threshold, config.threshold * config.retainAnalyzedBatches);
const records = this.readRecords();
const retained = records.slice(-keep);
const content = retained.map((record) => JSON.stringify(record)).join("\n");
this.writeAtomic(this.decisionsPath, content ? `${content}\n` : "");
}
private readRecords(): AutoReviewLedgerRecord[] {
if (!existsSync(this.decisionsPath)) return [];
return readFileSync(this.decisionsPath, "utf8")
.split("\n")
.filter(Boolean)
.flatMap((line) => {
try { return [JSON.parse(line) as AutoReviewLedgerRecord]; }
catch { return []; }
})
.sort((left, right) => left.sequence - right.sequence);
}
private persistState(): void {
this.writeAtomic(this.statePath, this.state);
}
private writeAtomic(path: string, value: unknown): void {
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
const content = typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`;
writeFileSync(temporaryPath, content, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
try {
renameSync(temporaryPath, path);
try { chmodSync(path, FILE_MODE); } catch { /* best effort */ }
} catch (error) {
try { unlinkSync(temporaryPath); } catch { /* best effort */ }
throw error;
}
}
}
+26 -43
View File
@@ -1,65 +1,48 @@
import type { AutoReviewConfig } from './config.js'
import type { Api, Model, Provider } from '@earendil-works/pi-ai'
import type { ModelRegistry } from '@earendil-works/pi-coding-agent'
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from './config.js'
export type ReviewModelRegistry = Pick<ModelRegistry, 'find' | 'getAll' | 'getApiKeyAndHeaders' | 'getProvider'>
interface ResolvedReviewModel {
export interface ResolvedReviewModel {
model: Model<Api>
provider: Provider<Api>
synthesized: boolean
}
export type ResolveReviewModelFailure =
| 'model-unconfigured'
| 'provider-unresolved'
| 'model-unresolved'
export type ResolveReviewModelResult =
| { ok: true; value: ResolvedReviewModel }
| {
ok: false
category: 'provider-unresolved' | 'model-unresolved'
}
function findCodexTemplate(registry: ReviewModelRegistry, provider: Provider<Api>): Model<Api> | undefined {
return (
registry.getAll().find(model => model.provider === DEFAULT_PROVIDER && model.api === 'openai-codex-responses') ??
provider.getModels().find(model => model.api === 'openai-codex-responses')
)
}
| { ok: false; category: ResolveReviewModelFailure }
/**
* Resolve the reviewer model from operator configuration alone.
*
* There is no built-in provider/model pair and no synthesized stand-in: the
* reviewer runs exactly the model named in `autoReview.provider` /
* `autoReview.model`. A configuration that names neither, names an unknown
* provider, or names a model the provider does not publish resolves to a
* failure, and the reviewer defers the ask to the human prompt.
*/
export function resolveReviewModel(registry: ReviewModelRegistry, config: AutoReviewConfig): ResolveReviewModelResult {
const provider = registry.getProvider(config.provider)
const providerId = config.provider
const modelId = config.model
if (providerId === undefined || modelId === undefined) {
return { ok: false, category: 'model-unconfigured' }
}
const provider = registry.getProvider(providerId)
if (provider === undefined) {
return { ok: false, category: 'provider-unresolved' }
}
const registeredModel = registry.find(config.provider, config.model)
if (registeredModel !== undefined) {
return {
ok: true,
value: { model: registeredModel, provider, synthesized: false },
}
}
if (config.provider !== DEFAULT_PROVIDER || config.model !== DEFAULT_MODEL) {
const model = registry.find(providerId, modelId)
if (model === undefined) {
return { ok: false, category: 'model-unresolved' }
}
const template = findCodexTemplate(registry, provider)
if (template === undefined) {
return { ok: false, category: 'model-unresolved' }
}
return {
ok: true,
value: {
model: {
...template,
id: DEFAULT_MODEL,
name: 'Codex Auto Review',
reasoning: true,
input: ['text'],
},
provider,
synthesized: true,
},
}
return { ok: true, value: { model, provider } }
}
@@ -20,6 +20,7 @@ const FAILURE_EVENT = 'auto_review.failure'
const CIRCUIT_OPEN_EVENT = 'auto_review.circuit_open'
type FailureCategory =
| 'model-unconfigured'
| 'provider-unresolved'
| 'model-unresolved'
| 'auth-unresolved'
@@ -35,12 +36,6 @@ export interface ReviewerRuntime {
sessionManager: Pick<SessionManager, 'getBranch'>
circuitBreaker: DenialCircuitBreaker
sessionSignal?: AbortSignal
onDecision?: (event: {
details: PromptPermissionDetails
assessment: ReviewAssessment
durationMs: number
policyRevision: string
}) => void
}
export interface ReviewerDependencies {
@@ -154,7 +149,7 @@ function buildStreamOptions(
if (auth.env !== undefined) {
options.env = auth.env
}
if (reasoning && runtime.config.reasoning !== 'off') {
if (reasoning && runtime.config.reasoning !== undefined && runtime.config.reasoning !== 'off') {
options.reasoning = runtime.config.reasoning
}
return options
@@ -358,16 +353,6 @@ export function createPermissionReviewer(
durationMs,
...contextDiagnostics,
})
try {
runtime.onDecision?.({
details,
assessment,
durationMs,
policyRevision: POLICY_REVISION,
})
} catch {
// Audit/analysis persistence must never change the permission verdict.
}
if (assessment.outcome === 'allow') {
runtime.circuitBreaker.recordNonDenial()
+22 -114
View File
@@ -1,23 +1,15 @@
import type {
ExtensionAPI,
ExtensionContext,
ModelRegistry,
SessionManager,
} from "@earendil-works/pi-coding-agent";
import type { ConfigReader } from "../config-store";
import { getAnalysisStateDir } from "../config-paths";
import type { PermissionsService } from "../service";
import type { ReviewLogger } from "../session-logger";
import { runAutoReviewAnalysisAgent } from "./analysis-agent.js";
import {
DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG,
DEFAULT_AUTO_REVIEW_CONFIG,
} from "./config.js";
import { DEFAULT_AUTO_REVIEW_CONFIG } from "./config.js";
import { DenialCircuitBreaker } from "./circuit-breaker.js";
import { AutoReviewLedger } from "./ledger.js";
import { createPermissionReviewer } from "./reviewer.js";
const AUTHORIZER_NAME = "auto-review";
const ANALYSIS_RETRY_BACKOFF_MS = 60_000;
interface SessionRuntime {
registry: ModelRegistry;
@@ -26,40 +18,38 @@ interface SessionRuntime {
}
export interface AutoReviewLifecycleController {
handleSessionStart(context: {
modelRegistry: ModelRegistry;
sessionManager: Pick<SessionManager, "getBranch">;
}): void;
handleSessionStart(ctx: ExtensionContext): void;
handleTurnStart(): void;
handleAgentSettled(): Promise<void>;
handleSessionShutdown(): void;
}
/** Warn once per session when AutoReview is active but names no usable model. */
function warnWhenUnconfigured(config: ConfigReader, ctx: ExtensionContext): void {
const active = config.current();
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
if (!autoReview.enabled) return;
if (!(active.authorizerChain ?? []).includes(AUTHORIZER_NAME)) return;
if (autoReview.provider !== undefined && autoReview.model !== undefined) return;
if (!ctx.hasUI) return;
ctx.ui.notify(
"AutoReview is enabled but autoReview.provider / autoReview.model are not both set, so automatic review cannot run. Every ask will go to the human permission prompt. Set both in the global policy.jsonc to enable automatic review.",
"warning",
);
}
export function installBuiltinAutoReview(options: {
pi: ExtensionAPI;
service: PermissionsService;
config: ConfigReader;
logger: ReviewLogger;
agentDir: string;
/** Test seam; production always uses the isolated AgentSession implementation. */
runAnalysis?: typeof runAutoReviewAnalysisAgent;
}): AutoReviewLifecycleController {
const { pi, service, config, logger, agentDir } = options;
const analyze = options.runAnalysis ?? runAutoReviewAnalysisAgent;
const { service, config } = options;
const circuitBreaker = new DenialCircuitBreaker();
const stateDir = getAnalysisStateDir(agentDir);
const ledger = new AutoReviewLedger(stateDir);
let runtime: SessionRuntime | undefined;
let analysisRunning = false;
let analysisGeneration = 0;
let nextAnalysisAttemptAt = 0;
service.registerAuthorizer(
AUTHORIZER_NAME,
async (details, query, log) => {
const active = config.current();
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
const analysis = active.analysis ?? DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG;
if (!autoReview.enabled || runtime === undefined) return { kind: "defer" };
const reviewer = createPermissionReviewer({
config: autoReview,
@@ -67,110 +57,28 @@ export function installBuiltinAutoReview(options: {
sessionManager: runtime.sessionManager,
circuitBreaker,
sessionSignal: runtime.controller.signal,
onDecision: (event) => {
ledger.recordDecision({
details: event.details,
assessment: event.assessment,
policyRevision: event.policyRevision,
config: autoReview,
analysis,
});
},
});
return reviewer(details, query, log);
},
);
const runPendingAnalysis = async (): Promise<void> => {
if (analysisRunning || runtime === undefined || !ledger.hasPendingAnalysis()) return;
if (Date.now() < nextAnalysisAttemptAt) return;
const active = config.current();
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
const analysis = active.analysis ?? DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG;
if (!analysis.enabled) return;
const batch = ledger.prepareBatch(analysis);
if (!batch) return;
const activeRuntime = runtime;
const generation = ++analysisGeneration;
const isCurrent = (): boolean =>
runtime === activeRuntime &&
analysisGeneration === generation &&
!activeRuntime.controller.signal.aborted;
analysisRunning = true;
try {
const proposal = await analyze({
batch,
registry: activeRuntime.registry,
autoReview,
analysis,
stateDir,
agentDir,
signal: activeRuntime.controller.signal,
});
if (!isCurrent()) return;
pi.sendMessage(
{
customType: "permission-policy-analysis-ready",
content: [
`Permission policy analysis is ready for automatic review decisions ${batch.fromSequence}-${batch.toSequence}.`,
"Present these suggestions to the user in this conversation. Do not edit either policy file until the user explicitly selects and confirms rules.",
JSON.stringify(proposal),
].join("\n\n"),
display: true,
details: {
fromSequence: batch.fromSequence,
toSequence: batch.toSequence,
},
},
{ deliverAs: "followUp", triggerTurn: true },
);
if (!isCurrent()) return;
const stored = ledger.saveProposal(batch, proposal);
ledger.compact(analysis);
logger.review("auto_review.analysis_completed", {
proposalId: stored.proposalId,
fromSequence: stored.fromSequence,
toSequence: stored.toSequence,
allowCandidates: proposal.allowCandidates.length,
denyCandidates: proposal.denyCandidates.length,
hardGuardCandidates: proposal.hardGuardCandidates.length,
});
nextAnalysisAttemptAt = 0;
} catch (error) {
if (!isCurrent()) return;
nextAnalysisAttemptAt = Date.now() + ANALYSIS_RETRY_BACKOFF_MS;
logger.review("auto_review.analysis_failed", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
if (analysisGeneration === generation) analysisRunning = false;
}
};
return {
handleSessionStart(context): void {
handleSessionStart(ctx): void {
runtime?.controller.abort();
analysisGeneration += 1;
analysisRunning = false;
runtime = {
registry: context.modelRegistry,
sessionManager: context.sessionManager,
registry: ctx.modelRegistry,
sessionManager: ctx.sessionManager,
controller: new AbortController(),
};
circuitBreaker.resetTurn();
warnWhenUnconfigured(config, ctx);
},
handleTurnStart(): void {
circuitBreaker.resetTurn();
},
async handleAgentSettled(): Promise<void> {
await runPendingAnalysis();
},
handleSessionShutdown(): void {
runtime?.controller.abort();
runtime = undefined;
analysisGeneration += 1;
analysisRunning = false;
circuitBreaker.resetTurn();
},
};
+6 -6
View File
@@ -254,13 +254,13 @@ export function mergeUnifiedConfigs(
) as ShellToolsConfig;
}
// Built-in AutoReview and analyzer objects merge field-by-field so a project
// may narrow runtime settings without erasing global defaults.
for (const key of ["autoReview", "analysis"] as const) {
const baseValue = base[key];
const overrideValue = override[key];
// The built-in AutoReview object merges field-by-field so a project may
// narrow runtime settings without erasing global defaults.
{
const baseValue = base.autoReview;
const overrideValue = override.autoReview;
if (baseValue !== undefined || overrideValue !== undefined) {
merged[key] = { ...baseValue, ...overrideValue };
merged.autoReview = { ...baseValue, ...overrideValue };
}
}
-4
View File
@@ -17,10 +17,6 @@ export function getHardGuardConfigPath(agentDir: string): string {
return join(getGlobalConfigDir(agentDir), "hard-guards.jsonc");
}
export function getAnalysisStateDir(agentDir: string): string {
return join(getGlobalConfigDir(agentDir), "analysis");
}
export function getGlobalLogsDir(agentDir: string): string {
return join(getGlobalConfigDir(agentDir), "logs");
+1 -8
View File
@@ -1,8 +1,5 @@
import { z } from "zod";
import {
analysisConfigFileSchema,
autoReviewConfigFileSchema,
} from "./auto-review/config.ts";
import { autoReviewConfigFileSchema } from "./auto-review/config.ts";
/**
* Single source of truth for the permission-system config file shape.
@@ -251,10 +248,6 @@ export const unifiedConfigSchema = z
description:
"Built-in model-assisted reviewer used for static-policy review decisions.",
}),
analysis: analysisConfigFileSchema.optional().meta({
description:
"Background policy analysis triggered after a configured number of completed automatic reviews.",
}),
shellTools: shellToolsSchema.optional(),
})
.meta({
@@ -2,9 +2,7 @@ import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
normalizeAutoReviewAnalysisConfig,
normalizeAutoReviewConfig,
type AutoReviewAnalysisConfig,
type AutoReviewConfig,
} from "./auto-review/config.js";
import type {
@@ -38,8 +36,6 @@ export interface PermissionSystemExtensionConfig {
authorizerChain?: string[];
/** Built-in model reviewer configuration. */
autoReview?: AutoReviewConfig;
/** Count-based background policy analysis configuration. */
analysis?: AutoReviewAnalysisConfig;
}
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
@@ -82,9 +78,6 @@ export function normalizePermissionSystemConfig(
if (raw.autoReview !== undefined) {
result.autoReview = normalizeAutoReviewConfig(raw.autoReview);
}
if (raw.analysis !== undefined) {
result.analysis = normalizeAutoReviewAnalysisConfig(raw.analysis);
}
if (raw.forwardingTimeoutMs !== undefined) {
result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
}
-6
View File
@@ -243,11 +243,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
);
const autoReview = installBuiltinAutoReview({
pi,
service: permissionsService,
config: configStore,
logger,
agentDir,
});
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
@@ -316,9 +313,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
lifecycle.handleResourcesDiscover(event, ctx),
);
pi.on("turn_start", () => autoReview.handleTurnStart());
pi.on("agent_settled", () => {
void autoReview.handleAgentSettled();
});
pi.on("session_shutdown", async () => {
autoReview.handleSessionShutdown();
await lifecycle.handleSessionShutdown();