@kammcs/buildit-mcp 0.2.0 → 0.7.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/CHANGELOG.md +116 -0
- package/README.md +252 -32
- package/SECURITY.md +1 -1
- package/dist/api/client.js +5 -1
- package/dist/api/generated/operations.js +661 -4
- package/dist/api/generated/schemas.js +680 -2
- package/dist/api/generated/strict.js +799 -2
- package/dist/channel/api.js +128 -0
- package/dist/channel/approvals.js +259 -0
- package/dist/channel/item-events.js +138 -0
- package/dist/channel/lock.js +181 -0
- package/dist/channel/loop.js +776 -0
- package/dist/channel/mask.js +365 -0
- package/dist/channel/threads.js +60 -0
- package/dist/channel/tool-ring.js +124 -0
- package/dist/channel/typing.js +150 -0
- package/dist/channel/waiters.js +229 -0
- package/dist/channel/workers.js +66 -0
- package/dist/cli/claude-config.js +100 -0
- package/dist/cli/commands.js +410 -0
- package/dist/config.js +329 -6
- package/dist/errors.js +38 -0
- package/dist/index.js +122 -6
- package/dist/log.js +3 -2
- package/dist/secrets/keychain.js +273 -0
- package/dist/server.js +53 -5
- package/dist/tools/approve.js +791 -0
- package/dist/tools/ask.js +898 -0
- package/dist/tools/chat.js +98 -1
- package/dist/tools/items-read.js +5 -3
- package/dist/tools/items-write.js +66 -15
- package/dist/tools/plans.js +221 -14
- package/dist/tools/reply.js +138 -0
- package/dist/tools/shared.js +10 -1
- package/dist/tools/threads.js +123 -0
- package/dist/tools/whoami.js +17 -3
- package/dist/toolsets/catalog.js +3 -1
- package/dist/toolsets/registry.js +2 -0
- package/dist/transports/stdio.js +260 -6
- package/package.json +5 -1
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool approvals in buildIt.Social: the plugin's PermissionRequest hook
|
|
3
|
+
* (`approve`), and the hooks that tell it when an approval is no longer
|
|
4
|
+
* needed (`tool_seen`, `tool_done`, `agent_done`, `prompt_typed`; `turn_ended`
|
|
5
|
+
* is in reply.ts). Channel mode only; none of them is for Claude to call.
|
|
6
|
+
*
|
|
7
|
+
* When Claude Code would ask the person whether a tool may run, `approve`
|
|
8
|
+
* posts a tool approval card in buildIt.Social and waits for the owner's
|
|
9
|
+
* answer there, while the terminal asks too (for the main agent; for a
|
|
10
|
+
* background subagent the terminal asks only once the hook gives up). The
|
|
11
|
+
* card shows only the tool's name and who asks, in buildIt.Social's own
|
|
12
|
+
* words; the details (Claude's description, the command, the file and the
|
|
13
|
+
* change) are cleaned, masked and cut here (channel/mask.ts) and only the
|
|
14
|
+
* owner can read them. Only the owner's own tap answers: the verdict comes
|
|
15
|
+
* back as the card's `card.closed` event (its `data.approval`), and nothing
|
|
16
|
+
* else this server reads (an answer to the card, a message) is a verdict.
|
|
17
|
+
*
|
|
18
|
+
* The hook's answer (the tool's text) is one JSON object: allow (with the
|
|
19
|
+
* session rule when the owner chose "Allow for this session"), deny with a
|
|
20
|
+
* reason Claude reads, or `{}`, which changes nothing (the terminal decides):
|
|
21
|
+
* when approvals in the app are off, the folder has no channel session, the
|
|
22
|
+
* input can't be read, the post failed, or the approval stopped being needed.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import { ApiError } from '../api/client.js';
|
|
27
|
+
import { closeSessionApproval, getSessionApproval, postSessionApproval } from '../channel/api.js';
|
|
28
|
+
import { apiApprovalCloser, } from '../channel/approvals.js';
|
|
29
|
+
import { buildDetails, REDACTED, sessionRuleFrom } from '../channel/mask.js';
|
|
30
|
+
import { isUuid, itemThreadId } from '../channel/threads.js';
|
|
31
|
+
import { canonicalJson, inputHash, parseToolInput } from '../channel/tool-ring.js';
|
|
32
|
+
import { CardInbox, eventMessageId } from '../channel/waiters.js';
|
|
33
|
+
import { isItemWorker, workerEntry } from '../channel/workers.js';
|
|
34
|
+
import { defineTool } from '../toolsets/registry.js';
|
|
35
|
+
import { sanitizeLabel } from '../untrusted.js';
|
|
36
|
+
import { uuidV5 } from './ask.js';
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// The hook's answers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
/** The hook's answer that changes nothing: Claude Code decides as usual. */
|
|
41
|
+
export const HOOK_PASS = '{}';
|
|
42
|
+
/** The hook's answer that lets the tool run (with the session rule the owner allowed). */
|
|
43
|
+
export function permissionAllow(rule) {
|
|
44
|
+
return JSON.stringify({
|
|
45
|
+
hookSpecificOutput: {
|
|
46
|
+
hookEventName: 'PermissionRequest',
|
|
47
|
+
decision: {
|
|
48
|
+
behavior: 'allow',
|
|
49
|
+
...(rule === undefined ? {} : { updatedPermissions: [rule] }),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** The hook's answer that refuses the tool, with a reason Claude reads. */
|
|
55
|
+
export function permissionDeny(message) {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
hookSpecificOutput: {
|
|
58
|
+
hookEventName: 'PermissionRequest',
|
|
59
|
+
decision: { behavior: 'deny', message },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function denyText(reason, owner, minutes) {
|
|
64
|
+
switch (reason) {
|
|
65
|
+
case 'denied':
|
|
66
|
+
return `${owner} denied this in buildIt.Social. Don't retry it; ask them what to do instead.`;
|
|
67
|
+
case 'too_long':
|
|
68
|
+
return `This command was too long to show in full in buildIt.Social, so ${owner} could not approve it there. Split it into shorter commands, or ask ${owner} to approve it in the terminal.`;
|
|
69
|
+
case 'hidden':
|
|
70
|
+
return `This holds something that looks like a credential, which buildIt.Social hides, so ${owner} could not check it there. Ask ${owner} to approve it in the terminal, or keep credentials out of commands and files (read them from the environment).`;
|
|
71
|
+
case 'switched_off':
|
|
72
|
+
return 'Tool approvals in buildIt.Social were switched off before anyone answered, so this was not run.';
|
|
73
|
+
case 'expired':
|
|
74
|
+
return `Nobody approved this in buildIt.Social within ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}, so it was not run. Don't retry it now: stop and tell the person what you need, or do the task another way.`;
|
|
75
|
+
case 'session_ended':
|
|
76
|
+
return 'The buildIt.Social session ended before anyone approved this, so it was not run. Stop and tell the person in this terminal what you need.';
|
|
77
|
+
case 'unassigned':
|
|
78
|
+
return 'You were taken off this work item in buildIt.Social, so this was not run. Stop work on it.';
|
|
79
|
+
case 'closed':
|
|
80
|
+
return 'The approval card was closed in buildIt.Social before anyone answered, so this was not run. Ask the person what to do.';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Ids and input
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/** The UUIDv5 namespace of tool approval cards' message ids. */
|
|
87
|
+
export const APPROVE_NAMESPACE = '6c2f8a14-3b7d-4e91-8d5a-0f4b9c2e7a63';
|
|
88
|
+
const TOOL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$/;
|
|
89
|
+
const TOOL_USE_ID_RE = /^[A-Za-z0-9_-]{1,100}$/;
|
|
90
|
+
const AGENT_REF_RE = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
91
|
+
/** The agent types sent to buildIt.Social (anything else is left out). */
|
|
92
|
+
const AGENT_TYPE_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
|
|
93
|
+
/** A hook field: trimmed, and empty when Claude Code left the `${…}` unfilled. */
|
|
94
|
+
export function hookField(value) {
|
|
95
|
+
if (typeof value !== 'string')
|
|
96
|
+
return '';
|
|
97
|
+
const s = value.trim();
|
|
98
|
+
return s.startsWith('${') ? '' : s;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A card's id from its tool call: its id, who asks, the tool and the input's
|
|
102
|
+
* hash. One card per call, however often the hook runs; and a verdict for
|
|
103
|
+
* one input is never the verdict for another under the same tool call id
|
|
104
|
+
* (Claude can call these tools itself, and name any id).
|
|
105
|
+
*/
|
|
106
|
+
export function approvalIdForToolUse(toolUseId, agentId, toolName, hash) {
|
|
107
|
+
return uuidV5(['tool_use', toolUseId, agentId, toolName, hash].join('\n'), APPROVE_NAMESPACE);
|
|
108
|
+
}
|
|
109
|
+
/** How long `approve` waits for the call's PreToolUse to name its id. */
|
|
110
|
+
export const CORRELATE_WAIT_MS = 300;
|
|
111
|
+
/** How often a waiting approval reads its state (the fallback when an event was missed). */
|
|
112
|
+
export const POLL_EVERY_S = 120;
|
|
113
|
+
/** How often a long wait reports progress. */
|
|
114
|
+
const PROGRESS_EVERY_S = 30;
|
|
115
|
+
export const ApproveInputSchema = z.object({
|
|
116
|
+
tool_name: z.string().max(300),
|
|
117
|
+
tool_input: z.union([z.string().max(2_000_000), z.record(z.string(), z.unknown())]),
|
|
118
|
+
tool_use_id: z.string().max(300).optional(),
|
|
119
|
+
agent_id: z.string().max(300).optional(),
|
|
120
|
+
agent_type: z.string().max(300).optional(),
|
|
121
|
+
session_id: z.string().max(300).optional(),
|
|
122
|
+
prompt_id: z.string().max(300).optional(),
|
|
123
|
+
suggestions: z.union([z.string().max(200_000), z.array(z.unknown())]).optional(),
|
|
124
|
+
mode: z.string().max(100).optional(),
|
|
125
|
+
});
|
|
126
|
+
const ApproveOutputSchema = z.object({
|
|
127
|
+
status: z.enum(['approved', 'approved_session', 'denied', 'expired', 'closed', 'passed']),
|
|
128
|
+
reason: z.string().optional(),
|
|
129
|
+
message_id: z.string().nullable(),
|
|
130
|
+
});
|
|
131
|
+
/**
|
|
132
|
+
* The main agent's approvals go to the direct message (its latest thread);
|
|
133
|
+
* an item worker named in the lead's table asks in its item's thread; any
|
|
134
|
+
* other subagent, and a worker the table doesn't name, asks in the direct
|
|
135
|
+
* message too (the card names the subagent), never only in a terminal
|
|
136
|
+
* nobody may be watching.
|
|
137
|
+
*/
|
|
138
|
+
async function placement(state, ctx, agentId, agentType) {
|
|
139
|
+
const dm = { thread: state.lastDmThread, item: false };
|
|
140
|
+
if (agentId === '' || !isItemWorker(agentType) || state.folder === undefined)
|
|
141
|
+
return dm;
|
|
142
|
+
const entry = workerEntry(state.folder, agentId);
|
|
143
|
+
if (entry === undefined)
|
|
144
|
+
return dm;
|
|
145
|
+
if (entry.thread_id !== undefined && isUuid(entry.thread_id)) {
|
|
146
|
+
return { thread: entry.thread_id, item: true };
|
|
147
|
+
}
|
|
148
|
+
if (entry.item === undefined)
|
|
149
|
+
return dm;
|
|
150
|
+
try {
|
|
151
|
+
return { thread: await itemThreadId(entry.item, ctx), item: true };
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return dm;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The refusals of an item thread after which the card goes to the direct message instead. */
|
|
158
|
+
const ITEM_FALLBACK_CODES = new Set([
|
|
159
|
+
'thread_not_bound',
|
|
160
|
+
'outside_reach',
|
|
161
|
+
'not_found',
|
|
162
|
+
]);
|
|
163
|
+
function answerOf(final, init) {
|
|
164
|
+
const at = (status, text, reason) => ({
|
|
165
|
+
structured: {
|
|
166
|
+
status,
|
|
167
|
+
...(reason === undefined ? {} : { reason }),
|
|
168
|
+
message_id: init.id,
|
|
169
|
+
},
|
|
170
|
+
text,
|
|
171
|
+
});
|
|
172
|
+
switch (final.status) {
|
|
173
|
+
case 'approved':
|
|
174
|
+
return at('approved', permissionAllow());
|
|
175
|
+
case 'approved_session':
|
|
176
|
+
// The rule this call offered, and only when the server stored that same rule (what the
|
|
177
|
+
// owner was shown); otherwise this one call only.
|
|
178
|
+
if (init.rule !== undefined &&
|
|
179
|
+
final.rule !== undefined &&
|
|
180
|
+
canonicalJson(final.rule) === canonicalJson(init.rule)) {
|
|
181
|
+
return at('approved_session', permissionAllow(init.rule));
|
|
182
|
+
}
|
|
183
|
+
init.mismatch?.();
|
|
184
|
+
return at('approved', permissionAllow(), 'rule_mismatch');
|
|
185
|
+
case 'denied':
|
|
186
|
+
return at('denied', permissionDeny(denyText(init.unchecked ?? 'denied', init.owner, init.minutes)), init.unchecked);
|
|
187
|
+
case 'expired':
|
|
188
|
+
return at('expired', permissionDeny(denyText('expired', init.owner, init.minutes)), final.reason);
|
|
189
|
+
case 'closed': {
|
|
190
|
+
const why = final.reason === 'session_ended' ||
|
|
191
|
+
final.reason === 'unassigned' ||
|
|
192
|
+
final.reason === 'switched_off'
|
|
193
|
+
? final.reason
|
|
194
|
+
: 'closed';
|
|
195
|
+
return at('closed', permissionDeny(denyText(why, init.owner, init.minutes)), final.reason);
|
|
196
|
+
}
|
|
197
|
+
default:
|
|
198
|
+
// answered_elsewhere, stale, and anything newer: nothing to decide here.
|
|
199
|
+
return at('passed', HOOK_PASS, final.status);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const codeOf = (err) => (err instanceof ApiError ? err.code : 'error');
|
|
203
|
+
/** The approval object of a card.closed event for this card, if it carries one (and names it). */
|
|
204
|
+
function eventApproval(event, id) {
|
|
205
|
+
const approval = event.data.approval;
|
|
206
|
+
if (approval === null || typeof approval !== 'object')
|
|
207
|
+
return undefined;
|
|
208
|
+
const a = approval;
|
|
209
|
+
if (typeof a.outcome !== 'string' || a.id !== id)
|
|
210
|
+
return undefined;
|
|
211
|
+
return {
|
|
212
|
+
status: a.outcome,
|
|
213
|
+
reason: typeof a.reason === 'string' ? a.reason : undefined,
|
|
214
|
+
...(a.rule === undefined ? {} : { rule: a.rule }),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** A post that failed in a way that may have stored it: a timeout, the network, a 5xx. */
|
|
218
|
+
function mayBeStored(err) {
|
|
219
|
+
if (!(err instanceof ApiError))
|
|
220
|
+
return true;
|
|
221
|
+
return err.status >= 500 || err.status === 408 || err.code === 'timeout';
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// approve
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
/** The approve tool (the PermissionRequest hook) for one channel session. */
|
|
227
|
+
export function createApproveTool(state, options) {
|
|
228
|
+
const scale = options.msPerSecond ?? 1000;
|
|
229
|
+
const clock = options.now ?? Date.now;
|
|
230
|
+
/**
|
|
231
|
+
* How many fallback ids this process made per prefix (so a retried
|
|
232
|
+
* identical call gets a new card), and how many of them are in use; a
|
|
233
|
+
* prefix in use is never forgotten.
|
|
234
|
+
*/
|
|
235
|
+
const fallbackSeq = new Map();
|
|
236
|
+
/** This server's own nonce (made once, at start-up): fallback ids can't be guessed or reused. */
|
|
237
|
+
const nonce = randomUUID();
|
|
238
|
+
return defineTool({
|
|
239
|
+
name: 'approve',
|
|
240
|
+
// Not in the catalog: listed only in channel mode, whatever the toolsets.
|
|
241
|
+
toolset: 'chat',
|
|
242
|
+
title: 'Tool approval (plugin hook)',
|
|
243
|
+
description: "Called by the buildit plugin's PermissionRequest hook when a tool needs the person's approval: it asks them in buildIt.Social too. Not for you to call. Keep secrets, tokens, passwords and keys out of commands, file contents and edits (read them from environment variables or config files, never echo them): the app hides anything that looks like a credential, and a call with a hidden value can only be denied there.",
|
|
244
|
+
scopes: [],
|
|
245
|
+
runsAfterSessionEnd: true,
|
|
246
|
+
annotations: {
|
|
247
|
+
title: 'Tool approval (plugin hook)',
|
|
248
|
+
readOnlyHint: false,
|
|
249
|
+
destructiveHint: false,
|
|
250
|
+
idempotentHint: false,
|
|
251
|
+
openWorldHint: true,
|
|
252
|
+
},
|
|
253
|
+
inputSchema: ApproveInputSchema,
|
|
254
|
+
outputSchema: ApproveOutputSchema,
|
|
255
|
+
async run(args, ctx) {
|
|
256
|
+
const pass = (why, id = null) => {
|
|
257
|
+
ctx.logger.info('tool approval passed to the terminal', { reason: why });
|
|
258
|
+
return {
|
|
259
|
+
structured: { status: 'passed', reason: why, message_id: id },
|
|
260
|
+
text: HOOK_PASS,
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
try {
|
|
264
|
+
return await approve(args, ctx, pass);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
// A hook never fails: the terminal asks.
|
|
268
|
+
ctx.logger.warn('tool approval failed', { code: codeOf(err) });
|
|
269
|
+
return pass('error');
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
async function approve(args, ctx, pass) {
|
|
274
|
+
// The whole call, the post included, stays within the card's wait (and so the hook's timeout).
|
|
275
|
+
const started = clock();
|
|
276
|
+
if (!options.approvalsInApp)
|
|
277
|
+
return pass('approvals_in_app_off');
|
|
278
|
+
const toolName = hookField(args.tool_name);
|
|
279
|
+
// AskUserQuestion is the question hook's.
|
|
280
|
+
if (toolName === 'AskUserQuestion')
|
|
281
|
+
return pass('ask_user_question');
|
|
282
|
+
if (!TOOL_NAME_RE.test(toolName))
|
|
283
|
+
return pass('tool_name');
|
|
284
|
+
if (!state.isChannel)
|
|
285
|
+
return pass('not_the_channel');
|
|
286
|
+
const info = state.session;
|
|
287
|
+
if (info === undefined || state.ended !== undefined || state.waiters.isEnded) {
|
|
288
|
+
return pass('no_session');
|
|
289
|
+
}
|
|
290
|
+
const input = parseToolInput(args.tool_input);
|
|
291
|
+
if (input === undefined)
|
|
292
|
+
return pass('tool_input');
|
|
293
|
+
const approvals = state.approvals;
|
|
294
|
+
if (approvals === undefined)
|
|
295
|
+
return pass('no_session');
|
|
296
|
+
const agentId = hookField(args.agent_id);
|
|
297
|
+
const hash = inputHash(input);
|
|
298
|
+
const given = hookField(args.tool_use_id);
|
|
299
|
+
const toolUseId = TOOL_USE_ID_RE.test(given)
|
|
300
|
+
? given
|
|
301
|
+
: await state.ring.claimSoon(agentId, toolName, hash, CORRELATE_WAIT_MS);
|
|
302
|
+
let id;
|
|
303
|
+
let releasePrefix = () => undefined;
|
|
304
|
+
if (toolUseId !== undefined) {
|
|
305
|
+
id = approvalIdForToolUse(toolUseId, agentId, toolName, hash);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
const sessionId = hookField(args.session_id) || info.session.id;
|
|
309
|
+
const prefix = [sessionId, agentId, toolName, hash, hookField(args.prompt_id), nonce].join('\n');
|
|
310
|
+
const entry = fallbackSeq.get(prefix) ?? { seq: 0, inUse: 0 };
|
|
311
|
+
entry.seq++;
|
|
312
|
+
entry.inUse++;
|
|
313
|
+
fallbackSeq.delete(prefix);
|
|
314
|
+
fallbackSeq.set(prefix, entry);
|
|
315
|
+
if (fallbackSeq.size > 1000) {
|
|
316
|
+
for (const [key, value] of fallbackSeq) {
|
|
317
|
+
if (value.inUse === 0) {
|
|
318
|
+
fallbackSeq.delete(key);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
releasePrefix = () => {
|
|
324
|
+
entry.inUse = Math.max(0, entry.inUse - 1);
|
|
325
|
+
};
|
|
326
|
+
id = uuidV5(`${prefix}\n${entry.seq}`, APPROVE_NAMESPACE);
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
return await approveCard(args, ctx, pass, { started, id, toolName, hash, toolUseId, input });
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
releasePrefix();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function approveCard(args, ctx, pass, call) {
|
|
336
|
+
const { started, id, toolName, hash, toolUseId, input } = call;
|
|
337
|
+
const info = state.session;
|
|
338
|
+
const approvals = state.approvals;
|
|
339
|
+
if (info === undefined || approvals === undefined)
|
|
340
|
+
return pass('no_session');
|
|
341
|
+
const agentId = hookField(args.agent_id);
|
|
342
|
+
const agentType = hookField(args.agent_type);
|
|
343
|
+
const rule = sessionRuleFrom(args.suggestions, toolName);
|
|
344
|
+
const known = state.waiters.approvalOf(id, toolName, hash);
|
|
345
|
+
if (known !== undefined) {
|
|
346
|
+
// A remembered session rule is granted again only to a call that offers that same rule.
|
|
347
|
+
if (known.status === 'approved_session' &&
|
|
348
|
+
(rule === undefined || canonicalJson(known.rule) !== canonicalJson(rule))) {
|
|
349
|
+
ctx.logger.warn('tool approval rule not granted', { reason: 'rule_mismatch' });
|
|
350
|
+
return {
|
|
351
|
+
structured: { status: 'approved', reason: 'rule_mismatch', message_id: id },
|
|
352
|
+
text: permissionAllow(),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
structured: {
|
|
357
|
+
status: known.status,
|
|
358
|
+
...(known.reason === undefined ? {} : { reason: known.reason }),
|
|
359
|
+
message_id: id,
|
|
360
|
+
},
|
|
361
|
+
text: known.text,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const owner = sanitizeLabel(info.owner.name, 64);
|
|
365
|
+
const waitS = options.hookWaitS;
|
|
366
|
+
const minutes = Math.max(1, Math.round(waitS / 60));
|
|
367
|
+
const details = buildDetails(toolName, input);
|
|
368
|
+
// A command cut to fit can't be checked in the app: its card offers only Deny.
|
|
369
|
+
const tooLong = (toolName === 'Bash' || toolName === 'PowerShell') &&
|
|
370
|
+
(details.fields.some((f) => (f.cut ?? 0) > 0) || (details.more_fields ?? 0) > 0);
|
|
371
|
+
// Nor can a call with a hidden part: the owner can approve only what they see in full.
|
|
372
|
+
const hidden = details.fields.some((f) => f.value.includes(REDACTED) || f.key.includes(REDACTED));
|
|
373
|
+
const unchecked = tooLong
|
|
374
|
+
? 'too_long'
|
|
375
|
+
: hidden
|
|
376
|
+
? 'hidden'
|
|
377
|
+
: undefined;
|
|
378
|
+
const subagent = agentId !== '' || agentType !== '';
|
|
379
|
+
const agent = subagent && AGENT_REF_RE.test(agentId)
|
|
380
|
+
? { ref: agentId, ...(AGENT_TYPE_RE.test(agentType) ? { type: agentType } : {}) }
|
|
381
|
+
: undefined;
|
|
382
|
+
let place = await placement(state, ctx, agentId, agentType);
|
|
383
|
+
// Registered before the post, so a verdict in the next poll finds its waiter.
|
|
384
|
+
const inbox = new CardInbox();
|
|
385
|
+
let staleReason;
|
|
386
|
+
let wake;
|
|
387
|
+
const settle = (reason) => {
|
|
388
|
+
staleReason ??= reason;
|
|
389
|
+
wake?.();
|
|
390
|
+
};
|
|
391
|
+
let pollNow = false;
|
|
392
|
+
/** The session can't answer any more: switched off, or ended (from a poll or a GET). */
|
|
393
|
+
let gone;
|
|
394
|
+
const goneBy = (err) => {
|
|
395
|
+
const code = codeOf(err);
|
|
396
|
+
if (code === 'agent_sessions_off')
|
|
397
|
+
return { status: 'closed', reason: 'switched_off' };
|
|
398
|
+
if (code === 'token_revoked' || code === 'token_expired' || code === 'token_invalid') {
|
|
399
|
+
return { status: 'closed', reason: 'session_ended' };
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
};
|
|
403
|
+
/** Read through a function: a poll or GET may set it while this call waits. */
|
|
404
|
+
const goneNow = () => gone;
|
|
405
|
+
const onPollFailed = (err) => {
|
|
406
|
+
gone ??= goneBy(err);
|
|
407
|
+
pollNow = true;
|
|
408
|
+
wake?.();
|
|
409
|
+
};
|
|
410
|
+
let unregister = state.waiters.register(id, inbox, place.thread);
|
|
411
|
+
const postedAt = new Date(clock()).toISOString();
|
|
412
|
+
const leave = approvals.add({ id, agentRef: agentId, toolUseId, postedAt, settle });
|
|
413
|
+
state.pollFailed.add(onPollFailed);
|
|
414
|
+
const closer = apiApprovalCloser(ctx.api, ctx.logger);
|
|
415
|
+
const closeLater = (reason) => {
|
|
416
|
+
void closer(id, reason);
|
|
417
|
+
};
|
|
418
|
+
let progress;
|
|
419
|
+
let answer;
|
|
420
|
+
let typingThread;
|
|
421
|
+
try {
|
|
422
|
+
const post = (thread) => postSessionApproval(ctx.api, {
|
|
423
|
+
id,
|
|
424
|
+
...(thread === undefined ? {} : { thread_id: thread }),
|
|
425
|
+
tool_name: toolName,
|
|
426
|
+
...(toolUseId === undefined ? {} : { tool_use_id: toolUseId }),
|
|
427
|
+
...(agent === undefined ? {} : { agent }),
|
|
428
|
+
...(details.description === undefined ? {} : { description: details.description }),
|
|
429
|
+
fields: details.fields,
|
|
430
|
+
...(details.more_fields === undefined ? {} : { more_fields: details.more_fields }),
|
|
431
|
+
...(rule === undefined ? {} : { suggestion: rule }),
|
|
432
|
+
wait_s: waitS,
|
|
433
|
+
}, ctx.apiOptions);
|
|
434
|
+
/**
|
|
435
|
+
* A post that failed: when it may have been stored anyway (or the id is
|
|
436
|
+
* another call's), the card is closed as interrupted and stays in the
|
|
437
|
+
* journal until that close succeeds; then the terminal decides.
|
|
438
|
+
*/
|
|
439
|
+
const failed = (err) => {
|
|
440
|
+
const code = codeOf(err);
|
|
441
|
+
ctx.logger.info('tool approval not posted', { code });
|
|
442
|
+
if (mayBeStored(err) || code === 'idempotency_conflict') {
|
|
443
|
+
approvals.orphan(id, postedAt);
|
|
444
|
+
void closer(id, 'interrupted').then((ok) => {
|
|
445
|
+
if (ok)
|
|
446
|
+
approvals.forget(id);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return pass('post_failed');
|
|
450
|
+
};
|
|
451
|
+
let posted;
|
|
452
|
+
try {
|
|
453
|
+
posted = await post(place.thread);
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
if (!(place.item && ITEM_FALLBACK_CODES.has(codeOf(err))))
|
|
457
|
+
return failed(err);
|
|
458
|
+
// The item's thread refused it: ask in the direct message instead.
|
|
459
|
+
ctx.logger.info('tool approval moved to the direct message', { code: codeOf(err) });
|
|
460
|
+
place = { thread: state.lastDmThread, item: false };
|
|
461
|
+
unregister();
|
|
462
|
+
unregister = state.waiters.register(id, inbox, place.thread);
|
|
463
|
+
try {
|
|
464
|
+
posted = await post(place.thread);
|
|
465
|
+
}
|
|
466
|
+
catch (again) {
|
|
467
|
+
return failed(again);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const init = {
|
|
471
|
+
id,
|
|
472
|
+
owner,
|
|
473
|
+
minutes,
|
|
474
|
+
rule,
|
|
475
|
+
unchecked,
|
|
476
|
+
mismatch: () => {
|
|
477
|
+
ctx.logger.warn('tool approval rule not granted', { reason: 'rule_mismatch' });
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
ctx.logger.info('tool approval posted', {
|
|
481
|
+
message_id: id,
|
|
482
|
+
created: posted.created,
|
|
483
|
+
correlated: toolUseId !== undefined,
|
|
484
|
+
});
|
|
485
|
+
if (posted.approval.status !== 'open') {
|
|
486
|
+
answer = answerOf(posted.approval, init);
|
|
487
|
+
return answer;
|
|
488
|
+
}
|
|
489
|
+
typingThread = place.thread ?? posted.message.thread_id ?? undefined;
|
|
490
|
+
state.typing?.stop(typingThread);
|
|
491
|
+
if (ctx.progress !== undefined) {
|
|
492
|
+
const report = ctx.progress;
|
|
493
|
+
let beats = 0;
|
|
494
|
+
progress = setInterval(() => {
|
|
495
|
+
beats++;
|
|
496
|
+
void report(`Waiting for the person's approval in buildIt.Social (${Math.round((beats * PROGRESS_EVERY_S) / 60)} min).`).catch(() => undefined);
|
|
497
|
+
}, PROGRESS_EVERY_S * scale);
|
|
498
|
+
}
|
|
499
|
+
const deadline = started + waitS * scale;
|
|
500
|
+
let nextPoll = clock() + POLL_EVERY_S * scale;
|
|
501
|
+
const readState = async () => {
|
|
502
|
+
try {
|
|
503
|
+
const r = await getSessionApproval(ctx.api, id, ctx.apiOptions);
|
|
504
|
+
return r.approval.status === 'open' ? undefined : r.approval;
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
ctx.logger.info('tool approval state not read', { code: codeOf(err) });
|
|
508
|
+
gone ??= goneBy(err);
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
for (;;) {
|
|
513
|
+
const ended = goneNow();
|
|
514
|
+
if (ended !== undefined) {
|
|
515
|
+
answer = answerOf(ended, init);
|
|
516
|
+
return answer;
|
|
517
|
+
}
|
|
518
|
+
if (staleReason !== undefined) {
|
|
519
|
+
closeLater(staleReason);
|
|
520
|
+
answer = answerOf({ status: staleReason }, init);
|
|
521
|
+
return answer;
|
|
522
|
+
}
|
|
523
|
+
if (ctx.signal?.aborted === true) {
|
|
524
|
+
closeLater('interrupted');
|
|
525
|
+
answer = pass('aborted', id);
|
|
526
|
+
return answer;
|
|
527
|
+
}
|
|
528
|
+
const now = clock();
|
|
529
|
+
if (now >= deadline) {
|
|
530
|
+
// The owner may have tapped in the last second: the close answers the final state.
|
|
531
|
+
let final = { status: 'expired', reason: 'client' };
|
|
532
|
+
try {
|
|
533
|
+
const closed = await closeSessionApproval(ctx.api, id, 'expired', {
|
|
534
|
+
...(ctx.apiOptions.tool === undefined ? {} : { tool: ctx.apiOptions.tool }),
|
|
535
|
+
signal: AbortSignal.timeout(5000),
|
|
536
|
+
});
|
|
537
|
+
if (closed.approval.status !== 'open')
|
|
538
|
+
final = closed.approval;
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
ctx.logger.info('tool approval not closed', { code: codeOf(err) });
|
|
542
|
+
}
|
|
543
|
+
answer = answerOf(final, init);
|
|
544
|
+
return answer;
|
|
545
|
+
}
|
|
546
|
+
if (pollNow || now >= nextPoll) {
|
|
547
|
+
pollNow = false;
|
|
548
|
+
nextPoll = now + POLL_EVERY_S * scale;
|
|
549
|
+
const final = await readState();
|
|
550
|
+
if (goneNow() !== undefined)
|
|
551
|
+
continue;
|
|
552
|
+
if (final !== undefined) {
|
|
553
|
+
answer = answerOf(final, init);
|
|
554
|
+
return answer;
|
|
555
|
+
}
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const local = new AbortController();
|
|
559
|
+
wake = () => {
|
|
560
|
+
local.abort();
|
|
561
|
+
};
|
|
562
|
+
const signal = ctx.signal === undefined ? local.signal : AbortSignal.any([local.signal, ctx.signal]);
|
|
563
|
+
const next = await inbox.next(Math.min(deadline, nextPoll), signal, clock);
|
|
564
|
+
wake = undefined;
|
|
565
|
+
if (next === 'timeout' || next === 'aborted')
|
|
566
|
+
continue;
|
|
567
|
+
if (next === 'session_ended') {
|
|
568
|
+
answer = answerOf({ status: 'closed', reason: 'session_ended' }, init);
|
|
569
|
+
return answer;
|
|
570
|
+
}
|
|
571
|
+
if (next === 'closed') {
|
|
572
|
+
// The session was taken off the item: the server closed the card.
|
|
573
|
+
answer = answerOf({ status: 'closed', reason: 'unassigned' }, init);
|
|
574
|
+
return answer;
|
|
575
|
+
}
|
|
576
|
+
const { event, done } = next;
|
|
577
|
+
try {
|
|
578
|
+
if (event.type !== 'card.closed' || eventMessageId(event) !== id)
|
|
579
|
+
continue;
|
|
580
|
+
const final = eventApproval(event, id);
|
|
581
|
+
if (final === undefined) {
|
|
582
|
+
// A close without the verdict: read it.
|
|
583
|
+
pollNow = true;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
answer = answerOf(final, init);
|
|
587
|
+
return answer;
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
done();
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
if (progress !== undefined)
|
|
596
|
+
clearInterval(progress);
|
|
597
|
+
state.pollFailed.delete(onPollFailed);
|
|
598
|
+
unregister();
|
|
599
|
+
leave();
|
|
600
|
+
inbox.drain();
|
|
601
|
+
if (answer !== undefined) {
|
|
602
|
+
const verdict = {
|
|
603
|
+
status: answer.structured.status,
|
|
604
|
+
...(answer.structured.reason === undefined ? {} : { reason: answer.structured.reason }),
|
|
605
|
+
text: answer.text,
|
|
606
|
+
toolName,
|
|
607
|
+
hash,
|
|
608
|
+
...(rule === undefined ? {} : { rule }),
|
|
609
|
+
};
|
|
610
|
+
state.waiters.rememberApproval(id, verdict);
|
|
611
|
+
if ((answer.structured.status === 'approved' ||
|
|
612
|
+
answer.structured.status === 'approved_session') &&
|
|
613
|
+
typingThread !== undefined) {
|
|
614
|
+
// The agent works again there.
|
|
615
|
+
state.typing?.work(typingThread);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// The hooks that end approvals
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
/** A hook field that may be long or of any type (only strings count). */
|
|
625
|
+
const HookString = z.unknown().optional();
|
|
626
|
+
const str = (v) => hookField(v);
|
|
627
|
+
/** What every hook tool answers: nothing for Claude Code to do. */
|
|
628
|
+
const EMPTY = { structured: { ok: true }, text: '{}' };
|
|
629
|
+
const OkSchema = z.object({ ok: z.boolean() });
|
|
630
|
+
/**
|
|
631
|
+
* The hooks' annotations: tool_seen only records the call here (read-only);
|
|
632
|
+
* the others may close a card in buildIt.Social. All of them stay listed
|
|
633
|
+
* under --read-only (where approve isn't, so there is nothing for them to
|
|
634
|
+
* close), so the plugin's hooks never fail.
|
|
635
|
+
*/
|
|
636
|
+
const hookAnnotations = (title, readOnly) => ({
|
|
637
|
+
title,
|
|
638
|
+
readOnlyHint: readOnly,
|
|
639
|
+
destructiveHint: false,
|
|
640
|
+
idempotentHint: true,
|
|
641
|
+
openWorldHint: !readOnly,
|
|
642
|
+
});
|
|
643
|
+
/**
|
|
644
|
+
* Whether a prompt Claude Code submitted was typed by the person: a channel
|
|
645
|
+
* message (`<channel …>`) and a background task's report
|
|
646
|
+
* (`<task-notification>`) arrive as prompts too, and say nothing about the
|
|
647
|
+
* approvals waiting.
|
|
648
|
+
*/
|
|
649
|
+
export function typedByPerson(prompt) {
|
|
650
|
+
if (typeof prompt !== 'string')
|
|
651
|
+
return true;
|
|
652
|
+
const p = prompt.trimStart();
|
|
653
|
+
return !p.startsWith('<channel') && !p.startsWith('<task-notification');
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* tool_seen (PreToolUse), tool_done (PostToolUse, PostToolUseFailure),
|
|
657
|
+
* agent_done (SubagentStop) and prompt_typed (UserPromptSubmit).
|
|
658
|
+
*/
|
|
659
|
+
export function createApprovalHookTools(state) {
|
|
660
|
+
const guarded = (name, body) => (args, ctx) => {
|
|
661
|
+
try {
|
|
662
|
+
body(args, ctx);
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
ctx.logger.info(`${name} failed`, { code: codeOf(err) });
|
|
666
|
+
}
|
|
667
|
+
return Promise.resolve(EMPTY);
|
|
668
|
+
};
|
|
669
|
+
const toolSeen = defineTool({
|
|
670
|
+
name: 'tool_seen',
|
|
671
|
+
toolset: 'chat',
|
|
672
|
+
title: 'Tool call seen (plugin hook)',
|
|
673
|
+
description: "Called by the buildit plugin's PreToolUse hook before each tool call, so a tool approval can name its call. Not for you to call.",
|
|
674
|
+
scopes: [],
|
|
675
|
+
runsAfterSessionEnd: true,
|
|
676
|
+
annotations: hookAnnotations('Tool call seen (plugin hook)', true),
|
|
677
|
+
inputSchema: z.object({
|
|
678
|
+
tool_name: HookString,
|
|
679
|
+
tool_input: HookString,
|
|
680
|
+
tool_use_id: HookString,
|
|
681
|
+
agent_id: HookString,
|
|
682
|
+
}),
|
|
683
|
+
outputSchema: OkSchema,
|
|
684
|
+
run: guarded('tool_seen', (args) => {
|
|
685
|
+
const toolName = str(args.tool_name);
|
|
686
|
+
const toolUseId = str(args.tool_use_id);
|
|
687
|
+
const agentId = str(args.agent_id);
|
|
688
|
+
if (toolName !== '' && TOOL_USE_ID_RE.test(toolUseId)) {
|
|
689
|
+
const input = parseToolInput(args.tool_input);
|
|
690
|
+
state.ring.record({
|
|
691
|
+
agentId,
|
|
692
|
+
toolName,
|
|
693
|
+
hash: inputHash(input ?? args.tool_input ?? null),
|
|
694
|
+
toolUseId,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
// Claude is at work: the main agent on its thread, an item worker on its item's.
|
|
698
|
+
const typing = state.typing;
|
|
699
|
+
if (typing === undefined)
|
|
700
|
+
return;
|
|
701
|
+
if (agentId === '') {
|
|
702
|
+
typing.activity();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (state.folder === undefined)
|
|
706
|
+
return;
|
|
707
|
+
const entry = workerEntry(state.folder, agentId);
|
|
708
|
+
if (entry === undefined)
|
|
709
|
+
return;
|
|
710
|
+
const thread = entry.thread_id !== undefined && isUuid(entry.thread_id)
|
|
711
|
+
? entry.thread_id
|
|
712
|
+
: entry.item === undefined
|
|
713
|
+
? undefined
|
|
714
|
+
: state.items.get(entry.item.toUpperCase());
|
|
715
|
+
if (thread !== undefined && state.activeItems.has(thread))
|
|
716
|
+
typing.work(thread);
|
|
717
|
+
}),
|
|
718
|
+
});
|
|
719
|
+
const toolDone = defineTool({
|
|
720
|
+
name: 'tool_done',
|
|
721
|
+
toolset: 'chat',
|
|
722
|
+
title: 'Tool call done (plugin hook)',
|
|
723
|
+
description: "Called by the buildit plugin's PostToolUse and PostToolUseFailure hooks after each tool call: a tool approval still waiting for that call was answered in the terminal. Not for you to call.",
|
|
724
|
+
scopes: [],
|
|
725
|
+
runsAfterSessionEnd: true,
|
|
726
|
+
annotations: hookAnnotations('Tool call done (plugin hook)', false),
|
|
727
|
+
inputSchema: z.object({ tool_use_id: HookString, agent_id: HookString }),
|
|
728
|
+
outputSchema: OkSchema,
|
|
729
|
+
run: guarded('tool_done', (args, ctx) => {
|
|
730
|
+
const toolUseId = str(args.tool_use_id);
|
|
731
|
+
if (toolUseId === '')
|
|
732
|
+
return;
|
|
733
|
+
const n = state.approvals?.toolDone(toolUseId) ?? 0;
|
|
734
|
+
if (n > 0)
|
|
735
|
+
ctx.logger.info('tool approval answered elsewhere', { count: n });
|
|
736
|
+
}),
|
|
737
|
+
});
|
|
738
|
+
const agentDone = defineTool({
|
|
739
|
+
name: 'agent_done',
|
|
740
|
+
toolset: 'chat',
|
|
741
|
+
title: 'Subagent done (plugin hook)',
|
|
742
|
+
description: "Called by the buildit plugin's SubagentStop hook when a subagent stops: its tool approvals are no longer needed. Not for you to call.",
|
|
743
|
+
scopes: [],
|
|
744
|
+
runsAfterSessionEnd: true,
|
|
745
|
+
annotations: hookAnnotations('Subagent done (plugin hook)', false),
|
|
746
|
+
inputSchema: z.object({ agent_id: HookString, agent_type: HookString }),
|
|
747
|
+
outputSchema: OkSchema,
|
|
748
|
+
run: guarded('agent_done', (args, ctx) => {
|
|
749
|
+
const agentId = str(args.agent_id);
|
|
750
|
+
if (agentId === '')
|
|
751
|
+
return;
|
|
752
|
+
const n = state.approvals?.agentDone(agentId) ?? 0;
|
|
753
|
+
if (n > 0)
|
|
754
|
+
ctx.logger.info('tool approvals no longer needed', { count: n });
|
|
755
|
+
}),
|
|
756
|
+
});
|
|
757
|
+
const promptTyped = defineTool({
|
|
758
|
+
name: 'prompt_typed',
|
|
759
|
+
toolset: 'chat',
|
|
760
|
+
title: 'Prompt typed (plugin hook)',
|
|
761
|
+
description: "Called by the buildit plugin's UserPromptSubmit hook: a new prompt from the person means the main agent's tool approvals are no longer needed. Not for you to call.",
|
|
762
|
+
scopes: [],
|
|
763
|
+
runsAfterSessionEnd: true,
|
|
764
|
+
annotations: hookAnnotations('Prompt typed (plugin hook)', false),
|
|
765
|
+
inputSchema: z.object({ prompt: HookString }),
|
|
766
|
+
outputSchema: OkSchema,
|
|
767
|
+
run: guarded('prompt_typed', (args, ctx) => {
|
|
768
|
+
if (!typedByPerson(args.prompt))
|
|
769
|
+
return;
|
|
770
|
+
const n = state.approvals?.agentDone('') ?? 0;
|
|
771
|
+
if (n > 0)
|
|
772
|
+
ctx.logger.info('tool approvals no longer needed', { count: n });
|
|
773
|
+
}),
|
|
774
|
+
});
|
|
775
|
+
return [toolSeen, toolDone, agentDone, promptTyped];
|
|
776
|
+
}
|
|
777
|
+
/** The hook tools listed under --read-only too (no-ops there: approve, which posts, isn't). */
|
|
778
|
+
export const READ_ONLY_SAFE_HOOKS = new Set([
|
|
779
|
+
'tool_seen',
|
|
780
|
+
'tool_done',
|
|
781
|
+
'agent_done',
|
|
782
|
+
'prompt_typed',
|
|
783
|
+
]);
|
|
784
|
+
/** The hook tools that are no activity of their own for "is writing" (tool_seen counts it itself). */
|
|
785
|
+
export const APPROVAL_HOOK_TOOLS = new Set([
|
|
786
|
+
'approve',
|
|
787
|
+
'tool_seen',
|
|
788
|
+
'tool_done',
|
|
789
|
+
'agent_done',
|
|
790
|
+
'prompt_typed',
|
|
791
|
+
]);
|