EXTENSION for TreeOS
approve
The AI pauses and waits for you. Any tool call can be put on a watchlist. When the AI tries to call a watched tool, the call freezes. A notification goes out. The operator sees what the AI wants to do, with what arguments, and why. They approve or reject. The AI resumes or adapts. No timeout pressure. The human decides when they're ready. Works with gateway notifications so you can approve from your phone.
v1.0.2 by TreeOS Site 0 downloads 5 files 567 lines 18.3 KB published 38d ago
treeos ext install approve
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: hooks, metadata, websocket
  • models: Node

Optional

  • services: energy
  • extensions: notifications, gateway
SHA256: 07caca1b030648ff036f5b920d236bdda331dc9f94f9c3796f29cfe2a44bf1ad

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

CLI Commands

CommandMethodDescription
approveGETTool approval watchlist and pending requests. Actions: watch, unwatch, pending, approve <id>, reject <id>
approve watchPOSTAdd a tool to the watchlist
approve unwatchPOSTRemove a tool from the watchlist
approve pendingGETShow pending approval requests
approve approvePOSTApprove a pending request
approve rejectPOSTReject a pending request

Hooks

Listens To

  • beforeToolCall
  • enrichContext

Source Code

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

Versions

Version Published Downloads
1.0.2 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star approve

Comments

Loading comments...

Post comments from the CLI: treeos ext comment approve "your comment"
Max 3 comments per extension. One star and one flag per user.