@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,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool approval card's details hold, and how they are made safe to
|
|
3
|
+
* show.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code's PermissionRequest hook passes the tool call's raw input.
|
|
6
|
+
* Before any of it leaves this process, every value is cleaned (control and
|
|
7
|
+
* bidi characters removed), credential-shaped text in it is replaced with
|
|
8
|
+
* `[REDACTED]`, and long values are cut. buildIt.Social cleans and masks the
|
|
9
|
+
* stored details again with the same rules, so a second pass changes nothing
|
|
10
|
+
* this one already did (the rules are idempotent).
|
|
11
|
+
*
|
|
12
|
+
* The rules are a list of known shapes: private key blocks, Authorization
|
|
13
|
+
* headers and bearer tokens, passwords in URLs, password and token options
|
|
14
|
+
* and assignments, the prefixes of well-known API keys, and JSON Web Tokens;
|
|
15
|
+
* plus any field whose name says it is a secret. A secret without a known
|
|
16
|
+
* shape is not caught.
|
|
17
|
+
*/
|
|
18
|
+
/* eslint-disable no-control-regex, no-misleading-character-class -- removing control, combining and variation characters is the point */
|
|
19
|
+
/**
|
|
20
|
+
* Removed from every value: control characters but the line feed and tab,
|
|
21
|
+
* the soft hyphen, bidi controls, zero-width and invisible joiners and
|
|
22
|
+
* separators, variation selectors, the byte order mark and tag characters.
|
|
23
|
+
*/
|
|
24
|
+
const STRIP = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u00AD\u034F\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060\u2066-\u2069\uFE00-\uFE0F\uFEFF\u{E0000}-\u{E007F}]/gu;
|
|
25
|
+
/* eslint-enable no-control-regex, no-misleading-character-class */
|
|
26
|
+
/** What replaces a secret. */
|
|
27
|
+
export const REDACTED = '[REDACTED]';
|
|
28
|
+
/**
|
|
29
|
+
* A value as it may be shown: CR LF, CR, and the Unicode line and paragraph
|
|
30
|
+
* separators become line feeds; the other control characters, bidi controls,
|
|
31
|
+
* zero-width characters and the byte order mark go. Line feeds and tabs stay.
|
|
32
|
+
*/
|
|
33
|
+
export function cleanApprovalText(text) {
|
|
34
|
+
return text
|
|
35
|
+
.replace(/\r\n?/g, '\n')
|
|
36
|
+
.replace(/[\u2028\u2029]/g, '\n')
|
|
37
|
+
.replace(STRIP, '');
|
|
38
|
+
}
|
|
39
|
+
/** A field's name: cleaned, its line feeds and tabs folded to spaces, trimmed. */
|
|
40
|
+
export function cleanApprovalKey(text) {
|
|
41
|
+
return cleanApprovalText(text)
|
|
42
|
+
.replace(/[\n\t]/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The masking rules, in the order they apply. Each replaces only the secret.
|
|
47
|
+
*
|
|
48
|
+
* **Masking never hides what is being approved.** A span becomes
|
|
49
|
+
* `[REDACTED]` only when all of it is in the token charset
|
|
50
|
+
* `[A-Za-z0-9._~+=-]`: never shell syntax, a path, a URL, a space or a line
|
|
51
|
+
* break. A value that holds anything else (`"$(curl … | sh)"`, a path) is
|
|
52
|
+
* shown as it is, and quotes around a value stay. A key block is masked
|
|
53
|
+
* only when every line between its BEGIN and END lines is armor (base64, or
|
|
54
|
+
* a `Name: value` header). Masking twice gives what masking once gave
|
|
55
|
+
* (`[REDACTED]` is not in the token charset).
|
|
56
|
+
*
|
|
57
|
+
* They are written so that no input makes them slow: a match may start only
|
|
58
|
+
* where a lookbehind allows it (the start of a name, a token, an option or
|
|
59
|
+
* a URL, never inside a run of letters), names are at most 64 characters
|
|
60
|
+
* and spaces 16, and the alternatives of a value start differently, so a
|
|
61
|
+
* regular expression engine that takes the longest match gives the same
|
|
62
|
+
* result as this one (which takes the first alternative).
|
|
63
|
+
*/
|
|
64
|
+
const RULES = [
|
|
65
|
+
// R1: a PEM or PGP private key block whose body is all armor, to its END line or the end.
|
|
66
|
+
{
|
|
67
|
+
pattern: /-----BEGIN [A-Z0-9 ]{0,64}PRIVATE KEY(?: BLOCK)?-----\n(?:(?:[A-Za-z0-9+/=]*|[A-Za-z0-9-]{1,64}: [A-Za-z0-9._~+= -]*)\n)*?(?:-----END [A-Z0-9 ]{0,64}PRIVATE KEY(?: BLOCK)?-----|(?:[A-Za-z0-9+/=]*|[A-Za-z0-9-]{1,64}: [A-Za-z0-9._~+= -]*)$)/g,
|
|
68
|
+
replacement: REDACTED,
|
|
69
|
+
},
|
|
70
|
+
// R2: an Authorization header's credentials (its quotes may be JSON-escaped).
|
|
71
|
+
{
|
|
72
|
+
pattern: /(authorization(?:\\?["'])?[ \t\n\v\f\r]{0,16}[:=][ \t\n\v\f\r]{0,16}(?:\\?["'])?(?:bearer|basic|token|bot)[ \t\n\v\f\r]{1,16})[A-Za-z0-9._~+=-]+(?![^ \t\n\v\f\r"'\\&;,])/gi,
|
|
73
|
+
replacement: `$1${REDACTED}`,
|
|
74
|
+
},
|
|
75
|
+
// R3: a bearer token anywhere.
|
|
76
|
+
{
|
|
77
|
+
pattern: /(?<![A-Za-z0-9_])(bearer[ \t\n\v\f\r]{1,16})[A-Za-z0-9._~+=-]{16,}(?![^ \t\n\v\f\r"'\\&;,])/gi,
|
|
78
|
+
replacement: `$1${REDACTED}`,
|
|
79
|
+
},
|
|
80
|
+
// R4: the password in a URL's user info (the host after it stays).
|
|
81
|
+
{
|
|
82
|
+
pattern: /(?<![a-z0-9+.-])([a-z][a-z0-9+.-]{0,31}:\/\/[^/ \t\n\v\f\r:@]{1,64}:)[A-Za-z0-9._~+=-]+@/gi,
|
|
83
|
+
replacement: `$1${REDACTED}@`,
|
|
84
|
+
},
|
|
85
|
+
// R5: a password, secret, token or API key option's value (--password x, --token=x),
|
|
86
|
+
// plain or in quotes (which stay).
|
|
87
|
+
{
|
|
88
|
+
pattern: /(?<![^ \t\n\v\f\r])(--?[A-Za-z0-9-]{0,64}(?:password|passwd|secret|token|api-?key)(?:[ \t\n\v\f\r]{1,16}|=)(?:"(?=[A-Za-z0-9._~+=-]+")|'(?=[A-Za-z0-9._~+=-]+')|(?=[A-Za-z0-9._~+=-]+(?![^ \t\n\v\f\r]))))[A-Za-z0-9._~+=-]+/gi,
|
|
89
|
+
replacement: `$1${REDACTED}`,
|
|
90
|
+
},
|
|
91
|
+
// R6: an assignment or JSON member whose name says it holds a secret: the value
|
|
92
|
+
// plain, in quotes, or in JSON-escaped quotes inside a shell string (the quotes stay).
|
|
93
|
+
{
|
|
94
|
+
pattern: /(?<![A-Za-z0-9_-])([A-Za-z0-9_-]{0,64}(?:password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key|access[_-]?key)(?:\\?["'])?[ \t\n\v\f\r]{0,16}[:=][ \t\n\v\f\r]{0,16}(?:\\"(?=[A-Za-z0-9._~+=-]+\\")|"(?=[A-Za-z0-9._~+=-]+")|'(?=[A-Za-z0-9._~+=-]+')|(?=[A-Za-z0-9._~+=-]+(?![^ \t\n\v\f\r"'\\&;,]))))[A-Za-z0-9._~+=-]+/gi,
|
|
95
|
+
replacement: `$1${REDACTED}`,
|
|
96
|
+
},
|
|
97
|
+
// R7: well-known API key and token prefixes.
|
|
98
|
+
{
|
|
99
|
+
pattern: /(?<![A-Za-z0-9_])(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|xapp-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{30,}|[sr]k_(?:live|test)_[A-Za-z0-9]{10,}|whsec_[A-Za-z0-9]{10,}|buildit_(?:pat|ags|app)_[A-Za-z0-9]{10,}|buildit_whsec_[0-9a-f]{10,})/g,
|
|
100
|
+
replacement: REDACTED,
|
|
101
|
+
},
|
|
102
|
+
// R8: a JSON Web Token.
|
|
103
|
+
{
|
|
104
|
+
pattern: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]*)?/g,
|
|
105
|
+
replacement: REDACTED,
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
/** R9: a field whose name says its whole value is a secret. */
|
|
109
|
+
const SECRET_KEY = /^[A-Za-z0-9_-]{0,64}(?:password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key|access[_-]?key|authorization|credentials?)$/i;
|
|
110
|
+
/** The only characters a masked span may hold. */
|
|
111
|
+
const TOKEN_CHARSET = /^[A-Za-z0-9._~+=-]+$/;
|
|
112
|
+
/** Replaces every credential-shaped part of a text with `[REDACTED]` (rules R1 to R8). */
|
|
113
|
+
export function maskCredentials(text) {
|
|
114
|
+
let out = text;
|
|
115
|
+
for (const rule of RULES)
|
|
116
|
+
out = out.replace(rule.pattern, rule.replacement);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A field's value masked: R1 to R8, then, when the field's name is a
|
|
121
|
+
* secret's and the (masked) value is all token charset, the whole value
|
|
122
|
+
* (R9). A value with anything else in it keeps what R1 to R8 made of it.
|
|
123
|
+
*/
|
|
124
|
+
export function maskField(key, value) {
|
|
125
|
+
const masked = maskCredentials(value);
|
|
126
|
+
return SECRET_KEY.test(key) && TOKEN_CHARSET.test(masked) ? REDACTED : masked;
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Cutting
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
/** The longest value kept whole, in code points. */
|
|
132
|
+
export const VALUE_MAX = 3500;
|
|
133
|
+
/** What a cut value keeps: its first and last code points around the marker. */
|
|
134
|
+
export const VALUE_HEAD = 2400;
|
|
135
|
+
export const VALUE_TAIL = 1000;
|
|
136
|
+
/** Marks where a value was cut. */
|
|
137
|
+
export const CUT_MARKER = '\n⋯\n';
|
|
138
|
+
const MARKER_LENGTH = Array.from(CUT_MARKER).length;
|
|
139
|
+
/** The longest description, in code points. */
|
|
140
|
+
export const DESCRIPTION_MAX = 1000;
|
|
141
|
+
/** The longest field name, in code points. */
|
|
142
|
+
export const KEY_MAX = 100;
|
|
143
|
+
/** At most this many fields; the rest are counted in more_fields. */
|
|
144
|
+
export const FIELDS_MAX = 20;
|
|
145
|
+
/** The details' JSON, in UTF-8 bytes, at most. */
|
|
146
|
+
export const DETAILS_MAX_BYTES = 12_288;
|
|
147
|
+
/** A value is never cut below this many code points to fit the details' size; fields go instead. */
|
|
148
|
+
const VALUE_FLOOR = 200;
|
|
149
|
+
/**
|
|
150
|
+
* Before masking, a value over this many code points keeps only its first
|
|
151
|
+
* and last PRECUT_KEEP (so masking a huge input stays fast; it is cut far
|
|
152
|
+
* below this afterwards anyway).
|
|
153
|
+
*/
|
|
154
|
+
export const PRECUT_MAX = 16_384;
|
|
155
|
+
export const PRECUT_KEEP = 8_192;
|
|
156
|
+
/** The text cut to `max` code points with "…", only when it is longer. */
|
|
157
|
+
function cutWithEllipsis(text, max) {
|
|
158
|
+
const chars = Array.from(text);
|
|
159
|
+
return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…';
|
|
160
|
+
}
|
|
161
|
+
/** The pre-cut marker's place: the marker nearest the middle. */
|
|
162
|
+
function markerIndex(chars) {
|
|
163
|
+
let best = -1;
|
|
164
|
+
const middle = chars.length / 2;
|
|
165
|
+
for (let i = 0; i + 2 < chars.length; i++) {
|
|
166
|
+
if (chars[i] === '\n' && chars[i + 1] === '⋯' && chars[i + 2] === '\n') {
|
|
167
|
+
if (best < 0 || Math.abs(i - middle) < Math.abs(best - middle))
|
|
168
|
+
best = i;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return best;
|
|
172
|
+
}
|
|
173
|
+
/** Cleans a value, cuts it to its ends when it is huge, and masks it. */
|
|
174
|
+
function prepare(text, mask) {
|
|
175
|
+
const cleaned = Array.from(cleanApprovalText(text));
|
|
176
|
+
if (cleaned.length <= PRECUT_MAX) {
|
|
177
|
+
return { chars: Array.from(mask(cleaned.join(''))), base: 0, markerAt: -1 };
|
|
178
|
+
}
|
|
179
|
+
const pre = cleaned.slice(0, PRECUT_KEEP).join('') +
|
|
180
|
+
CUT_MARKER +
|
|
181
|
+
cleaned.slice(cleaned.length - PRECUT_KEEP).join('');
|
|
182
|
+
const chars = Array.from(mask(pre));
|
|
183
|
+
return { chars, base: cleaned.length - 2 * PRECUT_KEEP, markerAt: markerIndex(chars) };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A value cut to its first `head` and last `tail` code points around the
|
|
187
|
+
* marker, and how many of the original code points were left out (the
|
|
188
|
+
* pre-cut's too, but not its marker).
|
|
189
|
+
*/
|
|
190
|
+
function keepEnds(p, head, tail) {
|
|
191
|
+
const n = p.chars.length;
|
|
192
|
+
if (head + tail >= n)
|
|
193
|
+
return { value: p.chars.join(''), cut: p.base };
|
|
194
|
+
const removedMarker = p.markerAt < 0
|
|
195
|
+
? 0
|
|
196
|
+
: Math.max(0, Math.min(p.markerAt + MARKER_LENGTH, n - tail) - Math.max(p.markerAt, head));
|
|
197
|
+
return {
|
|
198
|
+
value: p.chars.slice(0, head).join('') + CUT_MARKER + p.chars.slice(n - tail).join(''),
|
|
199
|
+
cut: p.base + (n - head - tail) - removedMarker,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** A value over 3,500 code points: its first 2,400 and last 1,000, with the marker between. */
|
|
203
|
+
export function cutValue(value) {
|
|
204
|
+
const chars = Array.from(value);
|
|
205
|
+
if (chars.length <= VALUE_MAX)
|
|
206
|
+
return { value, cut: 0 };
|
|
207
|
+
return keepEnds({ chars, base: 0, markerAt: -1 }, VALUE_HEAD, VALUE_TAIL);
|
|
208
|
+
}
|
|
209
|
+
const bytesOf = (details) => Buffer.byteLength(JSON.stringify(details), 'utf8');
|
|
210
|
+
function assemble(description, fields, more) {
|
|
211
|
+
return {
|
|
212
|
+
...(description === undefined ? {} : { description }),
|
|
213
|
+
fields: fields.map((f) => ({
|
|
214
|
+
key: f.key,
|
|
215
|
+
value: f.value,
|
|
216
|
+
...(f.cut > 0 ? { cut: f.cut } : {}),
|
|
217
|
+
})),
|
|
218
|
+
...(more > 0 ? { more_fields: more } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The details of a tool call, cleaned, masked and cut: for Bash and
|
|
223
|
+
* PowerShell a string `description` is the description; every other
|
|
224
|
+
* top-level key of the input, in order, is a field (a string as it is,
|
|
225
|
+
* anything else as indented JSON). Each value is cleaned, cut to its first
|
|
226
|
+
* and last 8,192 code points when it is over 16,384 (so masking stays
|
|
227
|
+
* fast), masked, then cut past 3,500 code points; at most 20 fields; then, while the JSON is over
|
|
228
|
+
* 12 KB, the longest value is cut to half (never below 200 code points),
|
|
229
|
+
* and once none is longer than that, fields are dropped from the end.
|
|
230
|
+
*/
|
|
231
|
+
export function buildDetails(toolName, input) {
|
|
232
|
+
let description;
|
|
233
|
+
const entries = Object.entries(input);
|
|
234
|
+
const pending = [];
|
|
235
|
+
let more = 0;
|
|
236
|
+
for (const [rawKey, raw] of entries) {
|
|
237
|
+
if ((toolName === 'Bash' || toolName === 'PowerShell') &&
|
|
238
|
+
rawKey === 'description' &&
|
|
239
|
+
typeof raw === 'string') {
|
|
240
|
+
const text = prepare(raw, maskCredentials).chars.join('').trim();
|
|
241
|
+
if (text !== '')
|
|
242
|
+
description = cutWithEllipsis(text, DESCRIPTION_MAX);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (pending.length >= FIELDS_MAX) {
|
|
246
|
+
more++;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const cleanedKey = cleanApprovalKey(rawKey);
|
|
250
|
+
// A key is shown too, so it is masked like a value (R9 reads the name as given).
|
|
251
|
+
const shownKey = maskCredentials(cleanedKey);
|
|
252
|
+
const key = cutWithEllipsis(shownKey === '' ? '_' : shownKey, KEY_MAX);
|
|
253
|
+
const text = typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2);
|
|
254
|
+
const full = prepare(text, (value) => maskField(cleanedKey, value));
|
|
255
|
+
const first = full.chars.length > VALUE_MAX
|
|
256
|
+
? keepEnds(full, VALUE_HEAD, VALUE_TAIL)
|
|
257
|
+
: { value: full.chars.join(''), cut: full.base };
|
|
258
|
+
pending.push({ key, full, value: first.value, cut: first.cut });
|
|
259
|
+
}
|
|
260
|
+
let details = assemble(description, pending, more);
|
|
261
|
+
while (bytesOf(details) > DETAILS_MAX_BYTES && pending.length > 0) {
|
|
262
|
+
const lengths = pending.map((f) => Array.from(f.value).length);
|
|
263
|
+
const longest = lengths.indexOf(Math.max(...lengths));
|
|
264
|
+
const field = pending[longest];
|
|
265
|
+
const kept = lengths[longest] ?? 0;
|
|
266
|
+
if (field !== undefined && kept > VALUE_FLOOR) {
|
|
267
|
+
// Cut again from the whole value: what is kept, the marker counted in.
|
|
268
|
+
const target = Math.max(VALUE_FLOOR, Math.floor(kept / 2)) - MARKER_LENGTH;
|
|
269
|
+
const head = Math.ceil((target * 2) / 3);
|
|
270
|
+
const again = keepEnds(field.full, head, target - head);
|
|
271
|
+
field.value = again.value;
|
|
272
|
+
field.cut = again.cut;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
pending.pop();
|
|
276
|
+
more++;
|
|
277
|
+
}
|
|
278
|
+
details = assemble(description, pending, more);
|
|
279
|
+
}
|
|
280
|
+
return details;
|
|
281
|
+
}
|
|
282
|
+
const TOOL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$/;
|
|
283
|
+
const RULE_FORBIDDEN =
|
|
284
|
+
// eslint-disable-next-line no-control-regex -- control characters disqualify a rule
|
|
285
|
+
/[\u0000-\u001F\u007F-\u009F\u061C\u200B-\u200F\u202A-\u202E\u2060\u2066-\u2069\uFEFF]/;
|
|
286
|
+
const RULE_CONTENT_MAX = 500;
|
|
287
|
+
const RULES_MAX = 5;
|
|
288
|
+
const RULE_MAX_BYTES = 2048;
|
|
289
|
+
const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
290
|
+
const hasOnly = (o, keys) => Object.keys(o).every((k) => keys.includes(k));
|
|
291
|
+
/** One suggestion as a session rule, or undefined when it isn't exactly one. */
|
|
292
|
+
function asSessionRule(entry, toolName) {
|
|
293
|
+
if (!isPlain(entry) || entry.destination !== 'session')
|
|
294
|
+
return undefined;
|
|
295
|
+
if (entry.type === 'setMode') {
|
|
296
|
+
if (!hasOnly(entry, ['type', 'mode', 'destination']) || entry.mode !== 'acceptEdits') {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
return { type: 'setMode', mode: 'acceptEdits', destination: 'session' };
|
|
300
|
+
}
|
|
301
|
+
if (entry.type !== 'addRules' || entry.behavior !== 'allow')
|
|
302
|
+
return undefined;
|
|
303
|
+
if (!hasOnly(entry, ['type', 'rules', 'behavior', 'destination']))
|
|
304
|
+
return undefined;
|
|
305
|
+
const rules = entry.rules;
|
|
306
|
+
if (!Array.isArray(rules) || rules.length < 1 || rules.length > RULES_MAX)
|
|
307
|
+
return undefined;
|
|
308
|
+
const out = [];
|
|
309
|
+
for (const r of rules) {
|
|
310
|
+
if (!isPlain(r) || !hasOnly(r, ['toolName', 'ruleContent']))
|
|
311
|
+
return undefined;
|
|
312
|
+
if (typeof r.toolName !== 'string' || !TOOL_NAME_RE.test(r.toolName))
|
|
313
|
+
return undefined;
|
|
314
|
+
// A rule only for the tool that asks: never another tool's.
|
|
315
|
+
if (r.toolName !== toolName)
|
|
316
|
+
return undefined;
|
|
317
|
+
if (r.ruleContent === undefined) {
|
|
318
|
+
out.push({ toolName: r.toolName });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (typeof r.ruleContent !== 'string')
|
|
322
|
+
return undefined;
|
|
323
|
+
// Cleaned by the strip rules only; a line feed or tab left in it disqualifies it.
|
|
324
|
+
const content = cleanApprovalText(r.ruleContent);
|
|
325
|
+
const chars = Array.from(content).length;
|
|
326
|
+
if (chars < 1 || chars > RULE_CONTENT_MAX || RULE_FORBIDDEN.test(content))
|
|
327
|
+
return undefined;
|
|
328
|
+
out.push({ toolName: r.toolName, ruleContent: content });
|
|
329
|
+
}
|
|
330
|
+
const rule = {
|
|
331
|
+
type: 'addRules',
|
|
332
|
+
rules: out,
|
|
333
|
+
behavior: 'allow',
|
|
334
|
+
destination: 'session',
|
|
335
|
+
};
|
|
336
|
+
return Buffer.byteLength(JSON.stringify(rule), 'utf8') <= RULE_MAX_BYTES ? rule : undefined;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The session rule to offer, from Claude Code's permission_suggestions (an
|
|
340
|
+
* array, or its JSON text): the first entry that is exactly a session-scoped
|
|
341
|
+
* allow rule (1 to 5 rules, each for the tool that asks) or "accept edits
|
|
342
|
+
* for the session". Anything else (another destination, a directory grant,
|
|
343
|
+
* another mode, a rule for another tool) is never offered.
|
|
344
|
+
*/
|
|
345
|
+
export function sessionRuleFrom(suggestions, toolName) {
|
|
346
|
+
let list = suggestions;
|
|
347
|
+
if (typeof list === 'string') {
|
|
348
|
+
if (list.trim() === '' || list.startsWith('${'))
|
|
349
|
+
return undefined;
|
|
350
|
+
try {
|
|
351
|
+
list = JSON.parse(list);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!Array.isArray(list))
|
|
358
|
+
return undefined;
|
|
359
|
+
for (const entry of list) {
|
|
360
|
+
const rule = asSessionRule(entry, toolName);
|
|
361
|
+
if (rule !== undefined)
|
|
362
|
+
return rule;
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which thread a channel tool (reply, ask, an approval card) posts in.
|
|
3
|
+
*
|
|
4
|
+
* A thread named by `thread_id`, or a work item's by `item` (its key: its
|
|
5
|
+
* id comes from the items the events named, else from get_item; the
|
|
6
|
+
* item's id is its thread's). Without either, the thread of the latest
|
|
7
|
+
* message, mention or assignment, but only while one thread is active:
|
|
8
|
+
* once the session works in several (an item's thread besides the direct
|
|
9
|
+
* message, or two items; the lead's item workers post in parallel through
|
|
10
|
+
* this one server), "the latest" could be another worker's, so a thread
|
|
11
|
+
* must be named, and the refusal lists the active ones.
|
|
12
|
+
*/
|
|
13
|
+
import { ToolInputError } from '../errors.js';
|
|
14
|
+
import { sanitizeLabel } from '../untrusted.js';
|
|
15
|
+
const UUID_RE = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
|
|
16
|
+
export const isUuid = (s) => UUID_RE.test(s);
|
|
17
|
+
/** An item reference as the tools take it (a leading # is fine). */
|
|
18
|
+
const keyOf = (ref) => (ref.startsWith('#') ? ref.slice(1) : ref);
|
|
19
|
+
/** An item's thread id: its own id, from the events' items or read with get_item. */
|
|
20
|
+
export async function itemThreadId(ref, ctx) {
|
|
21
|
+
const key = keyOf(ref);
|
|
22
|
+
if (isUuid(key))
|
|
23
|
+
return key;
|
|
24
|
+
const known = ctx.channel?.items.get(key.toUpperCase());
|
|
25
|
+
if (known !== undefined)
|
|
26
|
+
return known;
|
|
27
|
+
const { item } = await ctx.call('get_item', { path: { key } });
|
|
28
|
+
ctx.channel?.rememberItem(item.key, item.id);
|
|
29
|
+
return item.id;
|
|
30
|
+
}
|
|
31
|
+
/** The refusal when several threads are active and none was named. */
|
|
32
|
+
export function threadRequiredMessage(state, tool, how) {
|
|
33
|
+
const listed = state
|
|
34
|
+
.activeThreads()
|
|
35
|
+
.map((t) => t.item === undefined
|
|
36
|
+
? `your direct message (thread_id ${t.thread_id})`
|
|
37
|
+
: `${sanitizeLabel(t.item, 40)} (thread_id ${t.thread_id})`);
|
|
38
|
+
return `This session works in several threads now, so ${tool} needs ${how}: ${listed.join('; ')}. Name the thread of the message or item you are working on (list_threads shows them all).`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The thread to post in: `item`'s, `thread_id`, else the latest one while
|
|
42
|
+
* only one thread is active (undefined: none yet, a new message of the
|
|
43
|
+
* direct message). Throws ToolInputError for a conflict, or when several
|
|
44
|
+
* threads are active and none is named.
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveThread(state, ctx, given, tool, how = 'thread_id or item') {
|
|
47
|
+
const fromItem = given.item === undefined ? undefined : await itemThreadId(given.item, ctx);
|
|
48
|
+
if (fromItem !== undefined &&
|
|
49
|
+
given.thread_id !== undefined &&
|
|
50
|
+
given.thread_id.toLowerCase() !== fromItem.toLowerCase()) {
|
|
51
|
+
throw new ToolInputError("Give item or thread_id, not both: item's thread is the item's own id, and this thread_id is another thread.");
|
|
52
|
+
}
|
|
53
|
+
const named = fromItem ?? given.thread_id;
|
|
54
|
+
if (named !== undefined)
|
|
55
|
+
return named;
|
|
56
|
+
if (state.activeThreads().length > 1) {
|
|
57
|
+
throw new ToolInputError(threadRequiredMessage(state, tool, how));
|
|
58
|
+
}
|
|
59
|
+
return state.lastThreadId;
|
|
60
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recent tool calls Claude Code announced (the plugin's PreToolUse hook,
|
|
3
|
+
* `tool_seen`), so a tool approval can name the call it is for.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code's PermissionRequest hook gets no `tool_use_id`, but PreToolUse
|
|
6
|
+
* fires for the same call just before it, with the same input and agent id,
|
|
7
|
+
* and with the id. So `tool_seen` records each call (its agent, tool, a hash
|
|
8
|
+
* of its input and its id), and `approve` claims the oldest unclaimed entry
|
|
9
|
+
* that matches. An answer in the terminal then shows as such in the app: the
|
|
10
|
+
* call's PostToolUse names that id (`tool_done`).
|
|
11
|
+
*
|
|
12
|
+
* Only these four values are kept, for 10 seconds, at most 256 entries; the
|
|
13
|
+
* input itself never is.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
/** How many calls are remembered. */
|
|
17
|
+
export const RING_SIZE = 256;
|
|
18
|
+
/** How long a call is remembered. */
|
|
19
|
+
export const RING_TTL_MS = 10_000;
|
|
20
|
+
/** How old a call may be for an approval to claim it. */
|
|
21
|
+
export const CLAIM_MAX_AGE_MS = 5_000;
|
|
22
|
+
/** JSON with every object's keys sorted, at every level, and no spaces. */
|
|
23
|
+
export function canonicalJson(value) {
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return `[${value.map((v) => canonicalJson(v)).join(',')}]`;
|
|
26
|
+
if (value !== null && typeof value === 'object') {
|
|
27
|
+
const o = value;
|
|
28
|
+
return `{${Object.keys(o)
|
|
29
|
+
.sort()
|
|
30
|
+
.filter((k) => o[k] !== undefined)
|
|
31
|
+
.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
|
|
32
|
+
.join(',')}}`;
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(value);
|
|
35
|
+
}
|
|
36
|
+
/** The hex SHA-256 of a tool input's canonical JSON. */
|
|
37
|
+
export function inputHash(input) {
|
|
38
|
+
return createHash('sha256').update(canonicalJson(input), 'utf8').digest('hex');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A tool input as the hooks pass it (`${tool_input}` is JSON text; an object
|
|
42
|
+
* works too): the object, or undefined when it isn't one.
|
|
43
|
+
*/
|
|
44
|
+
export function parseToolInput(value) {
|
|
45
|
+
let raw = value;
|
|
46
|
+
if (typeof raw === 'string') {
|
|
47
|
+
try {
|
|
48
|
+
raw = JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return raw !== null && typeof raw === 'object' && !Array.isArray(raw)
|
|
55
|
+
? raw
|
|
56
|
+
: undefined;
|
|
57
|
+
}
|
|
58
|
+
export class ToolRing {
|
|
59
|
+
now;
|
|
60
|
+
entries = [];
|
|
61
|
+
listeners = new Set();
|
|
62
|
+
constructor(now = Date.now) {
|
|
63
|
+
this.now = now;
|
|
64
|
+
}
|
|
65
|
+
/** How many calls are remembered (tests). */
|
|
66
|
+
get size() {
|
|
67
|
+
this.prune();
|
|
68
|
+
return this.entries.length;
|
|
69
|
+
}
|
|
70
|
+
/** A call Claude Code is about to make (PreToolUse). */
|
|
71
|
+
record(init) {
|
|
72
|
+
this.prune();
|
|
73
|
+
this.entries.push({ ...init, at: this.now(), claimed: false });
|
|
74
|
+
while (this.entries.length > RING_SIZE)
|
|
75
|
+
this.entries.shift();
|
|
76
|
+
for (const listener of [...this.listeners])
|
|
77
|
+
listener();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The oldest unclaimed call that matches, at most 5 seconds old: its id,
|
|
81
|
+
* now claimed. Claude Code asks for permission in the order it announced
|
|
82
|
+
* the calls, so the oldest is the one asking.
|
|
83
|
+
*/
|
|
84
|
+
claim(agentId, toolName, hash) {
|
|
85
|
+
const oldest = this.now() - CLAIM_MAX_AGE_MS;
|
|
86
|
+
for (const e of this.entries) {
|
|
87
|
+
if (e.at < oldest)
|
|
88
|
+
continue;
|
|
89
|
+
if (e.claimed || e.agentId !== agentId || e.toolName !== toolName || e.hash !== hash) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
e.claimed = true;
|
|
93
|
+
return e.toolUseId;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/** claim, waiting up to `waitMs` for the call to be recorded. */
|
|
98
|
+
async claimSoon(agentId, toolName, hash, waitMs) {
|
|
99
|
+
const found = this.claim(agentId, toolName, hash);
|
|
100
|
+
if (found !== undefined || waitMs <= 0)
|
|
101
|
+
return found;
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const listener = () => {
|
|
104
|
+
const id = this.claim(agentId, toolName, hash);
|
|
105
|
+
if (id !== undefined)
|
|
106
|
+
finish(id);
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
finish(this.claim(agentId, toolName, hash));
|
|
110
|
+
}, waitMs);
|
|
111
|
+
const finish = (id) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
this.listeners.delete(listener);
|
|
114
|
+
resolve(id);
|
|
115
|
+
};
|
|
116
|
+
this.listeners.add(listener);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
prune() {
|
|
120
|
+
const oldest = this.now() - RING_TTL_MS;
|
|
121
|
+
while (this.entries.length > 0 && (this.entries[0]?.at ?? 0) < oldest)
|
|
122
|
+
this.entries.shift();
|
|
123
|
+
}
|
|
124
|
+
}
|