mtech-ai-local-runner 0.1.53 → 0.1.55

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.
@@ -0,0 +1,335 @@
1
+ import { constants } from "node:fs";
2
+ import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parse as parseToml } from "smol-toml";
6
+ import { claudeConfigDir, codexHome, parseCodexHooks } from "../asks/integrations.js";
7
+ import { GUIDE_SHIM_FILE, removeShim, shimCommand, writeShim } from "../asks/shim.js";
8
+ import { GUIDE_APPS } from "./types.js";
9
+ /*
10
+ * Installing the guide hooks (Work Item d38d60f9, spec sections 3.5 and 6.5), only on request: setup --feature, or
11
+ * integrations install --feature guide. Claude Code gets a second skills-directory plugin, mtec-ai-gateway-guide. Codex
12
+ * gets two handlers appended to ~/.codex/hooks.json after a backup, after the person's own hooks and the asks handlers.
13
+ * Both run the stable guide shim only, so a runner upgrade changes no handler and Codex never asks for trust again.
14
+ * Guide keeps its own record of the dirs it used, <stateRoot>/guide-integrations.json, so asks and guide are installed and
15
+ * removed independently.
16
+ */
17
+ export const GUIDE_PLUGIN_NAME = "mtec-ai-gateway-guide";
18
+ export const GUIDE_INTEGRATIONS_FILE = "guide-integrations.json";
19
+ const CLAUDE_PRE_TOOL_MATCHER = "Read|Grep|Glob|Bash|Edit|Write|mcp__.*";
20
+ const CODEX_PRE_TOOL_MATCHER = "^(Bash|apply_patch|mcp__.*)$";
21
+ const HANDLER_TIMEOUT_SECONDS = 5;
22
+ const GUIDE_CODEX_COMMAND = /^"[^"]*\/mtec-ai-local-runner\/bin\/gateway-guide-hook" codex (session-start|pre-tool)$/;
23
+ const CLAUDE_HOOKS_DESCRIPTION = "AI Gateway guide hooks, written by mtech-ai-local-runner. Remove with: npx mtech-ai-local-runner integrations remove claude --feature guide";
24
+ const CODEX_FILE_DESCRIPTION = "Hooks written by mtech-ai-local-runner for AI Gateway guide.";
25
+ /** Codex keys hook trust by "<hooks.json path>:<event>:<group>:<handler>", with the event in snake case. */
26
+ const TRUST_EVENTS = { SessionStart: "session_start", PreToolUse: "pre_tool_use" };
27
+ const RECORD_KEYS = { claude: "claude_config_dir", codex: "codex_home" };
28
+ /** The guide hook client installed next to this module (dist/src/guide/hook-client.js in the package). */
29
+ export function guideHookClientScript() {
30
+ return fileURLToPath(new URL("./hook-client.js", import.meta.url));
31
+ }
32
+ export function guideHookCommand(stateRoot, app, event) {
33
+ return shimCommand(stateRoot, GUIDE_SHIM_FILE, [app, event]);
34
+ }
35
+ export function guidePluginManifest() {
36
+ return { name: GUIDE_PLUGIN_NAME, version: "1.0.0", description: "Reminds this session to use the AI Gateway and counts how often it does.",
37
+ author: { name: "MTEC AI Gateway" } };
38
+ }
39
+ export function guideClaudeHooksFile(stateRoot) {
40
+ const handler = (event) => ({ type: "command", command: guideHookCommand(stateRoot, "claude", event), timeout: HANDLER_TIMEOUT_SECONDS });
41
+ return { description: CLAUDE_HOOKS_DESCRIPTION, hooks: {
42
+ SessionStart: [{ hooks: [handler("session-start")] }],
43
+ PreToolUse: [{ matcher: CLAUDE_PRE_TOOL_MATCHER, hooks: [handler("pre-tool")] }],
44
+ } };
45
+ }
46
+ /** The two Codex groups. Never change them: Codex trusts a hook by a hash that covers these fields. */
47
+ export function guideCodexHookGroups(stateRoot) {
48
+ const handler = (event) => ({ type: "command", command: guideHookCommand(stateRoot, "codex", event), timeout: HANDLER_TIMEOUT_SECONDS });
49
+ return { SessionStart: { hooks: [handler("session-start")] }, PreToolUse: { matcher: CODEX_PRE_TOOL_MATCHER, hooks: [handler("pre-tool")] } };
50
+ }
51
+ function isGuideHandler(handler) {
52
+ const command = handler?.command;
53
+ return typeof command === "string" && GUIDE_CODEX_COMMAND.test(command);
54
+ }
55
+ function guideEntries(document) {
56
+ return Object.entries(document.hooks ?? {}).flatMap(([event, groups]) => groups.flatMap(group => group.hooks.filter(isGuideHandler).map(handler => JSON.stringify({ event, handler }))));
57
+ }
58
+ function hasCurrentGuideHooks(document, stateRoot) {
59
+ const ours = guideEntries(document);
60
+ const expected = Object.entries(guideCodexHookGroups(stateRoot)).map(([event, group]) => JSON.stringify({ event, handler: group.hooks[0] }));
61
+ return ours.length === expected.length && expected.every(entry => ours.includes(entry));
62
+ }
63
+ export function withoutGuideCodexHooks(document) {
64
+ const hooks = {};
65
+ let changed = false;
66
+ for (const [event, groups] of Object.entries(document.hooks ?? {})) {
67
+ const kept = [];
68
+ for (const group of groups) {
69
+ const handlers = group.hooks.filter(handler => !isGuideHandler(handler));
70
+ if (handlers.length === group.hooks.length) {
71
+ kept.push(group);
72
+ continue;
73
+ }
74
+ changed = true;
75
+ if (handlers.length > 0)
76
+ kept.push({ ...group, hooks: handlers });
77
+ }
78
+ if (kept.length > 0 || groups.length === 0)
79
+ hooks[event] = kept;
80
+ }
81
+ return { document: document.hooks === undefined ? document : { ...document, hooks }, changed };
82
+ }
83
+ /** The document with the two guide groups appended after everything else; one that already holds them is left as it is. */
84
+ export function withGuideCodexHooks(document, stateRoot) {
85
+ if (hasCurrentGuideHooks(document, stateRoot))
86
+ return { document, changed: false };
87
+ const stripped = withoutGuideCodexHooks(document).document;
88
+ const hooks = { ...stripped.hooks ?? {} };
89
+ for (const [event, group] of Object.entries(guideCodexHookGroups(stateRoot))) {
90
+ hooks[event] = [...hooks[event] ?? [], group];
91
+ }
92
+ const next = { ...stripped, hooks };
93
+ return { document: next, changed: JSON.stringify(next) !== JSON.stringify(document) };
94
+ }
95
+ async function readOptional(path) {
96
+ try {
97
+ return await readFile(path, "utf8");
98
+ }
99
+ catch (error) {
100
+ if (error.code === "ENOENT")
101
+ return null;
102
+ throw error;
103
+ }
104
+ }
105
+ async function writeAtomic(path, content, mode) {
106
+ await mkdir(dirname(path), { recursive: true });
107
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
108
+ try {
109
+ await writeFile(temporary, content, { mode, flag: "wx" });
110
+ await rename(temporary, path);
111
+ }
112
+ catch (error) {
113
+ await rm(temporary, { force: true });
114
+ throw error;
115
+ }
116
+ }
117
+ /** Copies the Codex hooks file aside before it is changed. A backup is never overwritten. */
118
+ async function backup(path, now) {
119
+ const target = `${path}.mtec-ai-local-runner-${new Date(now).toISOString().replace(/[:.]/g, "-")}.bak`;
120
+ await copyFile(path, target, constants.COPYFILE_EXCL);
121
+ return target;
122
+ }
123
+ const pretty = (value) => `${JSON.stringify(value, null, 2)}\n`;
124
+ const pluginDirIn = (configDir) => join(configDir, "skills", GUIDE_PLUGIN_NAME);
125
+ const hooksFileIn = (home) => join(home, "hooks.json");
126
+ const placeIn = (app, dir) => (app === "claude" ? pluginDirIn(dir) : hooksFileIn(dir));
127
+ async function readRecord(stateRoot) {
128
+ const record = { claude_config_dir: [], codex_home: [] };
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(await readFile(join(stateRoot, GUIDE_INTEGRATIONS_FILE), "utf8"));
132
+ }
133
+ catch {
134
+ return record;
135
+ }
136
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
137
+ return record;
138
+ for (const key of [RECORD_KEYS.claude, RECORD_KEYS.codex]) {
139
+ const dirs = parsed[key];
140
+ for (const dir of Array.isArray(dirs) ? dirs : []) {
141
+ if (typeof dir === "string" && isAbsolute(dir) && !record[key].includes(dir))
142
+ record[key].push(dir);
143
+ }
144
+ }
145
+ return record;
146
+ }
147
+ async function saveRecord(stateRoot, record) {
148
+ const path = join(stateRoot, GUIDE_INTEGRATIONS_FILE);
149
+ if (record.claude_config_dir.length === 0 && record.codex_home.length === 0) {
150
+ await rm(path, { force: true });
151
+ return;
152
+ }
153
+ await mkdir(stateRoot, { recursive: true, mode: 0o700 });
154
+ await writeAtomic(path, pretty(record), 0o600);
155
+ }
156
+ async function recordDirectory(app, dir, stateRoot) {
157
+ const record = await readRecord(stateRoot);
158
+ const key = RECORD_KEYS[app];
159
+ if (!record[key].includes(dir))
160
+ await saveRecord(stateRoot, { ...record, [key]: [...record[key], dir] });
161
+ }
162
+ async function placesOf(app, env, stateRoot) {
163
+ const places = [placeIn(app, app === "claude" ? claudeConfigDir(env) : codexHome(env))];
164
+ for (const dir of (await readRecord(stateRoot))[RECORD_KEYS[app]]) {
165
+ const place = placeIn(app, dir);
166
+ if (!places.includes(place))
167
+ places.push(place);
168
+ }
169
+ return places;
170
+ }
171
+ async function claudeState(pluginDir, stateRoot) {
172
+ let manifest;
173
+ let hooks;
174
+ try {
175
+ manifest = await readOptional(join(pluginDir, ".claude-plugin", "plugin.json"));
176
+ hooks = await readOptional(join(pluginDir, "hooks", "hooks.json"));
177
+ }
178
+ catch {
179
+ return "unreadable";
180
+ }
181
+ if (manifest === null && hooks === null)
182
+ return "not_installed";
183
+ return manifest === pretty(guidePluginManifest()) && hooks === pretty(guideClaudeHooksFile(stateRoot)) ? "installed" : "outdated";
184
+ }
185
+ async function codexState(hooksFile, stateRoot) {
186
+ let raw;
187
+ try {
188
+ raw = await readOptional(hooksFile);
189
+ }
190
+ catch {
191
+ return "unreadable";
192
+ }
193
+ if (raw === null)
194
+ return "not_installed";
195
+ let document;
196
+ try {
197
+ document = parseCodexHooks(raw);
198
+ }
199
+ catch {
200
+ return "unreadable";
201
+ }
202
+ if (guideEntries(document).length === 0)
203
+ return "not_installed";
204
+ return hasCurrentGuideHooks(document, stateRoot) ? "installed" : "outdated";
205
+ }
206
+ const STATE_ORDER = ["installed", "outdated", "unreadable", "not_installed"];
207
+ export async function guideStatus(app, env, stateRoot) {
208
+ let best = null;
209
+ for (const place of await placesOf(app, env, stateRoot)) {
210
+ const state = app === "claude" ? await claudeState(place, stateRoot) : await codexState(place, stateRoot);
211
+ if (best === null || STATE_ORDER.indexOf(state) < STATE_ORDER.indexOf(best.state))
212
+ best = { app, state, path: place };
213
+ }
214
+ return best;
215
+ }
216
+ export async function installGuide(app, env, stateRoot, options = {}) {
217
+ const shim = () => writeShim({ stateRoot, nodePath: options.nodePath, clientPath: options.clientPath ?? guideHookClientScript(),
218
+ file: GUIDE_SHIM_FILE, feature: "guide" });
219
+ if (app === "claude") {
220
+ const configDir = claudeConfigDir(env);
221
+ const pluginDir = pluginDirIn(configDir);
222
+ await shim();
223
+ await recordDirectory("claude", configDir, stateRoot);
224
+ if (await claudeState(pluginDir, stateRoot) === "installed")
225
+ return { app, state: "installed", path: pluginDir };
226
+ await writeAtomic(join(pluginDir, ".claude-plugin", "plugin.json"), pretty(guidePluginManifest()), 0o644);
227
+ await writeAtomic(join(pluginDir, "hooks", "hooks.json"), pretty(guideClaudeHooksFile(stateRoot)), 0o644);
228
+ return { app, state: "installed", path: pluginDir };
229
+ }
230
+ const home = codexHome(env);
231
+ const hooksFile = hooksFileIn(home);
232
+ const raw = await readOptional(hooksFile);
233
+ const current = raw === null ? { description: CODEX_FILE_DESCRIPTION } : parseCodexHooks(raw);
234
+ const { document, changed } = withGuideCodexHooks(current, stateRoot);
235
+ await shim();
236
+ await recordDirectory("codex", home, stateRoot);
237
+ if (!changed)
238
+ return { app, state: "installed", path: hooksFile };
239
+ const saved = raw === null ? undefined : await backup(hooksFile, options.now ?? Date.now());
240
+ const mode = raw === null ? 0o600 : (await stat(hooksFile)).mode & 0o777;
241
+ await writeAtomic(hooksFile, pretty(document), mode);
242
+ return { app, state: "installed", path: hooksFile, ...(saved ? { backup: saved } : {}) };
243
+ }
244
+ export async function removeGuide(app, env, stateRoot, options = {}) {
245
+ const now = options.now ?? Date.now();
246
+ const places = await placesOf(app, env, stateRoot);
247
+ let first;
248
+ for (const place of places) {
249
+ if (app === "claude") {
250
+ const manifest = await readOptional(join(place, ".claude-plugin", "plugin.json"));
251
+ // Only a directory this runner wrote is removed.
252
+ if (manifest === null || JSON.parse(manifest).name !== GUIDE_PLUGIN_NAME)
253
+ continue;
254
+ await rm(place, { recursive: true, force: true });
255
+ first ??= { path: place };
256
+ continue;
257
+ }
258
+ const raw = await readOptional(place);
259
+ if (raw === null)
260
+ continue;
261
+ const { document, changed } = withoutGuideCodexHooks(parseCodexHooks(raw));
262
+ if (!changed)
263
+ continue;
264
+ const saved = await backup(place, now);
265
+ if (Object.keys(document.hooks ?? {}).length === 0 && document.description === CODEX_FILE_DESCRIPTION && Object.keys(document).length === 2) {
266
+ await rm(place, { force: true });
267
+ }
268
+ else {
269
+ await writeAtomic(place, pretty(document), (await stat(place)).mode & 0o777);
270
+ }
271
+ first ??= { path: place, backup: saved };
272
+ }
273
+ const record = await readRecord(stateRoot);
274
+ const key = RECORD_KEYS[app];
275
+ const kept = [];
276
+ for (const dir of record[key]) {
277
+ const state = app === "claude" ? await claudeState(placeIn(app, dir), stateRoot) : await codexState(placeIn(app, dir), stateRoot);
278
+ if (state !== "not_installed")
279
+ kept.push(dir);
280
+ }
281
+ if (kept.length !== record[key].length)
282
+ await saveRecord(stateRoot, { ...record, [key]: kept });
283
+ return { app, state: (await guideStatus(app, env, stateRoot)).state, path: first?.path ?? places[0], ...(first?.backup ? { backup: first.backup } : {}) };
284
+ }
285
+ export async function installedGuideApps(env, stateRoot) {
286
+ const apps = [];
287
+ for (const app of GUIDE_APPS)
288
+ if ((await guideStatus(app, env, stateRoot)).state === "installed")
289
+ apps.push(app);
290
+ return apps;
291
+ }
292
+ /** Removes the guide shim once no guide hook is left that runs it. */
293
+ export async function removeGuideShimIfUnused(env, stateRoot) {
294
+ for (const app of GUIDE_APPS)
295
+ if ((await guideStatus(app, env, stateRoot)).state !== "not_installed")
296
+ return false;
297
+ await removeShim(stateRoot, GUIDE_SHIM_FILE);
298
+ return true;
299
+ }
300
+ /**
301
+ * Whether Codex trusts both guide handlers: their "<hooks.json path>:<event>:<group>:<handler>" entries under
302
+ * [hooks.state] in the user config.toml each carry a trusted_hash and are not disabled.
303
+ */
304
+ export async function codexGuideTrust(env) {
305
+ const home = codexHome(env);
306
+ const hooksFile = hooksFileIn(home);
307
+ let document;
308
+ let config;
309
+ try {
310
+ const raw = await readOptional(hooksFile);
311
+ if (raw === null)
312
+ return "no";
313
+ document = parseCodexHooks(raw);
314
+ const toml = await readOptional(join(home, "config.toml"));
315
+ config = toml === null ? {} : parseToml(toml);
316
+ }
317
+ catch {
318
+ return "unknown";
319
+ }
320
+ const keys = [];
321
+ for (const [event, trustEvent] of Object.entries(TRUST_EVENTS)) {
322
+ const groups = document.hooks?.[event] ?? [];
323
+ groups.forEach((group, groupIndex) => group.hooks.forEach((handler, handlerIndex) => {
324
+ if (isGuideHandler(handler))
325
+ keys.push(`${hooksFile}:${trustEvent}:${groupIndex}:${handlerIndex}`);
326
+ }));
327
+ }
328
+ if (keys.length === 0)
329
+ return "no";
330
+ const state = (config.hooks?.state ?? {});
331
+ return keys.every(key => {
332
+ const entry = state[key];
333
+ return typeof entry?.trusted_hash === "string" && entry.trusted_hash !== "" && entry.enabled !== false;
334
+ }) ? "yes" : "no";
335
+ }
@@ -0,0 +1,314 @@
1
+ const KB_DIRECTORIES = new Set(["KnowledgeBase", "ai-work-process-kb", "ai-work-product-specs-kb"]);
2
+ const READ_PROGRAMS = new Set(["cat", "head", "tail", "less", "more", "sed", "awk", "grep", "egrep", "fgrep", "rg",
3
+ "ag", "find", "ls", "wc", "nl", "bat", "jq", "yq", "sort", "uniq", "cut", "diff", "stat", "tree", "file"]);
4
+ const WRITE_PROGRAMS = new Set(["tee", "touch"]);
5
+ const WRAPPERS = new Set(["sudo", "env", "command", "time", "nice", "nohup", "exec"]);
6
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash"]);
7
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
8
+ const DB_PROGRAMS = new Set(["mysql", "mariadb", "psql"]);
9
+ const AWS_VALUE_FLAGS = new Set(["--profile", "--region", "--output", "--endpoint-url", "--query", "--color",
10
+ "--ca-bundle", "--cli-read-timeout", "--cli-connect-timeout"]);
11
+ const SSH_VALUE_FLAGS = new Set(["-p", "-i", "-l", "-o", "-J", "-F", "-L", "-R", "-D", "-b", "-c", "-E", "-e",
12
+ "-m", "-O", "-Q", "-S", "-W", "-w", "-B", "-I"]);
13
+ const NO_VALUE_FLAGS = new Set();
14
+ const READ_STATEMENT = /^\s*(?:select|show|describe|desc|explain)\b/i;
15
+ const WRITE_KEYWORD = /\b(?:insert|update|delete|replace|alter|drop|truncate|create|grant|revoke|rename|load|call)\b/i;
16
+ const QUOTED = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
17
+ const MCP_TOOL = /^mcp__(.+)__([a-z][a-z0-9_]{1,63})$/;
18
+ const PATCH_FILE = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm;
19
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
20
+ export function isKnowledgeBasePath(path) {
21
+ return path.split("/").some(segment => KB_DIRECTORIES.has(segment));
22
+ }
23
+ /** The part of a KnowledgeBase path from its KnowledgeBase directory down, for local detail lines: never the folders above it. */
24
+ export function knowledgeBaseTail(path) {
25
+ const segments = path.split("/");
26
+ const index = segments.findIndex(segment => KB_DIRECTORIES.has(segment));
27
+ return index < 0 ? "" : segments.slice(index).join("/");
28
+ }
29
+ /**
30
+ * The words of each simple command in a shell line, split at &&, ||, ;, |, & and newlines. Quotes and backslashes are
31
+ * honoured. A redirect is kept as its own word, ">" for > and >> and "<" for <; a descriptor redirect such as 2>&1 is
32
+ * dropped.
33
+ */
34
+ export function shellCommands(line) {
35
+ const commands = [];
36
+ let words = [];
37
+ let word = "";
38
+ let inWord = false;
39
+ const endWord = () => { if (inWord)
40
+ words.push(word); word = ""; inWord = false; };
41
+ const endCommand = () => { endWord(); if (words.length > 0)
42
+ commands.push(words); words = []; };
43
+ for (let index = 0; index < line.length; index += 1) {
44
+ const char = line[index];
45
+ if (char === "'") {
46
+ const close = line.indexOf("'", index + 1);
47
+ const end = close < 0 ? line.length : close;
48
+ word += line.slice(index + 1, end);
49
+ inWord = true;
50
+ index = end;
51
+ }
52
+ else if (char === '"') {
53
+ inWord = true;
54
+ index += 1;
55
+ while (index < line.length && line[index] !== '"') {
56
+ if (line[index] === "\\" && index + 1 < line.length && '"\\$`'.includes(line[index + 1]))
57
+ index += 1;
58
+ word += line[index];
59
+ index += 1;
60
+ }
61
+ }
62
+ else if (char === "\\") {
63
+ if (index + 1 < line.length && line[index + 1] !== "\n") {
64
+ word += line[index + 1];
65
+ inWord = true;
66
+ }
67
+ index += 1;
68
+ }
69
+ else if (char === "\n" || char === ";") {
70
+ endCommand();
71
+ }
72
+ else if (char === "&" || char === "|") {
73
+ endCommand();
74
+ if (line[index + 1] === char)
75
+ index += 1;
76
+ }
77
+ else if (char === ">" || char === "<") {
78
+ // A descriptor number written right before the redirect, as in 2>, is not a word.
79
+ if (inWord && /^[0-9]+$/.test(word)) {
80
+ word = "";
81
+ inWord = false;
82
+ }
83
+ else
84
+ endWord();
85
+ if (char === ">" && line[index + 1] === ">")
86
+ index += 1;
87
+ if (line[index + 1] === "&") {
88
+ index += 1;
89
+ while (index + 1 < line.length && /[0-9-]/.test(line[index + 1]))
90
+ index += 1;
91
+ continue;
92
+ }
93
+ words.push(char);
94
+ }
95
+ else if (char === " " || char === "\t") {
96
+ endWord();
97
+ }
98
+ else {
99
+ word += char;
100
+ inWord = true;
101
+ }
102
+ }
103
+ endCommand();
104
+ return commands;
105
+ }
106
+ const baseName = (word) => word.slice(word.lastIndexOf("/") + 1);
107
+ /** The program after assignments and wrappers (sudo, env, rtk, rtk proxy and the like), its arguments and its redirect targets. */
108
+ function commandOf(words) {
109
+ let index = 0;
110
+ const skipAssignments = () => { while (index < words.length && ASSIGNMENT.test(words[index]))
111
+ index += 1; };
112
+ skipAssignments();
113
+ for (let guard = 0; guard < 8 && index < words.length; guard += 1) {
114
+ const name = baseName(words[index]);
115
+ if (name === "rtk") {
116
+ index += 1;
117
+ if (words[index] === "proxy")
118
+ index += 1;
119
+ continue;
120
+ }
121
+ if (!WRAPPERS.has(name))
122
+ break;
123
+ index += 1;
124
+ while (index < words.length && words[index].startsWith("-"))
125
+ index += 1;
126
+ skipAssignments();
127
+ }
128
+ const program = words[index];
129
+ if (program === undefined)
130
+ return null;
131
+ const args = [];
132
+ const writes = [];
133
+ for (let next = index + 1; next < words.length; next += 1) {
134
+ const current = words[next];
135
+ const target = words[next + 1];
136
+ if (current === ">" || current === "<") {
137
+ if (target !== undefined)
138
+ (current === ">" ? writes : args).push(target);
139
+ next += 1;
140
+ continue;
141
+ }
142
+ args.push(current);
143
+ }
144
+ return { program: baseName(program), args, writes };
145
+ }
146
+ /** The words that are neither flags nor flag values, in order. A --name=value flag takes no extra word. */
147
+ function positional(args, valueFlags) {
148
+ const words = [];
149
+ for (let index = 0; index < args.length; index += 1) {
150
+ const arg = args[index];
151
+ if (arg.startsWith("-")) {
152
+ if (valueFlags.has(arg))
153
+ index += 1;
154
+ continue;
155
+ }
156
+ words.push(arg);
157
+ }
158
+ return words;
159
+ }
160
+ function databaseHost(program, args) {
161
+ for (let index = 0; index < args.length; index += 1) {
162
+ const arg = args[index];
163
+ if (arg === "-h" || arg === "--host")
164
+ return (args[index + 1] ?? "").toLowerCase() || null;
165
+ if (arg.startsWith("--host="))
166
+ return arg.slice("--host=".length).toLowerCase() || null;
167
+ if (program !== "psql" && /^-h./.test(arg))
168
+ return arg.slice(2).toLowerCase();
169
+ if (program === "psql") {
170
+ const uri = /^postgres(?:ql)?:\/\/(?:[^@/]*@)?([^/:?]+)/i.exec(arg);
171
+ if (uri)
172
+ return uri[1].toLowerCase();
173
+ const conninfo = /(?:^|\s)host=(\S+)/.exec(arg);
174
+ if (conninfo)
175
+ return conninfo[1].toLowerCase();
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+ function databaseStatement(program, args) {
181
+ const short = program === "psql" ? "-c" : "-e";
182
+ const long = program === "psql" ? "--command" : "--execute";
183
+ for (let index = 0; index < args.length; index += 1) {
184
+ const arg = args[index];
185
+ if (arg === short || arg === long)
186
+ return args[index + 1] ?? null;
187
+ if (arg.startsWith(`${long}=`))
188
+ return arg.slice(long.length + 1);
189
+ if (program !== "psql" && arg.startsWith("-e") && arg.length > 2)
190
+ return arg.slice(2);
191
+ }
192
+ return null;
193
+ }
194
+ /** A database command against a named host that is not this Mac: a read only when its statement is plainly one. */
195
+ function databaseRule(program, args) {
196
+ const host = databaseHost(program, args);
197
+ if (host === null || LOCAL_HOSTS.has(host))
198
+ return null;
199
+ const statement = databaseStatement(program, args);
200
+ const read = statement !== null && READ_STATEMENT.test(statement) && !WRITE_KEYWORD.test(statement.replace(QUOTED, "''"));
201
+ return { kind: "cli", rule: read ? "cli_db_read" : "cli_db_write", subject: program };
202
+ }
203
+ function cliRule(program, args) {
204
+ if (program === "aws") {
205
+ const [service, subcommand, ...rest] = positional(args, AWS_VALUE_FLAGS);
206
+ if (service === "logs")
207
+ return { kind: "cli", rule: "cli_aws_logs", subject: "aws logs" };
208
+ if (service === "s3" && (subcommand === "ls" || (subcommand === "cp" && rest[0]?.startsWith("s3://") === true && rest[1]?.startsWith("s3://") !== true))) {
209
+ return { kind: "cli", rule: "cli_aws_s3_read", subject: "aws s3" };
210
+ }
211
+ if (service === "s3api" && (subcommand === "get-object" || subcommand?.startsWith("list-objects") === true)) {
212
+ return { kind: "cli", rule: "cli_aws_s3_read", subject: "aws s3api" };
213
+ }
214
+ return null;
215
+ }
216
+ if (program === "vapor")
217
+ return positional(args, NO_VALUE_FLAGS)[0] === "deploy" ? { kind: "cli", rule: "cli_vapor_deploy", subject: "vapor deploy" } : null;
218
+ if (program === "ssh") {
219
+ const target = positional(args, SSH_VALUE_FLAGS)[0];
220
+ if (target === undefined)
221
+ return null;
222
+ return LOCAL_HOSTS.has(target.slice(target.lastIndexOf("@") + 1).toLowerCase()) ? null : { kind: "cli", rule: "cli_ssh", subject: "ssh" };
223
+ }
224
+ return DB_PROGRAMS.has(program) ? databaseRule(program, args) : null;
225
+ }
226
+ /** Findings of one shell line: KnowledgeBase reads and writes and CLI rules, looking one level into sh -c scripts. */
227
+ export function classifyShell(line, depth = 0) {
228
+ const findings = [];
229
+ for (const words of shellCommands(line)) {
230
+ const command = commandOf(words);
231
+ if (command === null)
232
+ continue;
233
+ const { program, args, writes } = command;
234
+ if (depth === 0 && SHELLS.has(program)) {
235
+ const flag = args.findIndex(arg => /^-[a-z]*c$/.test(arg));
236
+ const script = flag >= 0 ? args[flag + 1] : undefined;
237
+ if (script !== undefined) {
238
+ findings.push(...classifyShell(script, 1));
239
+ continue;
240
+ }
241
+ }
242
+ const inPlace = program === "sed" && args.some(arg => arg.startsWith("-i") || arg === "--in-place");
243
+ const named = args.filter(isKnowledgeBasePath);
244
+ const written = [...writes.filter(isKnowledgeBasePath), ...(inPlace || WRITE_PROGRAMS.has(program) ? named : [])];
245
+ if (written.length > 0)
246
+ findings.push({ kind: "kb_write", paths: written });
247
+ if (named.length > 0 && READ_PROGRAMS.has(program) && !inPlace)
248
+ findings.push({ kind: "kb_read", readKind: "shell", paths: named });
249
+ const cli = cliRule(program, args);
250
+ if (cli !== null)
251
+ findings.push(cli);
252
+ }
253
+ return findings;
254
+ }
255
+ /** The shell line of a Bash or exec_command input: command or cmd, a string or an argument list. */
256
+ function shellLine(input) {
257
+ for (const key of ["command", "cmd"]) {
258
+ const value = input[key];
259
+ if (typeof value === "string")
260
+ return value;
261
+ if (Array.isArray(value) && value.length > 0 && value.every(part => typeof part === "string")) {
262
+ const parts = value;
263
+ if (parts.length >= 3 && SHELLS.has(baseName(parts[0])) && /^-[a-z]*c$/.test(parts[1]))
264
+ return parts[2];
265
+ return parts.map(part => (/^[A-Za-z0-9_./:=@%+-]+$/.test(part) ? part : `'${part.replace(/'/g, `'\\''`)}'`)).join(" ");
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+ /**
271
+ * What one PreToolUse call shows (spec section 3.3). An MCP tool counts as knowledge_search or another Gateway tool by
272
+ * its last name segment. Null when the input cannot be classified: the hook counts it as undecidable.
273
+ */
274
+ export function classifyToolCall(_app, toolName, toolInput, gatewayToolNames) {
275
+ if (typeof toolName !== "string")
276
+ return null;
277
+ const mcp = MCP_TOOL.exec(toolName);
278
+ if (mcp) {
279
+ const findings = [];
280
+ if (mcp[2] === "knowledge_search")
281
+ findings.push({ kind: "knowledge_search" });
282
+ if (gatewayToolNames.includes(mcp[2]))
283
+ findings.push({ kind: "gateway_tool" });
284
+ return findings;
285
+ }
286
+ if (toolInput === null || typeof toolInput !== "object" || Array.isArray(toolInput))
287
+ return null;
288
+ const input = toolInput;
289
+ const file = typeof input.file_path === "string" ? input.file_path : null;
290
+ switch (toolName) {
291
+ case "Read":
292
+ return file !== null && isKnowledgeBasePath(file) ? [{ kind: "kb_read", readKind: "read_tool", paths: [file] }] : [];
293
+ case "Grep":
294
+ case "Glob": {
295
+ const paths = [input.path, input.glob, toolName === "Glob" ? input.pattern : undefined]
296
+ .filter((value) => typeof value === "string" && isKnowledgeBasePath(value));
297
+ return paths.length > 0 ? [{ kind: "kb_read", readKind: "search_tool", paths }] : [];
298
+ }
299
+ case "Edit":
300
+ case "Write":
301
+ return file !== null && isKnowledgeBasePath(file) ? [{ kind: "kb_write", paths: [file] }] : [];
302
+ case "apply_patch": {
303
+ const patch = typeof input.command === "string" ? input.command : typeof input.input === "string" ? input.input : "";
304
+ const paths = [...patch.matchAll(PATCH_FILE)].map(match => match[1].trim()).filter(isKnowledgeBasePath);
305
+ return paths.length > 0 ? [{ kind: "kb_write", paths }] : [];
306
+ }
307
+ case "Bash": {
308
+ const line = shellLine(input);
309
+ return line === null ? null : classifyShell(line);
310
+ }
311
+ default:
312
+ return [];
313
+ }
314
+ }