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.
- package/README.md +6 -2
- package/package.json +2 -2
- package/src/approvals/decide.ts +36 -0
- package/src/approvals/errors.ts +22 -0
- package/src/approvals/schema.ts +48 -0
- package/src/approvals/store.ts +276 -0
- package/src/chat/approval.ts +62 -0
- package/src/chat/session.ts +31 -2
- package/src/cli.ts +2 -0
- package/src/commands/approval.ts +130 -0
- package/src/commands/chat.ts +47 -34
- package/src/commands/nuke.ts +12 -2
- package/src/commands/worker.ts +31 -12
- package/src/config/loader.ts +27 -0
- package/src/config/schemas.ts +28 -0
- package/src/constants.ts +6 -0
- package/src/init/index.ts +5 -0
- package/src/mcpx/client.ts +83 -1
- package/src/tools/mcp/exec.ts +32 -0
- package/src/tools/tool.ts +7 -0
- package/src/tui/App.tsx +25 -2
- package/src/tui/components/ApprovalPanel.tsx +222 -0
- package/src/tui/components/ApprovalPrompt.tsx +68 -0
- package/src/tui/components/HelpPanel.tsx +3 -0
- package/src/tui/components/TabBar.tsx +13 -6
- package/src/tui/components/TabPanels.tsx +9 -0
- package/src/tui/hooks/useAppKeybindings.ts +9 -0
- package/src/tui/hooks/useApprovalCount.ts +32 -0
- package/src/tui/hooks/useApprovalPrompt.ts +49 -0
- package/src/tui/hooks/useCaptureTabCycle.ts +1 -1
- package/src/tui/hooks/useChatSession.ts +5 -3
- package/src/tui/keys.ts +1 -0
- package/src/worker/approval.ts +60 -0
- package/src/worker/index.ts +37 -4
- package/src/worker/llm.ts +18 -0
- package/src/worker/run.ts +3 -1
- package/src/worker/spawn.ts +3 -0
- package/src/worker/tick.ts +25 -2
|
@@ -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
|
+
}
|
package/src/commands/chat.ts
CHANGED
|
@@ -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+
|
|
12
|
-
" Ctrl+n Context Ctrl+s Schedules
|
|
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
|
-
.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|
package/src/commands/nuke.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/commands/worker.ts
CHANGED
|
@@ -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
|
-
.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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")
|
package/src/config/loader.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config/schemas.ts
CHANGED
|
@@ -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/`);
|
package/src/mcpx/client.ts
CHANGED
|
@@ -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 {
|
|
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
|
*/
|
package/src/tools/mcp/exec.ts
CHANGED
|
@@ -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 };
|