SLOPSHOPPER

model-registry

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

newprocessagents
A shopper browsing a rack in a slop shop
Source 1 files
hooks/register.ts 130 lines
1import 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