botholomew 0.23.0 → 0.24.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.
@@ -0,0 +1,130 @@
1
+ import ansis from "ansis";
2
+ import type { Command } from "commander";
3
+ import { decideAndRequeue } from "../approvals/decide.ts";
4
+ import {
5
+ APPROVAL_STATUSES,
6
+ type Approval,
7
+ type ApprovalStatus,
8
+ } from "../approvals/schema.ts";
9
+ import { getApproval, listApprovals } from "../approvals/store.ts";
10
+ import { logger } from "../utils/logger.ts";
11
+
12
+ function statusColor(status: ApprovalStatus): string {
13
+ switch (status) {
14
+ case "approved":
15
+ return ansis.green(status);
16
+ case "denied":
17
+ return ansis.red(status);
18
+ case "pending":
19
+ return ansis.yellow(status);
20
+ }
21
+ }
22
+
23
+ function printApproval(a: Approval) {
24
+ console.log(
25
+ `${ansis.bold(a.id.slice(0, 8))} ${statusColor(a.status).padEnd(18)} ${ansis.cyan(`${a.server}/${a.tool}`)}`,
26
+ );
27
+ console.log(` args: ${a.args}`);
28
+ if (a.task_id) console.log(` task: ${a.task_id}`);
29
+ if (a.reason) console.log(` reason: ${a.reason}`);
30
+ console.log(` created: ${a.created_at}`);
31
+ if (a.decided_at) {
32
+ console.log(` decided: ${a.decided_at} by ${a.decided_by ?? "?"}`);
33
+ }
34
+ }
35
+
36
+ export function registerApprovalCommand(program: Command) {
37
+ const approval = program
38
+ .command("approval")
39
+ .description("Review and decide pending mcpx tool-call approvals");
40
+
41
+ approval
42
+ .command("list")
43
+ .description("List approval requests (newest first)")
44
+ .option(
45
+ "-s, --status <status>",
46
+ `filter by status (${APPROVAL_STATUSES.join("|")})`,
47
+ )
48
+ .option("-l, --limit <n>", "max number of approvals", Number.parseInt)
49
+ .option("-o, --offset <n>", "skip first N approvals", Number.parseInt)
50
+ .action(
51
+ async (opts: {
52
+ status?: ApprovalStatus;
53
+ limit?: number;
54
+ offset?: number;
55
+ }) => {
56
+ if (opts.status && !APPROVAL_STATUSES.includes(opts.status)) {
57
+ logger.error(
58
+ `Unknown status: ${opts.status}. Use one of: ${APPROVAL_STATUSES.join(", ")}`,
59
+ );
60
+ process.exit(1);
61
+ }
62
+ const dir = program.opts().dir;
63
+ const approvals = await listApprovals(dir, {
64
+ status: opts.status,
65
+ limit: opts.limit,
66
+ offset: opts.offset,
67
+ });
68
+ if (approvals.length === 0) {
69
+ logger.dim("No approvals found.");
70
+ return;
71
+ }
72
+ for (const a of approvals) {
73
+ printApproval(a);
74
+ console.log("");
75
+ }
76
+ console.log(ansis.dim(`${approvals.length} approval(s)`));
77
+ },
78
+ );
79
+
80
+ approval
81
+ .command("view <id>")
82
+ .description("Show a single approval request")
83
+ .action(async (id: string) => {
84
+ const dir = program.opts().dir;
85
+ const a = await getApproval(dir, id);
86
+ if (!a) {
87
+ logger.error(`No approval found with id ${id}.`);
88
+ process.exit(1);
89
+ }
90
+ printApproval(a);
91
+ });
92
+
93
+ approval
94
+ .command("approve <id>")
95
+ .description("Approve a pending request and re-queue its task")
96
+ .action(async (id: string) => {
97
+ const dir = program.opts().dir;
98
+ const a = await getApproval(dir, id);
99
+ if (!a) {
100
+ logger.error(`No approval found with id ${id}.`);
101
+ process.exit(1);
102
+ }
103
+ if (a.status !== "pending") {
104
+ logger.warn(`Approval ${id} is already ${a.status}.`);
105
+ return;
106
+ }
107
+ await decideAndRequeue(dir, id, "approved", "cli");
108
+ logger.success(`Approved ${a.server}/${a.tool} (${id}).`);
109
+ if (a.task_id) logger.dim(`Re-queued task ${a.task_id} (now pending).`);
110
+ });
111
+
112
+ approval
113
+ .command("deny <id>")
114
+ .description("Deny a pending request and re-queue its task to recover")
115
+ .action(async (id: string) => {
116
+ const dir = program.opts().dir;
117
+ const a = await getApproval(dir, id);
118
+ if (!a) {
119
+ logger.error(`No approval found with id ${id}.`);
120
+ process.exit(1);
121
+ }
122
+ if (a.status !== "pending") {
123
+ logger.warn(`Approval ${id} is already ${a.status}.`);
124
+ return;
125
+ }
126
+ await decideAndRequeue(dir, id, "denied", "cli");
127
+ logger.success(`Denied ${a.server}/${a.tool} (${id}).`);
128
+ if (a.task_id) logger.dim(`Re-queued task ${a.task_id} (now pending).`);
129
+ });
130
+ }
@@ -8,8 +8,9 @@ export function registerChatCommand(program: Command) {
8
8
  "Open the interactive chat TUI\n\n" +
9
9
  " Tab navigation (Ctrl+<letter> from any tab):\n" +
10
10
  " Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
11
- " Ctrl+o Tools Ctrl+e Threads Ctrl+g Help\n" +
12
- " Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
11
+ " Ctrl+o Tools Ctrl+e Threads Ctrl+p Approvals\n" +
12
+ " Ctrl+n Context Ctrl+s Schedules Ctrl+g Help\n" +
13
+ " Esc Return to Chat\n\n" +
13
14
  " Refresh: Ctrl+R refreshes Context · Tasks · Threads · Schedules · Workers\n\n" +
14
15
  " Chat input:\n" +
15
16
  " Enter Send message\n" +
@@ -27,37 +28,49 @@ export function registerChatCommand(program: Command) {
27
28
  )
28
29
  .option("--thread-id <id>", "Resume an existing chat thread")
29
30
  .option("-p, --prompt <text>", "Start chat with an initial prompt")
30
- .action(async (opts: { threadId?: string; prompt?: string }) => {
31
- const { render } = await import("ink");
32
- const React = await import("react");
33
- const { App } = await import("../tui/App.tsx");
34
- const dir = program.opts().dir;
35
- const config = await loadConfig(dir);
36
- const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
31
+ .option(
32
+ "--unsafe",
33
+ "bypass the mcpx approval gate (allow every tool without approval)",
34
+ false,
35
+ )
36
+ .action(
37
+ async (opts: {
38
+ threadId?: string;
39
+ prompt?: string;
40
+ unsafe?: boolean;
41
+ }) => {
42
+ const { render } = await import("ink");
43
+ const React = await import("react");
44
+ const { App } = await import("../tui/App.tsx");
45
+ const dir = program.opts().dir;
46
+ const config = await loadConfig(dir);
47
+ const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
37
48
 
38
- // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
39
- // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
40
- // capture. Use "disabled" mode in capture to keep text input working;
41
- // captures that need Tab/Escape should use the `-p` prompt flag or
42
- // a /slash command typed as text instead.
43
- const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
44
- const instance = render(
45
- React.createElement(App, {
46
- projectDir: dir,
47
- threadId: opts.threadId,
48
- initialPrompt: opts.prompt,
49
- idleTimeoutMs,
50
- }),
51
- {
52
- exitOnCtrlC: false,
53
- kittyKeyboard: isCapture
54
- ? { mode: "disabled" }
55
- : {
56
- mode: "enabled",
57
- flags: ["disambiguateEscapeCodes"],
58
- },
59
- },
60
- );
61
- await instance.waitUntilExit();
62
- });
49
+ // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
50
+ // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
51
+ // capture. Use "disabled" mode in capture to keep text input working;
52
+ // captures that need Tab/Escape should use the `-p` prompt flag or
53
+ // a /slash command typed as text instead.
54
+ const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
55
+ const instance = render(
56
+ React.createElement(App, {
57
+ projectDir: dir,
58
+ threadId: opts.threadId,
59
+ initialPrompt: opts.prompt,
60
+ idleTimeoutMs,
61
+ unsafe: opts.unsafe,
62
+ }),
63
+ {
64
+ exitOnCtrlC: false,
65
+ kittyKeyboard: isCapture
66
+ ? { mode: "disabled" }
67
+ : {
68
+ mode: "enabled",
69
+ flags: ["disambiguateEscapeCodes"],
70
+ },
71
+ },
72
+ );
73
+ await instance.waitUntilExit();
74
+ },
75
+ );
63
76
  }
@@ -2,8 +2,14 @@ import { rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import ansis from "ansis";
4
4
  import type { Command } from "commander";
5
+ import { deleteAllApprovals } from "../approvals/store.ts";
5
6
  import { loadConfig } from "../config/loader.ts";
6
- import { SCHEDULES_DIR, TASKS_DIR, THREADS_DIR } from "../constants.ts";
7
+ import {
8
+ APPROVALS_DIR,
9
+ SCHEDULES_DIR,
10
+ TASKS_DIR,
11
+ THREADS_DIR,
12
+ } from "../constants.ts";
7
13
  import { openMembot, resolveMembotDir } from "../mem/client.ts";
8
14
  import { deleteAllSchedules } from "../schedules/store.ts";
9
15
  import { deleteAllTasks } from "../tasks/store.ts";
@@ -86,6 +92,10 @@ async function runNuke(projectDir: string, scope: NukeScope): Promise<void> {
86
92
  `Deleted ${threads} threads (${interactions} interactions) from ${THREADS_DIR}/`,
87
93
  );
88
94
  }
95
+ if (scope === "all") {
96
+ const n = await deleteAllApprovals(projectDir);
97
+ logger.success(`Deleted ${n} approval file(s) from ${APPROVALS_DIR}/`);
98
+ }
89
99
  }
90
100
 
91
101
  function registerScope(
@@ -151,6 +161,6 @@ export function registerNukeCommand(program: Command) {
151
161
  program,
152
162
  nuke,
153
163
  "all",
154
- "Erase all agent-writable data: membot store, tasks/, schedules/, threads/",
164
+ "Erase all agent-writable data: membot store, tasks/, schedules/, threads/, approvals/",
155
165
  );
156
166
  }
@@ -64,11 +64,17 @@ export function registerWorkerCommand(program: Command) {
64
64
  "run exactly this task (implies one-shot; incompatible with --persist)",
65
65
  )
66
66
  .option("--no-eval-schedules", "skip schedule evaluation this run")
67
+ .option(
68
+ "--unsafe",
69
+ "bypass the mcpx approval gate (allow every tool without approval)",
70
+ false,
71
+ )
67
72
  .action(
68
73
  async (opts: {
69
74
  persist?: boolean;
70
75
  taskId?: string;
71
76
  evalSchedules?: boolean;
77
+ unsafe?: boolean;
72
78
  }) => {
73
79
  if (opts.persist && opts.taskId) {
74
80
  logger.error("--persist and --task-id are mutually exclusive.");
@@ -81,6 +87,7 @@ export function registerWorkerCommand(program: Command) {
81
87
  mode: opts.persist ? "persist" : "once",
82
88
  taskId: opts.taskId,
83
89
  evalSchedules: opts.evalSchedules,
90
+ unsafe: opts.unsafe,
84
91
  });
85
92
  },
86
93
  );
@@ -90,18 +97,30 @@ export function registerWorkerCommand(program: Command) {
90
97
  .description("Spawn a worker as a detached background process")
91
98
  .option("--persist", "keep running, looping over the tick cycle", false)
92
99
  .option("--task-id <id>", "run exactly this task (implies one-shot)")
93
- .action(async (opts: { persist?: boolean; taskId?: string }) => {
94
- if (opts.persist && opts.taskId) {
95
- logger.error("--persist and --task-id are mutually exclusive.");
96
- process.exit(1);
97
- }
98
- const dir = program.opts().dir;
99
- const { spawnWorker } = await import("../worker/spawn.ts");
100
- await spawnWorker(dir, {
101
- mode: opts.persist ? "persist" : "once",
102
- taskId: opts.taskId,
103
- });
104
- });
100
+ .option(
101
+ "--unsafe",
102
+ "bypass the mcpx approval gate (allow every tool without approval)",
103
+ false,
104
+ )
105
+ .action(
106
+ async (opts: {
107
+ persist?: boolean;
108
+ taskId?: string;
109
+ unsafe?: boolean;
110
+ }) => {
111
+ if (opts.persist && opts.taskId) {
112
+ logger.error("--persist and --task-id are mutually exclusive.");
113
+ process.exit(1);
114
+ }
115
+ const dir = program.opts().dir;
116
+ const { spawnWorker } = await import("../worker/spawn.ts");
117
+ await spawnWorker(dir, {
118
+ mode: opts.persist ? "persist" : "once",
119
+ taskId: opts.taskId,
120
+ unsafe: opts.unsafe,
121
+ });
122
+ },
123
+ );
105
124
 
106
125
  worker
107
126
  .command("list")
@@ -3,6 +3,7 @@ import { getConfigPath } from "../constants.ts";
3
3
  import { setLogLevel } from "../utils/logger.ts";
4
4
  import {
5
5
  type BotholomewConfig,
6
+ DEFAULT_APPROVALS,
6
7
  DEFAULT_CHUNKER_LLM,
7
8
  DEFAULT_CONFIG,
8
9
  DEFAULT_LLM,
@@ -60,6 +61,9 @@ export async function loadConfig(
60
61
  ...userConfig,
61
62
  llm: mergeLlmBlock(DEFAULT_LLM, userConfig.llm),
62
63
  chunker_llm: mergeLlmBlock(DEFAULT_CHUNKER_LLM, userConfig.chunker_llm),
64
+ // Deep-merge so a config predating the approval gate (or only overriding
65
+ // one key) still gets the safe defaults — and back-compat keeps the gate ON.
66
+ approvals: { ...DEFAULT_APPROVALS, ...(userConfig.approvals ?? {}) },
63
67
  };
64
68
 
65
69
  const config = applyEnvOverrides(merged);
@@ -101,3 +105,26 @@ export async function saveConfig(
101
105
  const configPath = getConfigPath(projectDir);
102
106
  await Bun.write(configPath, `${JSON.stringify(config, null, 2)}\n`);
103
107
  }
108
+
109
+ /**
110
+ * Append an mcpx tool pattern to `approvals.allowed_tools` on disk, preserving
111
+ * every other key in the file (a surgical merge, not a full rewrite of merged
112
+ * defaults). Used by the chat TUI's "always allow" decision. No-op if the
113
+ * pattern is already present.
114
+ */
115
+ export async function addAllowedTool(
116
+ projectDir: string,
117
+ pattern: string,
118
+ ): Promise<void> {
119
+ const configPath = getConfigPath(projectDir);
120
+ const file = Bun.file(configPath);
121
+ const raw: Record<string, unknown> = (await file.exists())
122
+ ? JSON.parse(await file.text())
123
+ : {};
124
+ if (!raw.approvals || typeof raw.approvals !== "object") raw.approvals = {};
125
+ const approvals = raw.approvals as Record<string, unknown>;
126
+ if (!Array.isArray(approvals.allowed_tools)) approvals.allowed_tools = [];
127
+ const allowed = approvals.allowed_tools as string[];
128
+ if (!allowed.includes(pattern)) allowed.push(pattern);
129
+ await Bun.write(configPath, `${JSON.stringify(raw, null, 2)}\n`);
130
+ }
@@ -14,9 +14,30 @@ export interface LlmBlock {
14
14
  supports_tools: boolean;
15
15
  }
16
16
 
17
+ /**
18
+ * Human-in-the-loop approval gate for outbound mcpx tool calls. The gate is
19
+ * ON by default (`enabled: true`) and gates **every** mcpx tool — users opt
20
+ * specific tools out via `allowed_tools`. A run launched with `--unsafe`
21
+ * bypasses the gate entirely (see `buildApprovalPolicy` in `src/mcpx/client.ts`).
22
+ */
23
+ export interface ApprovalConfig {
24
+ /** Master switch. When false the gate is off (equivalent to running `--unsafe`). Default true. */
25
+ enabled: boolean;
26
+ /**
27
+ * Opt-in allowlist of tools that run WITHOUT approval. Patterns match against
28
+ * "server/tool": exact ("gmail/send_email"), wildcards on either side
29
+ * ("gmail/" + star, or star + "/search"), or a "/regex/" tested against the
30
+ * tool name. Empty (default) ⇒ gate everything.
31
+ */
32
+ allowed_tools: string[];
33
+ /** Convenience: also skip the gate for tools the server annotates `readOnlyHint: true`. Default false. */
34
+ auto_allow_read_only: boolean;
35
+ }
36
+
17
37
  export interface BotholomewConfig {
18
38
  llm: LlmBlock;
19
39
  chunker_llm: LlmBlock;
40
+ approvals: ApprovalConfig;
20
41
  embedding_model: string;
21
42
  embedding_dimension: number;
22
43
  tick_interval_seconds: number;
@@ -51,9 +72,16 @@ export const DEFAULT_CHUNKER_LLM: LlmBlock = {
51
72
  model: "claude-haiku-4-5-20251001",
52
73
  };
53
74
 
75
+ export const DEFAULT_APPROVALS: ApprovalConfig = {
76
+ enabled: true,
77
+ allowed_tools: [],
78
+ auto_allow_read_only: false,
79
+ };
80
+
54
81
  export const DEFAULT_CONFIG: BotholomewConfig = {
55
82
  llm: DEFAULT_LLM,
56
83
  chunker_llm: DEFAULT_CHUNKER_LLM,
84
+ approvals: DEFAULT_APPROVALS,
57
85
  embedding_model: "Xenova/bge-small-en-v1.5",
58
86
  embedding_dimension: 384,
59
87
  tick_interval_seconds: 300,
package/src/constants.ts CHANGED
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  * tasks/.locks/<id>.lock O_EXCL claim files
16
16
  * schedules/<id>.md
17
17
  * schedules/.locks/<id>.lock
18
+ * approvals/<id>.md pending/decided mcpx approval requests
18
19
  * threads/<YYYY-MM-DD>/<id>.csv conversation history
19
20
  * workers/<id>.json pidfile + heartbeat
20
21
  * logs/ worker logs
@@ -42,6 +43,7 @@ export const SKILLS_DIR = "skills";
42
43
  export const MCPX_DIR = "mcpx";
43
44
  export const TASKS_DIR = "tasks";
44
45
  export const SCHEDULES_DIR = "schedules";
46
+ export const APPROVALS_DIR = "approvals";
45
47
  export const LOCKS_SUBDIR = ".locks";
46
48
  export const LOGS_DIR = "logs";
47
49
  export const WORKERS_DIR = "workers";
@@ -106,6 +108,10 @@ export function getSchedulesDir(projectDir: string): string {
106
108
  return join(projectDir, SCHEDULES_DIR);
107
109
  }
108
110
 
111
+ export function getApprovalsDir(projectDir: string): string {
112
+ return join(projectDir, APPROVALS_DIR);
113
+ }
114
+
109
115
  export function getSchedulesLockDir(projectDir: string): string {
110
116
  return join(projectDir, SCHEDULES_DIR, LOCKS_SUBDIR);
111
117
  }
package/src/init/index.ts CHANGED
@@ -5,6 +5,7 @@ import type { LlmProvider } from "../config/schemas.ts";
5
5
  import {
6
6
  CONFIG_DIR,
7
7
  CONFIG_FILENAME,
8
+ getApprovalsDir,
8
9
  getConfigPath,
9
10
  getMcpxDir,
10
11
  getPromptsDir,
@@ -74,6 +75,7 @@ export async function initProject(
74
75
  await mkdir(getTasksLockDir(projectDir), { recursive: true });
75
76
  await mkdir(getSchedulesDir(projectDir), { recursive: true });
76
77
  await mkdir(getSchedulesLockDir(projectDir), { recursive: true });
78
+ await mkdir(getApprovalsDir(projectDir), { recursive: true });
77
79
  await mkdir(getWorkersDir(projectDir), { recursive: true });
78
80
  await mkdir(getThreadsDir(projectDir), { recursive: true });
79
81
  await mkdir(join(projectDir, LOGS_DIR), { recursive: true });
@@ -150,6 +152,9 @@ export async function initProject(
150
152
  logger.dim(` ${TASKS_DIR}/ one markdown file per task`);
151
153
  logger.dim(` ${LOCKS_SUBDIR}/ worker claim lockfiles`);
152
154
  logger.dim(` ${SCHEDULES_DIR}/ one markdown file per schedule`);
155
+ logger.dim(
156
+ ` approvals/ one markdown file per gated tool-call request`,
157
+ );
153
158
  logger.dim(` threads/ one CSV per conversation, by UTC date`);
154
159
  logger.dim(` workers/ one JSON pidfile per worker (heartbeats)`);
155
160
  logger.dim(` skills/, mcpx/, logs/`);
@@ -1,7 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { type CallToolResult, McpxClient } from "@evantahler/mcpx";
4
+ import {
5
+ type ApprovalPolicy,
6
+ type CallToolResult,
7
+ isWriteable,
8
+ McpxClient,
9
+ type Tool,
10
+ type ToolApprovalCallback,
11
+ } from "@evantahler/mcpx";
5
12
  import type { BotholomewConfig } from "../config/schemas.ts";
6
13
  import { getMcpxDir, MCPX_SERVERS_FILENAME } from "../constants.ts";
7
14
 
@@ -19,13 +26,24 @@ export function resolveMcpxDir(
19
26
  : join(homedir(), ".mcpx");
20
27
  }
21
28
 
29
+ export interface McpxApprovalOptions {
30
+ /** mcpx approval policy. Omit/undefined ⇒ no gate (back-compat). */
31
+ approvalPolicy?: ApprovalPolicy;
32
+ /** Callback invoked when a gated tool is about to run. */
33
+ onApprovalRequired?: ToolApprovalCallback;
34
+ }
35
+
22
36
  /**
23
37
  * Create an McpxClient from `<mcpxDir>/servers.json`. Returns null if the
24
38
  * file is missing or has no servers configured. The caller is responsible
25
39
  * for resolving `mcpxDir` via `resolveMcpxDir`.
40
+ *
41
+ * Pass `approval` to wire the human-in-the-loop approval gate (see
42
+ * `buildApprovalPolicy`). When omitted the client gates nothing.
26
43
  */
27
44
  export async function createMcpxClient(
28
45
  mcpxDir: string,
46
+ approval: McpxApprovalOptions = {},
29
47
  ): Promise<McpxClient | null> {
30
48
  const serversPath = join(mcpxDir, MCPX_SERVERS_FILENAME);
31
49
  if (!existsSync(serversPath)) return null;
@@ -52,9 +70,73 @@ export async function createMcpxClient(
52
70
  auth,
53
71
  searchIndex,
54
72
  configDir: mcpxDir,
73
+ approvalPolicy: approval.approvalPolicy,
74
+ onApprovalRequired: approval.onApprovalRequired,
55
75
  });
56
76
  }
57
77
 
78
+ /**
79
+ * Translate the Botholomew `approvals` config into an mcpx `ApprovalPolicy`.
80
+ *
81
+ * The gate is ON by default and gates **every** mcpx tool; the predicate
82
+ * returns `true` (require approval) for any tool NOT covered by the allowlist
83
+ * (and, when `auto_allow_read_only`, not annotated read-only). Returns
84
+ * `undefined` — meaning "gate nothing", mcpx's zero-overhead path — when the
85
+ * run is `--unsafe` or `approvals.enabled` is false.
86
+ */
87
+ export function buildApprovalPolicy(
88
+ config: Pick<BotholomewConfig, "approvals">,
89
+ opts: { unsafe?: boolean } = {},
90
+ ): ApprovalPolicy | undefined {
91
+ const approvals = config.approvals;
92
+ if (opts.unsafe || !approvals.enabled) return undefined;
93
+ return (tool: Tool, server: string): boolean => {
94
+ if (approvals.auto_allow_read_only && !isWriteable(tool)) return false;
95
+ return !matchesAllowlist(approvals.allowed_tools, server, tool.name);
96
+ };
97
+ }
98
+
99
+ /**
100
+ * True when "server/toolName" matches any allowlist pattern. Patterns:
101
+ * - exact "server/tool"
102
+ * - wildcard, where "*" on either side of the slash matches anything
103
+ * - a "/regex/" (with optional flags) tested against the tool name
104
+ * A bare token with no slash matches the tool name (server side wildcarded).
105
+ */
106
+ export function matchesAllowlist(
107
+ patterns: string[],
108
+ server: string,
109
+ toolName: string,
110
+ ): boolean {
111
+ for (const raw of patterns) {
112
+ const pattern = raw.trim();
113
+ if (!pattern) continue;
114
+ if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
115
+ const close = pattern.lastIndexOf("/");
116
+ const body = pattern.slice(1, close);
117
+ const flags = pattern.slice(close + 1);
118
+ try {
119
+ if (new RegExp(body, flags).test(toolName)) return true;
120
+ } catch {
121
+ // invalid regex — ignore this pattern
122
+ }
123
+ continue;
124
+ }
125
+ const [serverPat, toolPat] = pattern.includes("/")
126
+ ? pattern.split("/", 2)
127
+ : ["*", pattern];
128
+ if (wildcardEq(serverPat, server) && wildcardEq(toolPat, toolName)) {
129
+ return true;
130
+ }
131
+ }
132
+ return false;
133
+ }
134
+
135
+ function wildcardEq(pattern: string | undefined, value: string): boolean {
136
+ if (pattern === undefined || pattern === "*" || pattern === "") return true;
137
+ return pattern === value;
138
+ }
139
+
58
140
  /**
59
141
  * Serialize a CallToolResult's content array into a plain text string.
60
142
  */
@@ -1,4 +1,9 @@
1
+ import {
2
+ ToolApprovalDeniedError,
3
+ ToolApprovalRequiredError,
4
+ } from "@evantahler/mcpx";
1
5
  import { z } from "zod";
6
+ import { ApprovalPendingError } from "../../approvals/errors.ts";
2
7
  import { formatCallToolResult } from "../../mcpx/client.ts";
3
8
  import { fakeMcpExec, isCaptureMode } from "../../worker/fake-mcp.ts";
4
9
  import { getTool, type ToolDefinition } from "../tool.ts";
@@ -131,6 +136,33 @@ export const mcpExecTool = {
131
136
  : undefined,
132
137
  };
133
138
  } catch (err) {
139
+ // Human-in-the-loop approval gate outcomes (see src/mcpx/client.ts).
140
+ if (err instanceof ApprovalPendingError) {
141
+ // Worker context: signal the loop to park this task as `waiting`.
142
+ ctx.onApprovalPending?.(err.approvalId);
143
+ return {
144
+ result: `This action is queued for human approval (id ${err.approvalId}).`,
145
+ is_error: true,
146
+ error_kind: "permanent" as const,
147
+ hint: `Awaiting approval. Call wait_task with a reason referencing approval ${err.approvalId}; the task will be re-queued automatically once a human approves or denies it.`,
148
+ };
149
+ }
150
+ if (err instanceof ToolApprovalDeniedError) {
151
+ return {
152
+ result: `This action was denied by a human reviewer (${input.server}/${input.tool}).`,
153
+ is_error: true,
154
+ error_kind: "permanent" as const,
155
+ hint: "Do not retry the same call — the human said no. Try a different approach, or call fail_task explaining that the required action was denied.",
156
+ };
157
+ }
158
+ if (err instanceof ToolApprovalRequiredError) {
159
+ return {
160
+ result: `This action requires approval, but no approver is wired up.`,
161
+ is_error: true,
162
+ error_kind: "permanent" as const,
163
+ hint: "The approval gate is active but no approver is available. Call fail_task; a human must re-run with --unsafe or allowlist this tool in config.",
164
+ };
165
+ }
134
166
  const { error_kind, hint } = classifyError(err);
135
167
  return {
136
168
  result: `MCP tool error: ${err}`,
package/src/tools/tool.ts CHANGED
@@ -36,6 +36,13 @@ export interface ToolContext {
36
36
  * back to `logger.info` so worker logs are unchanged.
37
37
  */
38
38
  notify?: (message: string) => void;
39
+ /**
40
+ * Worker-mode only. Called by `mcp_exec` when a gated mcpx call has no
41
+ * decision yet and a pending `approvals/<id>.md` was written. The worker
42
+ * loop records the id and parks the task as `waiting` after the turn.
43
+ * Chat leaves this `undefined` (chat resolves approvals inline).
44
+ */
45
+ onApprovalPending?: (approvalId: string) => void;
39
46
  }
40
47
 
41
48
  type ToolOutputBase = { is_error: z.ZodBoolean };