1/**
2 * Approve Core
3 *
4 * beforeToolCall hook checks if the tool is on the watchlist.
5 * If yes, freezes the call, creates a pending request, notifies
6 * the operator, and returns a Promise that resolves when the
7 * operator approves or rejects.
8 *
9 * The freeze is a Promise stored in memory. The resolve/reject
10 * functions are held in a Map. When the operator hits the
11 * approve/reject endpoint, the Promise resolves and the tool
12 * call either proceeds or gets cancelled.
13 */
14
15import log from "../../seed/log.js";
16import Node from "../../seed/models/node.js";
17import { v4 as uuidv4 } from "uuid";
18
19// In-memory pending requests. Map<requestId, { resolve, reject, request }>
20const pending = new Map();
21const MAX_PENDING = 100;
22const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min default, configurable
23
24let _emitToUser = null;
25let _notify = null;
26let _metadata = null;
27
28export function setServices({ websocket, notifications, gateway, metadata }) {
29 if (websocket?.emitToUser) _emitToUser = websocket.emitToUser;
30 if (notifications) _notify = notifications;
31 if (metadata) _metadata = metadata;
32}
33
34// ─────────────────────────────────────────────────────────────────────────
35// WATCHLIST
36// ─────────────────────────────────────────────────────────────────────────
37
38/**
39 * Get the watchlist for a node (inherits from ancestors via metadata).
40 */
41export async function getWatchlist(nodeId) {
42 const node = await Node.findById(nodeId).select("metadata").lean();
43 if (!node) return [];
44 const meta = node.metadata instanceof Map
45 ? node.metadata.get("approve")
46 : node.metadata?.approve;
47 return meta?.watchlist || [];
48}
49
50/**
51 * Get the effective watchlist by walking ancestors.
52 * A tool watched at any ancestor applies to all descendants.
53 */
54export async function getEffectiveWatchlist(nodeId) {
55 const watched = new Set();
56 try {
57 const { getAncestorChain } = await import("../../seed/tree/ancestorCache.js");
58 const ancestors = await getAncestorChain(nodeId);
59 if (ancestors) {
60 for (const ancestor of ancestors) {
61 const meta = ancestor.metadata instanceof Map
62 ? ancestor.metadata.get("approve")
63 : ancestor.metadata?.approve;
64 if (meta?.watchlist) {
65 for (const tool of meta.watchlist) watched.add(tool);
66 }
67 }
68 }
69 } catch {
70 // Fallback: just check this node
71 const local = await getWatchlist(nodeId);
72 for (const tool of local) watched.add(tool);
73 }
74 return watched;
75}
76
77/**
78 * Add a tool to the watchlist at a node.
79 */
80export async function watchTool(nodeId, toolName) {
81 const node = await Node.findById(nodeId);
82 if (!node) throw new Error("Node not found");
83
84 const meta = _metadata.getExtMeta(node, "approve") || {};
85 if (!meta.watchlist) meta.watchlist = [];
86 if (meta.watchlist.includes(toolName)) return meta.watchlist;
87
88 meta.watchlist.push(toolName);
89 await _metadata.setExtMeta(node, "approve", meta);
90 return meta.watchlist;
91}
92
93/**
94 * Remove a tool from the watchlist at a node.
95 */
96export async function unwatchTool(nodeId, toolName) {
97 const node = await Node.findById(nodeId);
98 if (!node) throw new Error("Node not found");
99
100 const meta = _metadata.getExtMeta(node, "approve") || {};
101 if (!meta.watchlist) return [];
102
103 meta.watchlist = meta.watchlist.filter(t => t !== toolName);
104 await _metadata.setExtMeta(node, "approve", meta);
105 return meta.watchlist;
106}
107
108// ─────────────────────────────────────────────────────────────────────────
109// REQUEST LIFECYCLE
110// ─────────────────────────────────────────────────────────────────────────
111
112/**
113 * Create a pending approval request. Returns a Promise that resolves
114 * when the operator decides.
115 */
116export function createRequest({ toolName, args, nodeId, userId, rootId }) {
117 if (pending.size >= MAX_PENDING) {
118 throw new Error("Too many pending approval requests. Approve or reject existing ones first.");
119 }
120
121 const id = uuidv4();
122 const request = {
123 id,
124 toolName,
125 args: sanitizeArgs(args),
126 nodeId,
127 userId,
128 rootId,
129 createdAt: new Date().toISOString(),
130 status: "pending",
131 };
132
133 let resolve, reject;
134 const promise = new Promise((res, rej) => {
135 resolve = res;
136 reject = rej;
137 });
138
139 pending.set(id, { resolve, reject, request });
140
141 // Timeout: auto-reject after configurable duration
142 const timer = setTimeout(() => {
143 if (pending.has(id)) {
144 const entry = pending.get(id);
145 entry.request.status = "timeout";
146 entry.reject(new Error(`Approval request timed out after ${DEFAULT_TIMEOUT_MS / 60000} minutes`));
147 pending.delete(id);
148 }
149 }, DEFAULT_TIMEOUT_MS);
150 if (timer.unref) timer.unref();
151
152 // Notify operator
153 notifyOperator(request);
154
155 return { id, promise, request };
156}
157
158/**
159 * Resolve a pending request (approve or reject).
160 */
161export function resolveRequest(requestId, decision, userId) {
162 const entry = pending.get(requestId);
163 if (!entry) return null;
164
165 entry.request.status = decision;
166 entry.request.resolvedBy = userId;
167 entry.request.resolvedAt = new Date().toISOString();
168
169 if (decision === "approved") {
170 entry.resolve({ approved: true, request: entry.request });
171 } else {
172 entry.reject(new Error(`Tool call rejected by operator: ${entry.request.toolName}`));
173 }
174
175 pending.delete(requestId);
176 return entry.request;
177}
178
179/**
180 * Get all pending requests.
181 */
182export function getPendingRequests() {
183 return [...pending.values()].map(e => e.request);
184}
185
186// ─────────────────────────────────────────────────────────────────────────
187// NOTIFICATION
188// ─────────────────────────────────────────────────────────────────────────
189
190function notifyOperator(request) {
191 const message = `Tool "${request.toolName}" needs approval at node ${request.nodeId}. ` +
192 `Args: ${JSON.stringify(request.args).slice(0, 200)}`;
193
194 // WebSocket: real-time if operator is connected
195 if (_emitToUser && request.userId) {
196 try {
197 _emitToUser(request.userId, "approvalRequired", {
198 id: request.id,
199 toolName: request.toolName,
200 args: request.args,
201 nodeId: request.nodeId,
202 createdAt: request.createdAt,
203 });
204 } catch {}
205 }
206
207 // Notifications extension: persistent
208 if (_notify) {
209 try {
210 const Notification = _notify;
211 Notification.create({
212 userId: request.userId,
213 rootId: request.rootId,
214 type: "approve",
215 title: `Approval needed: ${request.toolName}`,
216 content: message,
217 }).catch(() => {});
218 } catch {}
219 }
220
221 // Gateway: push to external channel if configured
222 try {
223 import("../loader.js").then(({ getExtension }) => {
224 const gw = getExtension("gateway");
225 if (gw?.exports?.dispatchNotifications) {
226 gw.exports.dispatchNotifications(request.rootId, [{
227 type: "approval",
228 title: `Approval needed: ${request.toolName}`,
229 content: message,
230 }]).catch(() => {});
231 }
232 }).catch(() => {});
233 } catch {}
234
235 log.verbose("Approve", `Approval requested: ${request.toolName} (${request.id.slice(0, 8)})`);
236}
237
238/**
239 * Sanitize tool args for display. Remove sensitive fields, truncate large values.
240 */
241function sanitizeArgs(args) {
242 if (!args || typeof args !== "object") return {};
243 const safe = {};
244 for (const [key, val] of Object.entries(args)) {
245 if (key === "userId" || key === "chatId" || key === "sessionId") continue;
246 if (typeof val === "string" && val.length > 500) {
247 safe[key] = val.slice(0, 497) + "...";
248 } else {
249 safe[key] = val;
250 }
251 }
252 return safe;
253}
254
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import {
4 setServices, getEffectiveWatchlist, createRequest,
5 getPendingRequests, resolveRequest,
6} from "./core.js";
7
8export async function init(core) {
9 // Wire services
10 const notificationModel = core.models.Notification || null;
11 setServices({
12 websocket: core.websocket || null,
13 notifications: notificationModel,
14 gateway: null, // accessed dynamically in core.js
15 metadata: core.metadata,
16 });
17
18 // beforeToolCall: intercept watched tools
19 core.hooks.register("beforeToolCall", async (hookData) => {
20 const { toolName, args, userId, rootId } = hookData;
21 const nodeId = args?.nodeId || rootId;
22 if (!nodeId || !toolName) return;
23
24 // Check if this tool is on the effective watchlist
25 const watched = await getEffectiveWatchlist(nodeId);
26 if (!watched.has(toolName)) return;
27
28 // Freeze the tool call. The hook system will wait for this to resolve.
29 log.verbose("Approve", `Freezing tool call: ${toolName} (waiting for operator approval)`);
30
31 const { id, promise } = createRequest({
32 toolName,
33 args,
34 nodeId,
35 userId,
36 rootId,
37 });
38
39 // Wait for operator decision. This blocks the tool call.
40 // The beforeToolCall hook supports async. The conversation loop
41 // waits for all before hooks to resolve before executing the tool.
42 try {
43 const result = await promise;
44 if (!result.approved) {
45 hookData.cancelled = true;
46 hookData.reason = "Rejected by operator";
47 }
48 // approved: hook returns normally, tool call proceeds
49 } catch (err) {
50 // Rejected or timed out
51 hookData.cancelled = true;
52 hookData.reason = err.message || "Approval denied";
53 }
54 }, "approve");
55
56 // enrichContext: let the AI know there's a watchlist active
57 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
58 const approveMeta = meta?.approve;
59 if (!approveMeta?.watchlist?.length) return;
60
61 context.toolApproval = {
62 watchedTools: approveMeta.watchlist,
63 note: "These tools require operator approval before execution. The call will pause until approved.",
64 };
65 }, "approve");
66
67 const { default: router } = await import("./routes.js");
68
69 log.verbose("Approve", "Approve loaded");
70
71 return {
72 router,
73 tools,
74 exports: {
75 getEffectiveWatchlist,
76 getPendingRequests,
77 resolveRequest,
78 },
79 };
80}
81
1export default {
2 name: "approve",
3 version: "1.0.2",
4 builtFor: "TreeOS",
5 description:
6 "The AI pauses and waits for you. Any tool call can be put on a watchlist. When the AI " +
7 "tries to call a watched tool, the call freezes. A notification goes out. The operator " +
8 "sees what the AI wants to do, with what arguments, and why. They approve or reject. " +
9 "The AI resumes or adapts. No timeout pressure. The human decides when they're ready. " +
10 "Works with gateway notifications so you can approve from your phone.",
11
12 needs: {
13 services: ["hooks", "metadata", "websocket"],
14 models: ["Node"],
15 },
16
17 optional: {
18 services: ["energy"],
19 extensions: ["notifications", "gateway"],
20 },
21
22 provides: {
23 models: {},
24 routes: "./routes.js",
25 tools: true,
26 jobs: false,
27 orchestrator: false,
28 energyActions: {},
29 sessionTypes: {},
30
31 hooks: {
32 fires: [],
33 listens: ["beforeToolCall", "enrichContext"],
34 },
35
36 cli: [
37 {
38 command: "approve [action] [args...]",
39 scope: ["tree"],
40 description: "Tool approval watchlist and pending requests. Actions: watch, unwatch, pending, approve <id>, reject <id>",
41 method: "GET",
42 endpoint: "/node/:nodeId/approve",
43 subcommands: {
44 watch: {
45 method: "POST",
46 endpoint: "/node/:nodeId/approve/watch",
47 args: ["toolName"],
48 description: "Add a tool to the watchlist",
49 },
50 unwatch: {
51 method: "POST",
52 endpoint: "/node/:nodeId/approve/unwatch",
53 args: ["toolName"],
54 description: "Remove a tool from the watchlist",
55 },
56 pending: {
57 method: "GET",
58 endpoint: "/node/:nodeId/approve/pending",
59 description: "Show pending approval requests",
60 },
61 approve: {
62 method: "POST",
63 endpoint: "/node/:nodeId/approve/resolve",
64 args: ["id"],
65 body: ["decision"],
66 description: "Approve a pending request",
67 },
68 reject: {
69 method: "POST",
70 endpoint: "/node/:nodeId/approve/resolve",
71 args: ["id"],
72 body: ["decision"],
73 description: "Reject a pending request",
74 },
75 },
76 },
77 ],
78 },
79};
80
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getWatchlist, watchTool, unwatchTool, getPendingRequests, resolveRequest } from "./core.js";
5
6const router = express.Router();
7
8// GET /node/:nodeId/approve - show watchlist and pending
9router.get("/node/:nodeId/approve", authenticate, async (req, res) => {
10 try {
11 const watchlist = await getWatchlist(req.params.nodeId);
12 const pending = getPendingRequests();
13 sendOk(res, { watchlist, pending });
14 } catch (err) {
15 sendError(res, 500, ERR.INTERNAL, err.message);
16 }
17});
18
19// POST /node/:nodeId/approve/watch - add tool to watchlist
20router.post("/node/:nodeId/approve/watch", authenticate, async (req, res) => {
21 try {
22 const { toolName } = req.body;
23 if (!toolName) return sendError(res, 400, ERR.INVALID_INPUT, "toolName is required");
24 const list = await watchTool(req.params.nodeId, toolName);
25 sendOk(res, { watchlist: list });
26 } catch (err) {
27 sendError(res, 500, ERR.INTERNAL, err.message);
28 }
29});
30
31// POST /node/:nodeId/approve/unwatch - remove tool from watchlist
32router.post("/node/:nodeId/approve/unwatch", authenticate, async (req, res) => {
33 try {
34 const { toolName } = req.body;
35 if (!toolName) return sendError(res, 400, ERR.INVALID_INPUT, "toolName is required");
36 const list = await unwatchTool(req.params.nodeId, toolName);
37 sendOk(res, { watchlist: list });
38 } catch (err) {
39 sendError(res, 500, ERR.INTERNAL, err.message);
40 }
41});
42
43// GET /node/:nodeId/approve/pending - pending requests
44router.get("/node/:nodeId/approve/pending", authenticate, async (req, res) => {
45 try {
46 sendOk(res, { pending: getPendingRequests() });
47 } catch (err) {
48 sendError(res, 500, ERR.INTERNAL, err.message);
49 }
50});
51
52// POST /node/:nodeId/approve/resolve - approve or reject
53router.post("/node/:nodeId/approve/resolve", authenticate, async (req, res) => {
54 try {
55 const { id, decision } = req.body;
56 if (!id) return sendError(res, 400, ERR.INVALID_INPUT, "id is required");
57 if (!["approved", "rejected"].includes(decision)) {
58 return sendError(res, 400, ERR.INVALID_INPUT, "decision must be 'approved' or 'rejected'");
59 }
60 const result = resolveRequest(id, decision, req.userId);
61 if (!result) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Request not found or already resolved");
62 sendOk(res, result);
63 } catch (err) {
64 sendError(res, 500, ERR.INTERNAL, err.message);
65 }
66});
67
68export default router;
69
1import { z } from "zod";
2import { getWatchlist, watchTool, unwatchTool, getPendingRequests, resolveRequest } from "./core.js";
3
4export default [
5 {
6 name: "approve-watch",
7 description: "Add a tool to the approval watchlist. The AI will pause and wait for operator approval before executing this tool.",
8 schema: {
9 nodeId: z.string().describe("The node to set the watchlist on (inherits to children)."),
10 toolName: z.string().describe("The tool name to watch (e.g. delete-node-branch, execute-shell)."),
11 userId: z.string().describe("Injected by server. Ignore."),
12 },
13 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
14 handler: async ({ nodeId, toolName }) => {
15 try {
16 const list = await watchTool(nodeId, toolName);
17 return { content: [{ type: "text", text: `Watching "${toolName}". AI will pause for approval before executing. Watchlist: ${list.join(", ")}` }] };
18 } catch (err) {
19 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
20 }
21 },
22 },
23 {
24 name: "approve-unwatch",
25 description: "Remove a tool from the approval watchlist.",
26 schema: {
27 nodeId: z.string().describe("The node to modify."),
28 toolName: z.string().describe("The tool name to stop watching."),
29 userId: z.string().describe("Injected by server. Ignore."),
30 },
31 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
32 handler: async ({ nodeId, toolName }) => {
33 try {
34 const list = await unwatchTool(nodeId, toolName);
35 return { content: [{ type: "text", text: `Unwatched "${toolName}". Remaining: ${list.length > 0 ? list.join(", ") : "(none)"}` }] };
36 } catch (err) {
37 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
38 }
39 },
40 },
41 {
42 name: "approve-pending",
43 description: "Show pending approval requests. Tools waiting for operator decision.",
44 schema: {
45 userId: z.string().describe("Injected by server. Ignore."),
46 },
47 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
48 handler: async () => {
49 try {
50 const requests = getPendingRequests();
51 if (requests.length === 0) {
52 return { content: [{ type: "text", text: "No pending approval requests." }] };
53 }
54 const lines = requests.map(r =>
55 `[${r.id.slice(0, 8)}] ${r.toolName} at ${r.nodeId?.slice(0, 8)} -- ${JSON.stringify(r.args).slice(0, 150)}`
56 );
57 return { content: [{ type: "text", text: `${requests.length} pending:\n${lines.join("\n")}` }] };
58 } catch (err) {
59 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
60 }
61 },
62 },
63 {
64 name: "approve-resolve",
65 description: "Approve or reject a pending tool call. Decision: 'approved' or 'rejected'.",
66 schema: {
67 requestId: z.string().describe("The request ID to resolve."),
68 decision: z.enum(["approved", "rejected"]).describe("Approve or reject the tool call."),
69 userId: z.string().describe("Injected by server. Ignore."),
70 },
71 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
72 handler: async ({ requestId, decision, userId }) => {
73 try {
74 const result = resolveRequest(requestId, decision, userId);
75 if (!result) return { content: [{ type: "text", text: "Request not found or already resolved." }] };
76 return { content: [{ type: "text", text: `${decision === "approved" ? "Approved" : "Rejected"}: ${result.toolName}. The AI will ${decision === "approved" ? "proceed" : "adapt"}.` }] };
77 } catch (err) {
78 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
79 }
80 },
81 },
82];
83
Loading comments...