auto-mode 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -107
- package/dist/classify-locally-rTUwxBZi.js +6203 -0
- package/dist/cli.js +22 -31
- package/dist/index.d.ts +9 -135
- package/dist/index.js +2 -2
- package/package.json +51 -26
- package/dist/classify-locally-BHl0xiqa.js +0 -786
|
@@ -1,786 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, isAbsolute, join, normalize, parse, relative } from "node:path";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
//#region src/config/config.ts
|
|
9
|
-
const run = promisify(execFile);
|
|
10
|
-
/**
|
|
11
|
-
* Muse Spark is the default because it reasons, it was correct on every case we
|
|
12
|
-
* measured, and a contributor-tier call costs about a fortieth of the
|
|
13
|
-
* alternatives. Its token budget is not a preference: below roughly 2000 it
|
|
14
|
-
* returns nothing at all.
|
|
15
|
-
*/
|
|
16
|
-
const DEFAULT_CONFIG = {
|
|
17
|
-
provider: {
|
|
18
|
-
baseURL: "https://api.meta.ai",
|
|
19
|
-
model: "muse-spark-1.3-contributor",
|
|
20
|
-
apiKeyEnv: "META_API_KEY",
|
|
21
|
-
reasoning: true,
|
|
22
|
-
maxTokens: 3e3,
|
|
23
|
-
timeoutMs: 45e3
|
|
24
|
-
},
|
|
25
|
-
transcriptEntries: 40,
|
|
26
|
-
onFailure: "defer"
|
|
27
|
-
};
|
|
28
|
-
/** Provider presets, so a config need only name one. */
|
|
29
|
-
const PRESETS = {
|
|
30
|
-
spark: DEFAULT_CONFIG.provider,
|
|
31
|
-
claude: {
|
|
32
|
-
baseURL: "https://api.anthropic.com",
|
|
33
|
-
model: "claude-haiku-4-5-20251001",
|
|
34
|
-
apiKeyEnv: "ANTHROPIC_API_KEY",
|
|
35
|
-
reasoning: true,
|
|
36
|
-
maxTokens: 3e3,
|
|
37
|
-
timeoutMs: 45e3
|
|
38
|
-
},
|
|
39
|
-
glm: {
|
|
40
|
-
baseURL: "https://api.z.ai/api/anthropic",
|
|
41
|
-
model: "glm-5.3-flash",
|
|
42
|
-
apiKeyEnv: "ZAI_API_KEY",
|
|
43
|
-
reasoning: true,
|
|
44
|
-
maxTokens: 3e3,
|
|
45
|
-
timeoutMs: 6e4
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
function configPath() {
|
|
49
|
-
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
50
|
-
const base = xdg !== void 0 && xdg !== "" ? xdg : join(homedir(), ".config");
|
|
51
|
-
return join(base, "auto-mode", "config.json");
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Reads the config, falling back to the shipped defaults. A missing file is
|
|
55
|
-
* normal. A malformed one is not, and it throws rather than silently running a
|
|
56
|
-
* policy the user did not write.
|
|
57
|
-
*/
|
|
58
|
-
async function loadConfig(path = configPath()) {
|
|
59
|
-
let text;
|
|
60
|
-
try {
|
|
61
|
-
text = await readFile(path, "utf8");
|
|
62
|
-
} catch {
|
|
63
|
-
return DEFAULT_CONFIG;
|
|
64
|
-
}
|
|
65
|
-
let parsed;
|
|
66
|
-
try {
|
|
67
|
-
parsed = JSON.parse(text);
|
|
68
|
-
} catch (error) {
|
|
69
|
-
throw new Error(`${path} is not valid JSON: ${error.message}`);
|
|
70
|
-
}
|
|
71
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${path} must hold a JSON object`);
|
|
72
|
-
return merge(parsed, path);
|
|
73
|
-
}
|
|
74
|
-
function merge(raw, path) {
|
|
75
|
-
const presetName = typeof raw["preset"] === "string" ? raw["preset"] : void 0;
|
|
76
|
-
if (presetName !== void 0 && PRESETS[presetName] === void 0) throw new Error(`${path} names an unknown preset '${presetName}'; known presets are ${Object.keys(PRESETS).join(", ")}`);
|
|
77
|
-
const base = presetName === void 0 ? DEFAULT_CONFIG.provider : PRESETS[presetName];
|
|
78
|
-
const override = isObject(raw["provider"]) ? raw["provider"] : {};
|
|
79
|
-
const provider = {
|
|
80
|
-
baseURL: str(override["baseURL"]) ?? base.baseURL,
|
|
81
|
-
model: str(override["model"]) ?? base.model,
|
|
82
|
-
reasoning: typeof override["reasoning"] === "boolean" ? override["reasoning"] : base.reasoning,
|
|
83
|
-
maxTokens: num(override["maxTokens"]) ?? base.maxTokens,
|
|
84
|
-
timeoutMs: num(override["timeoutMs"]) ?? base.timeoutMs,
|
|
85
|
-
...pick("apiKeyEnv", str(override["apiKeyEnv"]) ?? base.apiKeyEnv),
|
|
86
|
-
...pick("apiKeyCommand", str(override["apiKeyCommand"]) ?? base.apiKeyCommand)
|
|
87
|
-
};
|
|
88
|
-
const onFailure = raw["onFailure"] === "deny" ? "deny" : "defer";
|
|
89
|
-
return {
|
|
90
|
-
provider,
|
|
91
|
-
transcriptEntries: num(raw["transcriptEntries"]) ?? DEFAULT_CONFIG.transcriptEntries,
|
|
92
|
-
onFailure,
|
|
93
|
-
...pick("classifierPath", str(raw["classifierPath"])),
|
|
94
|
-
...pick("rulesPath", str(raw["rulesPath"]))
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
/** Reads the key from the environment, then from the helper command. */
|
|
98
|
-
async function resolveApiKey(provider) {
|
|
99
|
-
const fromEnv = provider.apiKeyEnv === void 0 ? void 0 : process.env[provider.apiKeyEnv];
|
|
100
|
-
if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
|
|
101
|
-
if (provider.apiKeyCommand === void 0 || provider.apiKeyCommand === "") return null;
|
|
102
|
-
try {
|
|
103
|
-
const { stdout } = await run("/bin/sh", ["-c", provider.apiKeyCommand], { timeout: 5e3 });
|
|
104
|
-
const key = stdout.trim();
|
|
105
|
-
return key === "" ? null : key;
|
|
106
|
-
} catch {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
function isObject(value) {
|
|
111
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
112
|
-
}
|
|
113
|
-
function str(value) {
|
|
114
|
-
return typeof value === "string" && value !== "" ? value : void 0;
|
|
115
|
-
}
|
|
116
|
-
function num(value) {
|
|
117
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
118
|
-
}
|
|
119
|
-
function pick(key, value) {
|
|
120
|
-
return value === void 0 ? {} : { [key]: value };
|
|
121
|
-
}
|
|
122
|
-
//#endregion
|
|
123
|
-
//#region src/harness/detect-harness.ts
|
|
124
|
-
/**
|
|
125
|
-
* Names the harness that sent a hook payload.
|
|
126
|
-
*
|
|
127
|
-
* The three shapes overlap, so order matters. Muse carries `model_provider`,
|
|
128
|
-
* which neither of the others sends, so it is tested first — Muse also sends
|
|
129
|
-
* `turn_id`, and testing that first would call every Muse payload Codex. Claude
|
|
130
|
-
* is the only one that sends `prompt_id`. Codex is then what is left that
|
|
131
|
-
* carries `turn_id`.
|
|
132
|
-
*
|
|
133
|
-
* Environment variables cannot help here: Muse runs hook commands with a
|
|
134
|
-
* scrubbed environment, so a Muse hook sees none of them.
|
|
135
|
-
*/
|
|
136
|
-
function detectHarness(payload) {
|
|
137
|
-
if ("model_provider" in payload) return "muse";
|
|
138
|
-
if ("prompt_id" in payload) return "claude";
|
|
139
|
-
if ("turn_id" in payload) return "codex";
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
//#endregion
|
|
143
|
-
//#region src/harness/parse-payload.ts
|
|
144
|
-
const EVENTS = /* @__PURE__ */ new Set(["PreToolUse", "PermissionRequest"]);
|
|
145
|
-
function readString(payload, key) {
|
|
146
|
-
const value = payload[key];
|
|
147
|
-
return typeof value === "string" && value !== "" ? value : void 0;
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Normalises one harness payload, or returns null when auto-mode has nothing to
|
|
151
|
-
* say about it — an unknown harness, an event that is not a tool gate, or a
|
|
152
|
-
* body that is not an object. A null is not a refusal: the caller stays out of
|
|
153
|
-
* the way and the harness decides for itself.
|
|
154
|
-
*/
|
|
155
|
-
function parsePayload(body) {
|
|
156
|
-
if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
|
|
157
|
-
const payload = body;
|
|
158
|
-
const harness = detectHarness(payload);
|
|
159
|
-
if (harness === null) return null;
|
|
160
|
-
const event = readString(payload, "hook_event_name");
|
|
161
|
-
if (event === void 0 || !EVENTS.has(event)) return null;
|
|
162
|
-
const toolName = readString(payload, "tool_name");
|
|
163
|
-
if (toolName === void 0) return null;
|
|
164
|
-
const toolInput = payload["tool_input"];
|
|
165
|
-
const transcriptPath = readString(payload, "transcript_path");
|
|
166
|
-
return {
|
|
167
|
-
harness,
|
|
168
|
-
event,
|
|
169
|
-
sessionId: readString(payload, "session_id") ?? "",
|
|
170
|
-
cwd: readString(payload, "cwd") ?? process.cwd(),
|
|
171
|
-
toolName,
|
|
172
|
-
toolInput: toolInput !== null && typeof toolInput === "object" && !Array.isArray(toolInput) ? toolInput : {},
|
|
173
|
-
...transcriptPath === void 0 ? {} : { transcriptPath },
|
|
174
|
-
raw: payload
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
//#endregion
|
|
178
|
-
//#region src/harness/render-verdict.ts
|
|
179
|
-
/**
|
|
180
|
-
* Turns a verdict into the JSON the harness reads on stdout.
|
|
181
|
-
*
|
|
182
|
-
* All three harnesses take the same shape, because Codex and Muse both model
|
|
183
|
-
* their hook contract on Claude's. `allow` and `ask` differ: `allow` runs the
|
|
184
|
-
* tool with no prompt, while `ask` falls through to whatever the harness would
|
|
185
|
-
* have done on its own, which is usually to ask the operator.
|
|
186
|
-
*/
|
|
187
|
-
function renderVerdict(event, verdict) {
|
|
188
|
-
if (verdict.kind === "ask") return JSON.stringify({ hookSpecificOutput: {
|
|
189
|
-
hookEventName: event,
|
|
190
|
-
permissionDecision: "ask"
|
|
191
|
-
} });
|
|
192
|
-
if (verdict.kind === "allow") return JSON.stringify({ hookSpecificOutput: {
|
|
193
|
-
hookEventName: event,
|
|
194
|
-
permissionDecision: "allow"
|
|
195
|
-
} });
|
|
196
|
-
return JSON.stringify({ hookSpecificOutput: {
|
|
197
|
-
hookEventName: event,
|
|
198
|
-
permissionDecision: "deny",
|
|
199
|
-
permissionDecisionReason: `[${verdict.rule}] ${verdict.reason}`
|
|
200
|
-
} });
|
|
201
|
-
}
|
|
202
|
-
//#endregion
|
|
203
|
-
//#region src/install/hook-config.ts
|
|
204
|
-
/**
|
|
205
|
-
* Prints the hook entry a harness needs, for the user to paste into their own
|
|
206
|
-
* settings. auto-mode never writes those files itself: they are the user's
|
|
207
|
-
* configuration, and an agent that can edit its own restraints has none.
|
|
208
|
-
*
|
|
209
|
-
* All three take a different entry shape, and each difference is load-bearing:
|
|
210
|
-
*
|
|
211
|
-
* - Claude matches tool names against a regular expression, so matching every
|
|
212
|
-
* tool is `.*`. Two near-misses both leave the hook installed but never
|
|
213
|
-
* firing: `*` is not a valid regular expression, and `""` matches only a tool
|
|
214
|
-
* whose name is the empty string.
|
|
215
|
-
* - Muse takes `""` to mean every tool, so the same value means opposite things
|
|
216
|
-
* in the two harnesses.
|
|
217
|
-
* - Codex registers a bare handler list with no matcher.
|
|
218
|
-
* - Muse 1.3 rejects a handler carrying `timeout_ms`, reporting an unknown
|
|
219
|
-
* field and skipping the whole handler silently, so the hook never runs. A
|
|
220
|
-
* user-tier Muse entry takes `type` and `command` and nothing else.
|
|
221
|
-
*
|
|
222
|
-
* The timeout is 90 seconds, not the usual 30: a reasoning model takes 13-24
|
|
223
|
-
* seconds on a hard case, and a harness timeout shorter than the model call
|
|
224
|
-
* turns every hard case into a silent failure.
|
|
225
|
-
*/
|
|
226
|
-
function hookConfig(harness, command) {
|
|
227
|
-
return JSON.stringify({ hooks: { PreToolUse: [harness === "claude" ? {
|
|
228
|
-
matcher: ".*",
|
|
229
|
-
hooks: [{
|
|
230
|
-
type: "command",
|
|
231
|
-
command,
|
|
232
|
-
timeout: 90
|
|
233
|
-
}]
|
|
234
|
-
} : harness === "codex" ? { hooks: [{
|
|
235
|
-
type: "command",
|
|
236
|
-
command,
|
|
237
|
-
timeout: 90
|
|
238
|
-
}] } : {
|
|
239
|
-
matcher: "",
|
|
240
|
-
hooks: [{
|
|
241
|
-
type: "command",
|
|
242
|
-
command
|
|
243
|
-
}]
|
|
244
|
-
}] } }, null, 2);
|
|
245
|
-
}
|
|
246
|
-
const SETTINGS_PATHS = {
|
|
247
|
-
claude: "~/.claude/settings.json",
|
|
248
|
-
codex: "~/.codex/hooks.json",
|
|
249
|
-
muse: "~/.config/muse/settings.json"
|
|
250
|
-
};
|
|
251
|
-
/** What else each harness needs before the hook will run. Empty when nothing. */
|
|
252
|
-
const SETUP_NOTES = {
|
|
253
|
-
claude: ["A tool your permission settings already deny is refused before the hook sees it, so", "auto-mode can only narrow what the harness would have allowed."],
|
|
254
|
-
codex: [
|
|
255
|
-
"Set `hooks = true` in ~/.codex/config.toml.",
|
|
256
|
-
"Codex will not run an untrusted hook. Trust it once when Codex prompts you; the",
|
|
257
|
-
"answer is recorded under [hooks.state] in config.toml. Until then the hook is",
|
|
258
|
-
"skipped silently."
|
|
259
|
-
],
|
|
260
|
-
muse: ["Muse runs hooks with a scrubbed environment, so an API key from a variable will", "not reach it. Set provider.apiKeyCommand in the auto-mode config instead."]
|
|
261
|
-
};
|
|
262
|
-
//#endregion
|
|
263
|
-
//#region src/policy/load-policy.ts
|
|
264
|
-
/** The line in the classifier file that the rule list replaces. */
|
|
265
|
-
const RULES_MARKER = "<rules>";
|
|
266
|
-
/**
|
|
267
|
-
* Reads the two policy files and splices them into the one system prompt the
|
|
268
|
-
* model receives. Nothing else is added, so `auto-mode print-prompt` shows
|
|
269
|
-
* exactly what the classifier reads.
|
|
270
|
-
*/
|
|
271
|
-
async function loadPolicy(paths = {}) {
|
|
272
|
-
const shipped = findShippedPolicyDir();
|
|
273
|
-
const classifierPath = paths.classifierPath ?? join(shipped, "classifier.md");
|
|
274
|
-
const rulesPath = paths.rulesPath ?? join(shipped, "rules.md");
|
|
275
|
-
const [classifier, rules] = await Promise.all([readFile(classifierPath, "utf8"), readFile(rulesPath, "utf8")]);
|
|
276
|
-
if (!classifier.includes(RULES_MARKER)) throw new Error(`${classifierPath} has no ${RULES_MARKER} line, so the rules cannot be placed`);
|
|
277
|
-
return classifier.replace(RULES_MARKER, rules.trim());
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Finds the `policy/` directory this package ships. It walks up from this
|
|
281
|
-
* module rather than counting directories, because the built bundle is flat
|
|
282
|
-
* while the source is nested, and a fixed `..` count is right for only one of
|
|
283
|
-
* them.
|
|
284
|
-
*/
|
|
285
|
-
function findShippedPolicyDir() {
|
|
286
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
287
|
-
for (;;) {
|
|
288
|
-
if (existsSync(join(dir, "policy", "classifier.md"))) return join(dir, "policy");
|
|
289
|
-
const parent = dirname(dir);
|
|
290
|
-
if (parent === dir || dir === parse(dir).root) throw new Error("auto-mode cannot find its shipped policy/ directory");
|
|
291
|
-
dir = parent;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
//#endregion
|
|
295
|
-
//#region src/transcript/read-transcript.ts
|
|
296
|
-
const MAX_ENTRY_CHARS = 2e3;
|
|
297
|
-
/**
|
|
298
|
-
* Reads the tail of a harness transcript.
|
|
299
|
-
*
|
|
300
|
-
* Only the tail, and always the same tail for a given session length, so the
|
|
301
|
-
* prompt prefix grows by appending rather than sliding. A sliding window would
|
|
302
|
-
* change the cached prefix on every call and pay full price each time.
|
|
303
|
-
*
|
|
304
|
-
* Every harness writes JSONL but none writes the same schema, so this reads
|
|
305
|
-
* defensively and drops what it cannot understand. A transcript it cannot parse
|
|
306
|
-
* yields no entries, and the classifier then judges the action with no history —
|
|
307
|
-
* which is weaker, not wrong.
|
|
308
|
-
*/
|
|
309
|
-
async function readTranscript(path, limit) {
|
|
310
|
-
if (path === void 0 || limit <= 0) return [];
|
|
311
|
-
let text;
|
|
312
|
-
try {
|
|
313
|
-
text = await readFile(path, "utf8");
|
|
314
|
-
} catch {
|
|
315
|
-
return [];
|
|
316
|
-
}
|
|
317
|
-
const entries = [];
|
|
318
|
-
for (const line of text.split("\n")) {
|
|
319
|
-
if (line.trim() === "") continue;
|
|
320
|
-
let parsed;
|
|
321
|
-
try {
|
|
322
|
-
parsed = JSON.parse(line);
|
|
323
|
-
} catch {
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
const entry = flatten(parsed);
|
|
327
|
-
if (entry !== null) entries.push(entry);
|
|
328
|
-
}
|
|
329
|
-
return entries.slice(-limit);
|
|
330
|
-
}
|
|
331
|
-
function flatten(value) {
|
|
332
|
-
if (value === null || typeof value !== "object") return null;
|
|
333
|
-
const row = value;
|
|
334
|
-
const message = row["message"];
|
|
335
|
-
const inner = message !== null && typeof message === "object" ? message : row;
|
|
336
|
-
const role = typeof inner["role"] === "string" ? inner["role"] : typeof row["type"] === "string" ? row["type"] : null;
|
|
337
|
-
if (role === null) return null;
|
|
338
|
-
const text = contentToText(inner["content"] ?? row["content"] ?? row["text"]);
|
|
339
|
-
return text === "" ? null : {
|
|
340
|
-
role,
|
|
341
|
-
text: text.slice(0, MAX_ENTRY_CHARS)
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
function contentToText(content) {
|
|
345
|
-
if (typeof content === "string") return content.trim();
|
|
346
|
-
if (!Array.isArray(content)) return "";
|
|
347
|
-
const parts = [];
|
|
348
|
-
for (const block of content) {
|
|
349
|
-
if (typeof block === "string") {
|
|
350
|
-
parts.push(block);
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
if (block === null || typeof block !== "object") continue;
|
|
354
|
-
const item = block;
|
|
355
|
-
if (typeof item["text"] === "string") {
|
|
356
|
-
parts.push(item["text"]);
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
if (item["type"] === "tool_use" && typeof item["name"] === "string") parts.push(`[tool ${item["name"]}] ${JSON.stringify(item["input"] ?? {})}`);
|
|
360
|
-
}
|
|
361
|
-
return parts.join("\n").trim();
|
|
362
|
-
}
|
|
363
|
-
//#endregion
|
|
364
|
-
//#region src/model/anthropic-client.ts
|
|
365
|
-
async function callModel(provider, apiKey, request) {
|
|
366
|
-
const controller = new AbortController();
|
|
367
|
-
const timer = setTimeout(() => controller.abort(), provider.timeoutMs);
|
|
368
|
-
try {
|
|
369
|
-
const response = await fetch(`${provider.baseURL.replace(/\/$/, "")}/v1/messages`, {
|
|
370
|
-
method: "POST",
|
|
371
|
-
signal: controller.signal,
|
|
372
|
-
headers: {
|
|
373
|
-
"content-type": "application/json",
|
|
374
|
-
"x-api-key": apiKey,
|
|
375
|
-
"anthropic-version": "2023-06-01"
|
|
376
|
-
},
|
|
377
|
-
body: JSON.stringify({
|
|
378
|
-
model: provider.model,
|
|
379
|
-
max_tokens: provider.maxTokens,
|
|
380
|
-
system: [{
|
|
381
|
-
type: "text",
|
|
382
|
-
text: request.system,
|
|
383
|
-
cache_control: { type: "ephemeral" }
|
|
384
|
-
}],
|
|
385
|
-
messages: [{
|
|
386
|
-
role: "user",
|
|
387
|
-
content: request.user
|
|
388
|
-
}]
|
|
389
|
-
})
|
|
390
|
-
});
|
|
391
|
-
if (!response.ok) throw new Error(`${response.status} ${(await response.text()).slice(0, 300)}`);
|
|
392
|
-
return readResult(await response.json());
|
|
393
|
-
} finally {
|
|
394
|
-
clearTimeout(timer);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
function readResult(body) {
|
|
398
|
-
const root = asObject(body);
|
|
399
|
-
const content = root["content"];
|
|
400
|
-
const parts = [];
|
|
401
|
-
if (Array.isArray(content)) for (const block of content) {
|
|
402
|
-
const item = asObject(block);
|
|
403
|
-
if (item["type"] === "text" && typeof item["text"] === "string") parts.push(item["text"]);
|
|
404
|
-
}
|
|
405
|
-
const usage = asObject(root["usage"]);
|
|
406
|
-
return {
|
|
407
|
-
text: parts.join("\n"),
|
|
408
|
-
cachedInputTokens: count(usage["cache_read_input_tokens"]),
|
|
409
|
-
cacheWriteTokens: count(usage["cache_creation_input_tokens"]),
|
|
410
|
-
newInputTokens: count(usage["input_tokens"]),
|
|
411
|
-
outputTokens: count(usage["output_tokens"])
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
function asObject(value) {
|
|
415
|
-
return value !== null && typeof value === "object" ? value : {};
|
|
416
|
-
}
|
|
417
|
-
function count(value) {
|
|
418
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
419
|
-
}
|
|
420
|
-
//#endregion
|
|
421
|
-
//#region src/model/build-request.ts
|
|
422
|
-
const MAX_INPUT_CHARS = 4e3;
|
|
423
|
-
/**
|
|
424
|
-
* Writes the user turn: the conversation so far, then the action to judge.
|
|
425
|
-
*
|
|
426
|
-
* The action goes last and is labelled, because the policy tells the classifier
|
|
427
|
-
* to judge the most recent action and treat everything before it as context.
|
|
428
|
-
* Anything earlier that looks like an instruction is data, and the policy says
|
|
429
|
-
* so; this only has to make clear which part is which.
|
|
430
|
-
*/
|
|
431
|
-
function buildUserMessage(payload, transcript, reasoning) {
|
|
432
|
-
const history = transcript.length === 0 ? "(no transcript available from this harness)" : transcript.map((entry) => `${entry.role}: ${entry.text}`).join("\n\n");
|
|
433
|
-
const input = JSON.stringify(payload.toolInput, null, 2).slice(0, MAX_INPUT_CHARS);
|
|
434
|
-
const closing = reasoning ? "Work through the classification process, then end your reply with the output contract tags." : "Reply with the output contract tags and nothing else.";
|
|
435
|
-
return `<transcript>
|
|
436
|
-
${history}
|
|
437
|
-
</transcript>
|
|
438
|
-
|
|
439
|
-
<action>
|
|
440
|
-
tool: ${payload.toolName}
|
|
441
|
-
cwd: ${payload.cwd}
|
|
442
|
-
input:
|
|
443
|
-
${input}
|
|
444
|
-
</action>
|
|
445
|
-
|
|
446
|
-
${closing}`;
|
|
447
|
-
}
|
|
448
|
-
//#endregion
|
|
449
|
-
//#region src/model/parse-verdict.ts
|
|
450
|
-
const BLOCK = /<block>\s*(yes|no)\s*<\/block>/i;
|
|
451
|
-
const RULE = /<rule>([^<]*)<\/rule>/i;
|
|
452
|
-
const REASON = /<reason>([^<]*)<\/reason>/i;
|
|
453
|
-
/**
|
|
454
|
-
* Reads the classifier's answer.
|
|
455
|
-
*
|
|
456
|
-
* Every ambiguity resolves to allow, on the policy's own reasoning: a block
|
|
457
|
-
* nobody can name is a false positive, and a false positive costs more than the
|
|
458
|
-
* action it stopped. A model that says `yes` but names no rule has not made a
|
|
459
|
-
* case, so there is nothing to show the user and nothing to appeal.
|
|
460
|
-
*/
|
|
461
|
-
function parseModelVerdict(text) {
|
|
462
|
-
const block = BLOCK.exec(text);
|
|
463
|
-
if (block === null || block[1]?.toLowerCase() !== "yes") return { kind: "allow" };
|
|
464
|
-
const rule = RULE.exec(text)?.[1]?.trim();
|
|
465
|
-
if (rule === void 0 || rule === "") return { kind: "allow" };
|
|
466
|
-
const stripped = (REASON.exec(text)?.[1]?.trim())?.replace(/^\[[^\]]*\]\s*/, "") ?? "";
|
|
467
|
-
return {
|
|
468
|
-
kind: "deny",
|
|
469
|
-
rule,
|
|
470
|
-
reason: stripped === "" ? "the policy blocks this action." : stripped
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
//#endregion
|
|
474
|
-
//#region src/model/classify-with-model.ts
|
|
475
|
-
/**
|
|
476
|
-
* Asks the model. A null verdict means auto-mode has no opinion, and the caller
|
|
477
|
-
* writes nothing so the harness decides for itself.
|
|
478
|
-
*/
|
|
479
|
-
async function classifyWithModel(payload, config) {
|
|
480
|
-
const apiKey = await resolveApiKey(config.provider);
|
|
481
|
-
if (apiKey === null) return failure(config, "no API key: set the configured environment variable or key command");
|
|
482
|
-
let system;
|
|
483
|
-
try {
|
|
484
|
-
system = await loadPolicy({
|
|
485
|
-
...config.classifierPath === void 0 ? {} : { classifierPath: config.classifierPath },
|
|
486
|
-
...config.rulesPath === void 0 ? {} : { rulesPath: config.rulesPath }
|
|
487
|
-
});
|
|
488
|
-
} catch (error) {
|
|
489
|
-
return failure(config, `policy unreadable: ${error.message}`);
|
|
490
|
-
}
|
|
491
|
-
const user = buildUserMessage(payload, await readTranscript(payload.transcriptPath, config.transcriptEntries), config.provider.reasoning);
|
|
492
|
-
try {
|
|
493
|
-
const result = await callModel(config.provider, apiKey, {
|
|
494
|
-
system,
|
|
495
|
-
user
|
|
496
|
-
});
|
|
497
|
-
if (result.text.trim() === "") return failure(config, `${config.provider.model} returned no text; raise maxTokens`);
|
|
498
|
-
const verdict = parseModelVerdict(result.text);
|
|
499
|
-
const cache = `${result.cachedInputTokens} cached / ${result.cacheWriteTokens} written / ${result.newInputTokens} new / ${result.outputTokens} out`;
|
|
500
|
-
return {
|
|
501
|
-
verdict,
|
|
502
|
-
note: verdict.kind === "deny" ? `${config.provider.model} blocked it: [${verdict.rule}] ${verdict.reason} (${cache})` : `${config.provider.model} allowed it (${cache})`
|
|
503
|
-
};
|
|
504
|
-
} catch (error) {
|
|
505
|
-
const message = error.name === "AbortError" ? `timed out after ${config.provider.timeoutMs}ms` : error.message;
|
|
506
|
-
return failure(config, `${config.provider.model} failed: ${message}`);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
function failure(config, note) {
|
|
510
|
-
if (config.onFailure === "deny") return {
|
|
511
|
-
verdict: {
|
|
512
|
-
kind: "deny",
|
|
513
|
-
rule: "Classifier Unavailable",
|
|
514
|
-
reason: `${note}; this policy is configured to fail closed.`
|
|
515
|
-
},
|
|
516
|
-
note
|
|
517
|
-
};
|
|
518
|
-
return {
|
|
519
|
-
verdict: null,
|
|
520
|
-
note: `${note}; deferring to the harness`
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
//#endregion
|
|
524
|
-
//#region src/rules/local-allowlists.ts
|
|
525
|
-
/**
|
|
526
|
-
* Tools whose documented effect is to read. Names differ by harness, so this
|
|
527
|
-
* set holds every spelling the three use.
|
|
528
|
-
*/
|
|
529
|
-
const READ_ONLY_TOOLS = /* @__PURE__ */ new Set([
|
|
530
|
-
"Read",
|
|
531
|
-
"Glob",
|
|
532
|
-
"Grep",
|
|
533
|
-
"LS",
|
|
534
|
-
"NotebookRead",
|
|
535
|
-
"WebFetch",
|
|
536
|
-
"WebSearch",
|
|
537
|
-
"TodoWrite",
|
|
538
|
-
"read",
|
|
539
|
-
"glob",
|
|
540
|
-
"grep",
|
|
541
|
-
"list_dir",
|
|
542
|
-
"read_file",
|
|
543
|
-
"file_search",
|
|
544
|
-
"view_image"
|
|
545
|
-
]);
|
|
546
|
-
/** Tools that carry a shell command and so need the command read instead. */
|
|
547
|
-
const SHELL_TOOLS = /* @__PURE__ */ new Set([
|
|
548
|
-
"Bash",
|
|
549
|
-
"bash",
|
|
550
|
-
"shell",
|
|
551
|
-
"run_command",
|
|
552
|
-
"local_shell"
|
|
553
|
-
]);
|
|
554
|
-
/**
|
|
555
|
-
* Commands that only print. A command outside this set is not assumed to
|
|
556
|
-
* write — it is simply not decided here, and goes to the model.
|
|
557
|
-
*/
|
|
558
|
-
const READ_ONLY_COMMANDS = /* @__PURE__ */ new Set([
|
|
559
|
-
"awk",
|
|
560
|
-
"basename",
|
|
561
|
-
"cat",
|
|
562
|
-
"cksum",
|
|
563
|
-
"column",
|
|
564
|
-
"comm",
|
|
565
|
-
"cut",
|
|
566
|
-
"date",
|
|
567
|
-
"df",
|
|
568
|
-
"diff",
|
|
569
|
-
"dirname",
|
|
570
|
-
"du",
|
|
571
|
-
"echo",
|
|
572
|
-
"env",
|
|
573
|
-
"false",
|
|
574
|
-
"fd",
|
|
575
|
-
"file",
|
|
576
|
-
"find",
|
|
577
|
-
"grep",
|
|
578
|
-
"head",
|
|
579
|
-
"hostname",
|
|
580
|
-
"id",
|
|
581
|
-
"jq",
|
|
582
|
-
"less",
|
|
583
|
-
"ls",
|
|
584
|
-
"md5sum",
|
|
585
|
-
"nl",
|
|
586
|
-
"od",
|
|
587
|
-
"printenv",
|
|
588
|
-
"printf",
|
|
589
|
-
"ps",
|
|
590
|
-
"pwd",
|
|
591
|
-
"readlink",
|
|
592
|
-
"realpath",
|
|
593
|
-
"rg",
|
|
594
|
-
"sha1sum",
|
|
595
|
-
"sha256sum",
|
|
596
|
-
"sort",
|
|
597
|
-
"stat",
|
|
598
|
-
"tail",
|
|
599
|
-
"tr",
|
|
600
|
-
"true",
|
|
601
|
-
"type",
|
|
602
|
-
"uname",
|
|
603
|
-
"uniq",
|
|
604
|
-
"uptime",
|
|
605
|
-
"wc",
|
|
606
|
-
"which",
|
|
607
|
-
"whoami",
|
|
608
|
-
"xxd",
|
|
609
|
-
"yq"
|
|
610
|
-
]);
|
|
611
|
-
/** `git` subcommands that only report. */
|
|
612
|
-
const READ_ONLY_GIT_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
613
|
-
"blame",
|
|
614
|
-
"describe",
|
|
615
|
-
"diff",
|
|
616
|
-
"log",
|
|
617
|
-
"ls-files",
|
|
618
|
-
"ls-remote",
|
|
619
|
-
"ls-tree",
|
|
620
|
-
"rev-list",
|
|
621
|
-
"rev-parse",
|
|
622
|
-
"shortlog",
|
|
623
|
-
"show",
|
|
624
|
-
"show-ref",
|
|
625
|
-
"status",
|
|
626
|
-
"tag",
|
|
627
|
-
"whatchanged"
|
|
628
|
-
]);
|
|
629
|
-
/**
|
|
630
|
-
* Directory names a build reproduces. Deleting one of these inside the working
|
|
631
|
-
* tree is the Regenerable output exception, which is the single most common
|
|
632
|
-
* thing a classifier gets wrong.
|
|
633
|
-
*/
|
|
634
|
-
const REGENERABLE_DIRS = /* @__PURE__ */ new Set([
|
|
635
|
-
".gradle",
|
|
636
|
-
".next",
|
|
637
|
-
".nuxt",
|
|
638
|
-
".parcel-cache",
|
|
639
|
-
".pytest_cache",
|
|
640
|
-
".turbo",
|
|
641
|
-
".venv",
|
|
642
|
-
"__pycache__",
|
|
643
|
-
"build",
|
|
644
|
-
"coverage",
|
|
645
|
-
"dist",
|
|
646
|
-
"node_modules",
|
|
647
|
-
"out",
|
|
648
|
-
"target",
|
|
649
|
-
"venv"
|
|
650
|
-
]);
|
|
651
|
-
//#endregion
|
|
652
|
-
//#region src/rules/split-shell-command.ts
|
|
653
|
-
/**
|
|
654
|
-
* Splits a shell command on the operators that chain separate commands, so each
|
|
655
|
-
* one can be judged on its own. Quoted text is left alone: `echo "a && b"` is
|
|
656
|
-
* one command, not two.
|
|
657
|
-
*
|
|
658
|
-
* This is deliberately not a shell parser. It recognises enough to decide
|
|
659
|
-
* whether every part of a command is plainly read-only; anything it cannot
|
|
660
|
-
* account for is reported through `hasUnparsedConstruct`, and the caller then
|
|
661
|
-
* declines to judge locally rather than guessing.
|
|
662
|
-
*/
|
|
663
|
-
function splitShellCommand(command) {
|
|
664
|
-
const segments = [];
|
|
665
|
-
let current = "";
|
|
666
|
-
let quote = null;
|
|
667
|
-
let unparsed = false;
|
|
668
|
-
let i = 0;
|
|
669
|
-
const push = () => {
|
|
670
|
-
const text = current.trim();
|
|
671
|
-
if (text !== "") segments.push({ text });
|
|
672
|
-
current = "";
|
|
673
|
-
};
|
|
674
|
-
while (i < command.length) {
|
|
675
|
-
const ch = command[i];
|
|
676
|
-
if (quote !== null) {
|
|
677
|
-
if (quote === "\"" && (command.startsWith("$(", i) || ch === "`")) unparsed = true;
|
|
678
|
-
if (ch === quote) quote = null;
|
|
679
|
-
current += ch;
|
|
680
|
-
i += 1;
|
|
681
|
-
continue;
|
|
682
|
-
}
|
|
683
|
-
if (ch === "'" || ch === "\"") {
|
|
684
|
-
quote = ch;
|
|
685
|
-
current += ch;
|
|
686
|
-
i += 1;
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
if (ch === "\\") {
|
|
690
|
-
current += command.slice(i, i + 2);
|
|
691
|
-
i += 2;
|
|
692
|
-
continue;
|
|
693
|
-
}
|
|
694
|
-
if (command.startsWith("$(", i) || ch === "`" || command.startsWith("<(", i) || ch === ">") {
|
|
695
|
-
unparsed = true;
|
|
696
|
-
current += ch;
|
|
697
|
-
i += 1;
|
|
698
|
-
continue;
|
|
699
|
-
}
|
|
700
|
-
if (command.startsWith("&&", i) || command.startsWith("||", i)) {
|
|
701
|
-
push();
|
|
702
|
-
i += 2;
|
|
703
|
-
continue;
|
|
704
|
-
}
|
|
705
|
-
if (ch === ";" || ch === "|" || ch === "\n" || ch === "&") {
|
|
706
|
-
push();
|
|
707
|
-
i += 1;
|
|
708
|
-
continue;
|
|
709
|
-
}
|
|
710
|
-
current += ch;
|
|
711
|
-
i += 1;
|
|
712
|
-
}
|
|
713
|
-
push();
|
|
714
|
-
return {
|
|
715
|
-
segments,
|
|
716
|
-
hasUnparsedConstruct: unparsed || quote !== null
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
//#endregion
|
|
720
|
-
//#region src/rules/classify-locally.ts
|
|
721
|
-
const ESCALATE = { kind: "escalate" };
|
|
722
|
-
function classifyLocally(payload) {
|
|
723
|
-
if (READ_ONLY_TOOLS.has(payload.toolName)) return {
|
|
724
|
-
kind: "allow",
|
|
725
|
-
exception: "Read-only actions"
|
|
726
|
-
};
|
|
727
|
-
if (!SHELL_TOOLS.has(payload.toolName)) return ESCALATE;
|
|
728
|
-
const command = payload.toolInput["command"];
|
|
729
|
-
if (typeof command !== "string" || command === "") return ESCALATE;
|
|
730
|
-
const { segments, hasUnparsedConstruct } = splitShellCommand(command);
|
|
731
|
-
if (hasUnparsedConstruct || segments.length === 0) return ESCALATE;
|
|
732
|
-
let exception = "Read-only actions";
|
|
733
|
-
for (const segment of segments) {
|
|
734
|
-
const verdict = classifySegment(segment.text, payload.cwd);
|
|
735
|
-
if (verdict === null) return ESCALATE;
|
|
736
|
-
if (verdict !== "Read-only actions") exception = verdict;
|
|
737
|
-
}
|
|
738
|
-
return {
|
|
739
|
-
kind: "allow",
|
|
740
|
-
exception
|
|
741
|
-
};
|
|
742
|
-
}
|
|
743
|
-
/** Names the exception covering one command, or null to send it to the model. */
|
|
744
|
-
function classifySegment(text, cwd) {
|
|
745
|
-
const words = tokenize(text);
|
|
746
|
-
const start = words.findIndex((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
|
|
747
|
-
if (start === -1) return "Read-only actions";
|
|
748
|
-
const head = words[start];
|
|
749
|
-
const rest = words.slice(start + 1);
|
|
750
|
-
const name = head.includes("/") ? head.split("/").pop() : head;
|
|
751
|
-
if (READ_ONLY_COMMANDS.has(name)) return "Read-only actions";
|
|
752
|
-
if (name === "git") {
|
|
753
|
-
const sub = rest.find((word) => !word.startsWith("-"));
|
|
754
|
-
return sub !== void 0 && READ_ONLY_GIT_SUBCOMMANDS.has(sub) ? "Read-only actions" : null;
|
|
755
|
-
}
|
|
756
|
-
if (name === "rm") return classifyRemove(rest, cwd);
|
|
757
|
-
return null;
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* Allows a delete only when every path it names is a regenerable directory
|
|
761
|
-
* inside the working tree. A single unrecognised path sends the whole command
|
|
762
|
-
* to the model, because `rm` takes many and one of them may not be scratch.
|
|
763
|
-
*/
|
|
764
|
-
function classifyRemove(args, cwd) {
|
|
765
|
-
const paths = args.filter((arg) => !arg.startsWith("-"));
|
|
766
|
-
if (paths.length === 0) return null;
|
|
767
|
-
for (const path of paths) {
|
|
768
|
-
const stripped = stripQuotes(path);
|
|
769
|
-
if (stripped.includes("*") || stripped.includes("~") || stripped.includes("$")) return null;
|
|
770
|
-
const resolved = isAbsolute(stripped) ? normalize(stripped) : normalize(`${cwd}/${stripped}`);
|
|
771
|
-
const within = relative(cwd, resolved);
|
|
772
|
-
if (within === "" || within.startsWith("..") || isAbsolute(within)) return null;
|
|
773
|
-
const last = within.split(/[\\/]/).filter((s) => s !== "").at(-1);
|
|
774
|
-
if (last === void 0 || !REGENERABLE_DIRS.has(last)) return null;
|
|
775
|
-
}
|
|
776
|
-
return "Regenerable output";
|
|
777
|
-
}
|
|
778
|
-
function tokenize(text) {
|
|
779
|
-
return text.split(/\s+/).filter((word) => word !== "");
|
|
780
|
-
}
|
|
781
|
-
function stripQuotes(word) {
|
|
782
|
-
const first = word[0];
|
|
783
|
-
return (first === "'" || first === "\"") && word.at(-1) === first ? word.slice(1, -1) : word;
|
|
784
|
-
}
|
|
785
|
-
//#endregion
|
|
786
|
-
export { loadPolicy as a, hookConfig as c, detectHarness as d, DEFAULT_CONFIG as f, resolveApiKey as g, loadConfig as h, readTranscript as i, renderVerdict as l, configPath as m, classifyWithModel as n, SETTINGS_PATHS as o, PRESETS as p, parseModelVerdict as r, SETUP_NOTES as s, classifyLocally as t, parsePayload as u };
|