Report model drift and deny only positively invalid stale-pin launches.

hooks/register.ts 130 lines1import type { Register } from "claude-code";
2
3type ModelsReport = {
4 vendors: { vendor: string; verdict: string; findings: string[]; retiring: string[] }[];
5 disabled_by_baseline: boolean;
6};
7
8type Services = {
9 clock: { now: () => Promise<number> };
10 session: { root: () => Promise<string> };
11 process: {
12 run: (
13 argv: readonly string[],
14 init?: { cwd?: string; timeoutMs?: number; stdin?: string },
15 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
16 };
17};
18
19let cachedReport: ModelsReport | null = null;
20let resolvedModel: string | undefined;
21let lastRefreshAtMs: number | null = null;
22const REFRESH_REUSE_MS = 7_500;
23const READ_ONLY_TOOLS = new Set(["Read", "Glob", "Grep", "NotebookRead", "TodoWrite"]);
24const ESCAPE_HATCH_TOOLS = new Set(["AskUserQuestion", "SendUserMessage"]);
25
26/** JSON is transport only; every verdict and launch judgement belongs to Python. */
27function parseReport(text: string): ModelsReport | null {
28 const value: unknown = JSON.parse(text);
29 if (!value || typeof value !== "object") return null;
30 const report = value as Record<string, unknown>;
31 if (typeof report.disabled_by_baseline !== "boolean" || !Array.isArray(report.vendors)) return null;
32 const vendors = report.vendors;
33 if (vendors.length !== 3 || !vendors.every((row: unknown) => {
34 if (!row || typeof row !== "object") return false;
35 const vendor = row as Record<string, unknown>;
36 return ["codex", "agy", "claude"].includes(String(vendor.vendor)) &&
37 ["ok", "drift", "invalid", "not_checked"].includes(String(vendor.verdict)) &&
38 Array.isArray(vendor.findings) && vendor.findings.every((line: unknown) => typeof line === "string") &&
39 Array.isArray(vendor.retiring) && vendor.retiring.every((line: unknown) => typeof line === "string");
40 })) return null;
41 if (new Set(vendors.map((row: { vendor: string }) => row.vendor)).size !== 3) return null;
42 // Retain the complete generated report for Python's typed decoder.
43 return value as ModelsReport;
44}
45
46async function readReport($: Services): Promise<ModelsReport | null> {
47 try {
48 const argv = ["mise", "run", "models-check", "--", "--json", "--baseline", "doctor.toml"];
49 if (resolvedModel) argv.push("--resolved-model", resolvedModel);
50 const child = await $.process.run(argv, { cwd: await $.session.root(), timeoutMs: 45_000 });
51 return child.exitCode === 0 ? parseReport(child.stdout) : null;
52 } catch {
53 return null;
54 }
55}
56
57function hasInvalid(report: ModelsReport | null): boolean {
58 return report !== null && !report.disabled_by_baseline &&
59 report.vendors.some((vendor) => vendor.verdict === "invalid");
60}
61
62async function classify($: Services, tool: string, command?: string, subagentType?: string): Promise<string | null> {
63 if (!cachedReport) return null;
64 const root = await $.session.root();
65 // Each child owns its stdin snapshot; no state file or cross-session race.
66 const snapshot = JSON.stringify(cachedReport);
67 try {
68 const argv = ["mise", "run", "models-classify", "--", "--report-json", "-", "--tool", tool];
69 if (command !== undefined) argv.push("--command", command);
70 if (subagentType !== undefined) argv.push("--subagent-type", subagentType);
71 const child = await $.process.run(argv, { cwd: root, timeoutMs: 45_000, stdin: snapshot });
72 if (child.exitCode !== 0) return null;
73 const decision = child.stdout.trim();
74 return decision === "ALLOW" || decision.startsWith("DENY: ") ? decision : null;
75 } catch {
76 return null;
77 }
78}
79
80async function refresh($: Services): Promise<void> {
81 try {
82 const now = await $.clock.now();
83 if (lastRefreshAtMs !== null && now >= lastRefreshAtMs && now - lastRefreshAtMs < REFRESH_REUSE_MS) return;
84 lastRefreshAtMs = now;
85 } catch {
86 lastRefreshAtMs = null;
87 }
88 const report = await readReport($);
89 if (report !== null) cachedReport = report;
90}
91
92async function denyReason($: Services, tool: string, command?: string, subagentType?: string): Promise<string | null> {
93 if (!hasInvalid(cachedReport)) return null;
94 const before = await classify($, tool, command, subagentType);
95 if (!before?.startsWith("DENY: ")) return null;
96 await refresh($);
97 if (!hasInvalid(cachedReport)) return null;
98 // An unanswered refresh/classification cannot weaken an established denial.
99 const after = await classify($, tool, command, subagentType);
100 return after === "ALLOW" ? null : (after ?? before).slice("DENY: ".length);
101}
102
103export const register: Register = (on) => {
104 on("classic.SessionStart", async ($, e, next) => {
105 const result = await next(e);
106 resolvedModel = e.model;
107 lastRefreshAtMs = null;
108 cachedReport = await readReport($);
109 const lines = cachedReport ? cachedReport.vendors.flatMap((vendor) => [
110 `model-registry ${vendor.vendor}: ${vendor.verdict}${vendor.verdict === "not_checked" ? " (this is not a pass)" : ""}`,
111 ...vendor.findings, ...vendor.retiring,
112 ]) : ["model-registry: NOT CHECKED (this is not a pass): report unavailable"];
113 return { ...result, additionalContext: [...(result.additionalContext ?? []), lines.join("\n")] };
114 });
115
116 on("classic.PreToolUse", async ($, e, next) => {
117 if (READ_ONLY_TOOLS.has(e.tool) || ESCAPE_HATCH_TOOLS.has(e.tool) || !hasInvalid(cachedReport)) return next(e);
118 if (e.tool !== "Bash" && e.tool !== "Agent") return next(e);
119 const reason = await denyReason($, e.tool,
120 e.tool === "Bash" ? e.command : undefined,
121 e.tool === "Agent" ? e.subagent_type : undefined);
122 return reason === null ? next(e) : { deny: reason };
123 });
124
125 on("agent.spawn", async ($, e, next) => {
126 const reason = await denyReason($, "agent.spawn", undefined, e.subagentType);
127 return reason === null ? next(e) : { deny: reason };
128 });
129};
130