@lucascouts/claude-agent-acp-plus 0.23.6 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/acp-agent.d.ts +85 -2
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +286 -31
- package/dist/classifier-escalation.d.ts +158 -0
- package/dist/classifier-escalation.d.ts.map +1 -0
- package/dist/classifier-escalation.js +562 -0
- package/dist/permissions/options/shared.d.ts +8 -0
- package/dist/permissions/options/shared.d.ts.map +1 -1
- package/dist/permissions/options/shared.js +18 -0
- package/dist/permissions/presentation.d.ts +1 -0
- package/dist/permissions/presentation.d.ts.map +1 -1
- package/dist/permissions/presentation.js +1 -1
- package/dist/permissions/response.d.ts +6 -0
- package/dist/permissions/response.d.ts.map +1 -1
- package/dist/permissions/response.js +19 -0
- package/dist/task-feed.d.ts +195 -0
- package/dist/task-feed.d.ts.map +1 -0
- package/dist/task-feed.js +530 -0
- package/package.json +1 -1
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story 016: a call the auto-mode classifier denies asks the operator instead
|
|
3
|
+
* of failing. An approval becomes a grant — memory only, scoped to one
|
|
4
|
+
* session — that the next matching call consumes to skip the classifier.
|
|
5
|
+
*/
|
|
6
|
+
import { buildClassifierEscalationOptions } from "./permissions/options/shared.js";
|
|
7
|
+
import { buildClaudePermissionPresentation, humanText } from "./permissions/presentation.js";
|
|
8
|
+
import { decodeClassifierEscalationResponse, } from "./permissions/response.js";
|
|
9
|
+
const CYCLE = Symbol("cycle");
|
|
10
|
+
function canonicalize(value, ancestors) {
|
|
11
|
+
if (value === null || typeof value !== "object")
|
|
12
|
+
return value;
|
|
13
|
+
if (ancestors.has(value))
|
|
14
|
+
throw CYCLE;
|
|
15
|
+
ancestors.add(value);
|
|
16
|
+
try {
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return value.map((item) => canonicalize(item, ancestors));
|
|
19
|
+
const sorted = {};
|
|
20
|
+
for (const key of Object.keys(value).sort()) {
|
|
21
|
+
sorted[key] = canonicalize(value[key], ancestors);
|
|
22
|
+
}
|
|
23
|
+
return sorted;
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
ancestors.delete(value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One string per (tool name, input): keys sorted recursively, array order kept,
|
|
31
|
+
* and the top-level Bash `description` dropped — a cosmetic label the model
|
|
32
|
+
* rewrites on retry. `undefined` when the input cannot be serialised (cyclic),
|
|
33
|
+
* which every caller treats as "no grant possible".
|
|
34
|
+
*/
|
|
35
|
+
export function fingerprint(toolName, input) {
|
|
36
|
+
let subject = input;
|
|
37
|
+
if (toolName === "Bash" &&
|
|
38
|
+
input !== null &&
|
|
39
|
+
typeof input === "object" &&
|
|
40
|
+
!Array.isArray(input) &&
|
|
41
|
+
"description" in input) {
|
|
42
|
+
const rest = { ...input };
|
|
43
|
+
delete rest.description;
|
|
44
|
+
subject = rest;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
// A JSON array keeps the tool name and the input apart: no text can move
|
|
48
|
+
// from one to the other and render the same string.
|
|
49
|
+
return JSON.stringify([toolName, canonicalize(subject, new Set())]);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error === CYCLE)
|
|
53
|
+
return undefined;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The grants of one session. Memory only: nothing here touches the disk. */
|
|
58
|
+
export class GrantStore {
|
|
59
|
+
once = new Set();
|
|
60
|
+
session = new Set();
|
|
61
|
+
grantOnce(fp) {
|
|
62
|
+
this.once.add(fp);
|
|
63
|
+
}
|
|
64
|
+
grantSession(fp) {
|
|
65
|
+
this.session.add(fp);
|
|
66
|
+
}
|
|
67
|
+
/** Whether a grant matches, without consuming it. */
|
|
68
|
+
has(fp) {
|
|
69
|
+
return this.session.has(fp) || this.once.has(fp);
|
|
70
|
+
}
|
|
71
|
+
/** A session grant wins and stays; a once grant is deleted when consumed. */
|
|
72
|
+
consume(fp) {
|
|
73
|
+
if (this.session.has(fp))
|
|
74
|
+
return "session";
|
|
75
|
+
if (this.once.delete(fp))
|
|
76
|
+
return "once";
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Turn end: an unused "Yes" does not outlive its turn. */
|
|
80
|
+
clearOnce() {
|
|
81
|
+
this.once.clear();
|
|
82
|
+
}
|
|
83
|
+
/** Session teardown. */
|
|
84
|
+
clear() {
|
|
85
|
+
this.once.clear();
|
|
86
|
+
this.session.clear();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** How long each side waits for the other before failing closed. */
|
|
90
|
+
const SIGNAL_WAIT_MS = 5_000;
|
|
91
|
+
const APPROVED_TEXT = "Approved by the operator after a classifier denial; the call will run again.";
|
|
92
|
+
const CANCELLED_TEXT = "Permission request cancelled; the call did not run.";
|
|
93
|
+
/** A timer that never keeps the process alive on its own. */
|
|
94
|
+
function backgroundTimer(fn, ms) {
|
|
95
|
+
const timer = setTimeout(fn, ms);
|
|
96
|
+
timer.unref?.();
|
|
97
|
+
return timer;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Orders the two signals the CLI sends for one classifier denial — the
|
|
101
|
+
* `permission_denied` frame and the `PermissionDenied` hook, in either order,
|
|
102
|
+
* keyed by tool_use_id — and guarantees the denied call exactly one final
|
|
103
|
+
* `tool_call_update`: never zero (a call left pending), never two.
|
|
104
|
+
*/
|
|
105
|
+
export class EscalationRegistry {
|
|
106
|
+
sessionId;
|
|
107
|
+
emit;
|
|
108
|
+
logger;
|
|
109
|
+
frames = new Map();
|
|
110
|
+
waiters = new Map();
|
|
111
|
+
deadlines = new Map();
|
|
112
|
+
claimed = new Set();
|
|
113
|
+
abandoned = new Set();
|
|
114
|
+
settled = new Map();
|
|
115
|
+
constructor(deps) {
|
|
116
|
+
this.sessionId = deps.sessionId;
|
|
117
|
+
this.emit = deps.emit;
|
|
118
|
+
this.logger = deps.logger;
|
|
119
|
+
}
|
|
120
|
+
/** Frame side. Answers any hook waiting on this id; a classifier frame arms the fallback. */
|
|
121
|
+
noteFrame(frame) {
|
|
122
|
+
const id = frame.toolUseId;
|
|
123
|
+
const waiting = this.waiters.get(id);
|
|
124
|
+
if (waiting) {
|
|
125
|
+
this.waiters.delete(id);
|
|
126
|
+
for (const wake of waiting)
|
|
127
|
+
wake(frame.reasonType);
|
|
128
|
+
}
|
|
129
|
+
// A duplicate frame for a settled call has nothing left to decide.
|
|
130
|
+
if (this.settled.has(id))
|
|
131
|
+
return;
|
|
132
|
+
this.frames.set(id, frame);
|
|
133
|
+
if (frame.reasonType !== "classifier") {
|
|
134
|
+
// The frame handler sends today's update for these itself; the frame is
|
|
135
|
+
// kept only long enough for a hook to read its reason type.
|
|
136
|
+
this.clearDeadline(id);
|
|
137
|
+
this.deadlines.set(id, backgroundTimer(() => {
|
|
138
|
+
this.deadlines.delete(id);
|
|
139
|
+
this.frames.delete(id);
|
|
140
|
+
this.abandoned.delete(id);
|
|
141
|
+
}, SIGNAL_WAIT_MS));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (this.claimed.has(id))
|
|
145
|
+
return;
|
|
146
|
+
// The hook already gave up on this id: nobody else will settle it.
|
|
147
|
+
if (this.abandoned.has(id)) {
|
|
148
|
+
this.settle(id, "rejected", true);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.clearDeadline(id);
|
|
152
|
+
this.deadlines.set(id, backgroundTimer(() => {
|
|
153
|
+
this.deadlines.delete(id);
|
|
154
|
+
if (!this.claimed.has(id))
|
|
155
|
+
this.settle(id, "rejected", true);
|
|
156
|
+
}, SIGNAL_WAIT_MS));
|
|
157
|
+
}
|
|
158
|
+
/** Hook side. The frame's reason type, or `undefined` after 5 s. Never rejects. */
|
|
159
|
+
awaitReasonType(toolUseId) {
|
|
160
|
+
const frame = this.frames.get(toolUseId);
|
|
161
|
+
if (frame)
|
|
162
|
+
return Promise.resolve(frame.reasonType);
|
|
163
|
+
// Settled without this hook (the fallback fired): no frame will come again.
|
|
164
|
+
if (this.settled.has(toolUseId))
|
|
165
|
+
return Promise.resolve(undefined);
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
let waiters = this.waiters.get(toolUseId);
|
|
168
|
+
if (!waiters) {
|
|
169
|
+
waiters = new Set();
|
|
170
|
+
this.waiters.set(toolUseId, waiters);
|
|
171
|
+
}
|
|
172
|
+
const wake = (reasonType) => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
resolve(reasonType);
|
|
175
|
+
};
|
|
176
|
+
const timer = backgroundTimer(() => {
|
|
177
|
+
const current = this.waiters.get(toolUseId);
|
|
178
|
+
current?.delete(wake);
|
|
179
|
+
if (current?.size === 0)
|
|
180
|
+
this.waiters.delete(toolUseId);
|
|
181
|
+
resolve(undefined);
|
|
182
|
+
}, SIGNAL_WAIT_MS);
|
|
183
|
+
waiters.add(wake);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The hook takes ownership: only `resolve` settles this id from now on.
|
|
188
|
+
* `false` when the call was already settled — the hook must not ask then.
|
|
189
|
+
*/
|
|
190
|
+
claim(toolUseId) {
|
|
191
|
+
if (this.settled.has(toolUseId))
|
|
192
|
+
return false;
|
|
193
|
+
this.claimed.add(toolUseId);
|
|
194
|
+
this.clearDeadline(toolUseId);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
/** The hook gave up (not the classifier's, or no frame in 5 s). */
|
|
198
|
+
abandon(toolUseId) {
|
|
199
|
+
if (this.settled.has(toolUseId) || this.claimed.has(toolUseId))
|
|
200
|
+
return;
|
|
201
|
+
const frame = this.frames.get(toolUseId);
|
|
202
|
+
if (!frame) {
|
|
203
|
+
// The frame may still come; when it does, it settles at once.
|
|
204
|
+
this.abandoned.add(toolUseId);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (frame.reasonType === "classifier") {
|
|
208
|
+
this.settle(toolUseId, "rejected", true);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
// The frame handler already sent today's update for this one.
|
|
212
|
+
this.clearDeadline(toolUseId);
|
|
213
|
+
this.frames.delete(toolUseId);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Idempotent: the first outcome for an id wins and is emitted once. */
|
|
217
|
+
resolve(toolUseId, outcome) {
|
|
218
|
+
this.settle(toolUseId, outcome);
|
|
219
|
+
}
|
|
220
|
+
/** True once claimed and until resolved. */
|
|
221
|
+
isEscalated(toolUseId) {
|
|
222
|
+
return this.claimed.has(toolUseId);
|
|
223
|
+
}
|
|
224
|
+
/** True after `resolve(..., "approved")`; the `tool_result` path reads it. */
|
|
225
|
+
wasApproved(toolUseId) {
|
|
226
|
+
return this.settled.get(toolUseId) === "approved";
|
|
227
|
+
}
|
|
228
|
+
/** Session teardown: drop every timer and every per-call record. */
|
|
229
|
+
clear() {
|
|
230
|
+
for (const timer of this.deadlines.values())
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
this.deadlines.clear();
|
|
233
|
+
for (const [, waiting] of this.waiters)
|
|
234
|
+
for (const wake of waiting)
|
|
235
|
+
wake(undefined);
|
|
236
|
+
this.waiters.clear();
|
|
237
|
+
this.frames.clear();
|
|
238
|
+
this.claimed.clear();
|
|
239
|
+
this.abandoned.clear();
|
|
240
|
+
this.settled.clear();
|
|
241
|
+
}
|
|
242
|
+
settle(toolUseId, outcome, fallback = false) {
|
|
243
|
+
if (this.settled.has(toolUseId))
|
|
244
|
+
return;
|
|
245
|
+
this.settled.set(toolUseId, outcome);
|
|
246
|
+
this.clearDeadline(toolUseId);
|
|
247
|
+
this.claimed.delete(toolUseId);
|
|
248
|
+
this.abandoned.delete(toolUseId);
|
|
249
|
+
const frame = this.frames.get(toolUseId);
|
|
250
|
+
this.frames.delete(toolUseId);
|
|
251
|
+
// The hook path emits a missing tool call before asking; the fallback does
|
|
252
|
+
// not, so it stays silent about a call the client was never shown.
|
|
253
|
+
if (fallback && frame?.emitted === false)
|
|
254
|
+
return;
|
|
255
|
+
this.send(toolUseId, buildFinalUpdate(toolUseId, outcome, frame));
|
|
256
|
+
}
|
|
257
|
+
send(toolUseId, update) {
|
|
258
|
+
const warn = (error) => this.logger.log(`classifier escalation: final tool_call_update failed level=warn sessionId=${this.sessionId} toolUseId=${toolUseId} error=${String(error)}`);
|
|
259
|
+
try {
|
|
260
|
+
void Promise.resolve(this.emit(toolUseId, update)).catch(warn);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
warn(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
clearDeadline(toolUseId) {
|
|
267
|
+
const timer = this.deadlines.get(toolUseId);
|
|
268
|
+
if (timer === undefined)
|
|
269
|
+
return;
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
this.deadlines.delete(toolUseId);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function buildFinalUpdate(toolUseId, outcome, frame) {
|
|
275
|
+
const text = (value) => [
|
|
276
|
+
{ type: "content", content: { type: "text", text: value } },
|
|
277
|
+
];
|
|
278
|
+
const claudeCode = frame?.meta?.claudeCode;
|
|
279
|
+
// Approved and cancelled keep the call's identity (tool name, parent stamp)
|
|
280
|
+
// but not the denial's `toolResponse`, which would contradict them.
|
|
281
|
+
const identity = claudeCode
|
|
282
|
+
? {
|
|
283
|
+
toolName: claudeCode.toolName,
|
|
284
|
+
...(claudeCode.parentToolUseId ? { parentToolUseId: claudeCode.parentToolUseId } : {}),
|
|
285
|
+
}
|
|
286
|
+
: undefined;
|
|
287
|
+
switch (outcome) {
|
|
288
|
+
case "approved":
|
|
289
|
+
return {
|
|
290
|
+
sessionUpdate: "tool_call_update",
|
|
291
|
+
toolCallId: toolUseId,
|
|
292
|
+
status: "completed",
|
|
293
|
+
content: text(APPROVED_TEXT),
|
|
294
|
+
...(identity ? { _meta: { claudeCode: identity } } : {}),
|
|
295
|
+
};
|
|
296
|
+
case "cancelled":
|
|
297
|
+
// ACP has no cancelled tool-call status: the adapter's representation is
|
|
298
|
+
// `failed` with `nonExecutionKind: "cancelled"`.
|
|
299
|
+
return {
|
|
300
|
+
sessionUpdate: "tool_call_update",
|
|
301
|
+
toolCallId: toolUseId,
|
|
302
|
+
status: "failed",
|
|
303
|
+
content: text(CANCELLED_TEXT),
|
|
304
|
+
...(identity
|
|
305
|
+
? {
|
|
306
|
+
_meta: {
|
|
307
|
+
claudeCode: { ...identity, nonExecutionKind: "cancelled" },
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
: {}),
|
|
311
|
+
};
|
|
312
|
+
case "rejected":
|
|
313
|
+
default:
|
|
314
|
+
// Today's failed update, byte for byte — also the fail-closed default.
|
|
315
|
+
return {
|
|
316
|
+
sessionUpdate: "tool_call_update",
|
|
317
|
+
toolCallId: toolUseId,
|
|
318
|
+
status: "failed",
|
|
319
|
+
content: text(`Permission denied: ${frame?.reason ?? "denied by the auto-mode classifier"}`),
|
|
320
|
+
...(frame?.meta ? { _meta: frame.meta } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function isRecord(value) {
|
|
325
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The only bridge from the hook to the client: one ACP permission request with
|
|
329
|
+
* the three classifier answers, referencing the denied call's own tool call
|
|
330
|
+
* (emitted first if the client has not seen it), and attributed to the
|
|
331
|
+
* spawning Agent/Task call when the denial happened inside a subagent.
|
|
332
|
+
*/
|
|
333
|
+
export function createAskOperator(agent, sessionId) {
|
|
334
|
+
return async (req, signal) => {
|
|
335
|
+
const session = agent.sessions[sessionId];
|
|
336
|
+
if (!session)
|
|
337
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
338
|
+
// Resolve a subagent the way the frame handler and canUseTool do: by
|
|
339
|
+
// `agent_id`, through `liveBackgroundTasks`, never by the Agent/Task call.
|
|
340
|
+
const parentToolUseId = req.agentId
|
|
341
|
+
? session.liveBackgroundTasks.get(req.agentId)?.parentToolUseId
|
|
342
|
+
: undefined;
|
|
343
|
+
if (req.agentId && !parentToolUseId) {
|
|
344
|
+
agent.logger.log(`classifier escalation: no parent tool_use recorded for subagent level=warn sessionId=${sessionId} toolUseId=${req.toolUseId} agentId=${req.agentId}`);
|
|
345
|
+
}
|
|
346
|
+
const input = isRecord(req.input) ? req.input : { input: req.input };
|
|
347
|
+
const presentation = buildClaudePermissionPresentation({
|
|
348
|
+
toolName: req.toolName,
|
|
349
|
+
input,
|
|
350
|
+
toolUseID: req.toolUseId,
|
|
351
|
+
cwd: session.cwd,
|
|
352
|
+
supportsTerminalOutput: agent.clientCapabilities?._meta?.["terminal_output"] === true,
|
|
353
|
+
});
|
|
354
|
+
// The reason goes into the request content itself, beside the command:
|
|
355
|
+
// the operator decides on both, not on the command alone.
|
|
356
|
+
presentation.toolCall.content = [
|
|
357
|
+
...(presentation.toolCall.content ?? []),
|
|
358
|
+
{
|
|
359
|
+
type: "content",
|
|
360
|
+
content: {
|
|
361
|
+
type: "text",
|
|
362
|
+
text: `Denied by the auto-mode classifier. Reason: ${
|
|
363
|
+
// Model-steerable text: the same cleaning and cap the card applies
|
|
364
|
+
// to every other reason it shows.
|
|
365
|
+
humanText(req.reason, 4_000) ?? "none given"}`,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
if (parentToolUseId) {
|
|
370
|
+
presentation.toolCall._meta = {
|
|
371
|
+
claudeCode: { toolName: req.toolName, parentToolUseId },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const response = await agent.requestPermissionFromClient({ ...presentation, options: buildClassifierEscalationOptions(), sessionId }, req.toolName, signal, parentToolUseId);
|
|
375
|
+
return decodeClassifierEscalationResponse(response);
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/** The hook's own bound, below the matcher's 600 s so the adapter decides. */
|
|
379
|
+
const ANSWER_WAIT_MS = 590_000;
|
|
380
|
+
const RETRY = {
|
|
381
|
+
hookSpecificOutput: { hookEventName: "PermissionDenied", retry: true },
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* The `PermissionDenied` hook. A classifier denial becomes one question; "Yes"
|
|
385
|
+
* or "Yes for this session" becomes a grant plus `retry: true`, which only
|
|
386
|
+
* tells the model it may retry — the grant hook is what lets the retry run.
|
|
387
|
+
* Every other path (No, cancel, abort, a failing client, the 590 s bound, an
|
|
388
|
+
* input that cannot be fingerprinted) grants nothing: fail closed.
|
|
389
|
+
*/
|
|
390
|
+
export function createPermissionDeniedHook(deps) {
|
|
391
|
+
const { session, logger, escalations, askOperator } = deps;
|
|
392
|
+
const sessionId = session.sessionId;
|
|
393
|
+
return async (input, toolUseID, { signal }) => {
|
|
394
|
+
if (input.hook_event_name !== "PermissionDenied")
|
|
395
|
+
return {};
|
|
396
|
+
const toolUseId = input.tool_use_id || toolUseID;
|
|
397
|
+
if (!toolUseId)
|
|
398
|
+
return {};
|
|
399
|
+
const toolName = input.tool_name;
|
|
400
|
+
// Never the command or the input: ids, tool name and outcome only (R6.3).
|
|
401
|
+
const line = (level, event, fields) => logger.log(`classifier escalation: ${event} level=${level} sessionId=${sessionId} toolUseId=${toolUseId} toolName=${toolName} ${fields}`);
|
|
402
|
+
let claimed = false;
|
|
403
|
+
try {
|
|
404
|
+
const reasonType = await escalations.awaitReasonType(toolUseId);
|
|
405
|
+
if (reasonType !== "classifier") {
|
|
406
|
+
escalations.abandon(toolUseId);
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
// Only an explicit `false` means the call was settled without this hook.
|
|
410
|
+
if (escalations.claim(toolUseId) === false)
|
|
411
|
+
return {};
|
|
412
|
+
claimed = true;
|
|
413
|
+
const fp = fingerprint(toolName, input.tool_input);
|
|
414
|
+
if (fp === undefined) {
|
|
415
|
+
escalations.resolve(toolUseId, "rejected");
|
|
416
|
+
line("warn", "input cannot be fingerprinted", "answer=none outcome=rejected");
|
|
417
|
+
return {};
|
|
418
|
+
}
|
|
419
|
+
const settlement = await askWithBounds(askOperator, {
|
|
420
|
+
toolUseId,
|
|
421
|
+
toolName,
|
|
422
|
+
input: input.tool_input,
|
|
423
|
+
reason: input.reason,
|
|
424
|
+
...(input.agent_id ? { agentId: input.agent_id } : {}),
|
|
425
|
+
}, signal);
|
|
426
|
+
switch (settlement.kind) {
|
|
427
|
+
case "answer":
|
|
428
|
+
switch (settlement.answer) {
|
|
429
|
+
case "once":
|
|
430
|
+
case "session":
|
|
431
|
+
if (settlement.answer === "once")
|
|
432
|
+
session.classifierGrants.grantOnce(fp);
|
|
433
|
+
else
|
|
434
|
+
session.classifierGrants.grantSession(fp);
|
|
435
|
+
escalations.resolve(toolUseId, "approved");
|
|
436
|
+
line("info", "operator answered", `answer=${settlement.answer} outcome=approved`);
|
|
437
|
+
return RETRY;
|
|
438
|
+
case "reject":
|
|
439
|
+
escalations.resolve(toolUseId, "rejected");
|
|
440
|
+
line("warn", "operator answered", "answer=reject outcome=rejected");
|
|
441
|
+
return {};
|
|
442
|
+
case "cancelled":
|
|
443
|
+
default:
|
|
444
|
+
escalations.resolve(toolUseId, "cancelled");
|
|
445
|
+
line("info", "operator answered", "answer=cancelled outcome=cancelled");
|
|
446
|
+
return {};
|
|
447
|
+
}
|
|
448
|
+
case "aborted":
|
|
449
|
+
escalations.resolve(toolUseId, "cancelled");
|
|
450
|
+
line("info", "turn aborted while asking", "answer=aborted outcome=cancelled");
|
|
451
|
+
return {};
|
|
452
|
+
case "timeout":
|
|
453
|
+
escalations.resolve(toolUseId, "rejected");
|
|
454
|
+
line("warn", "no answer in time", `answer=timeout outcome=rejected waitedMs=${ANSWER_WAIT_MS}`);
|
|
455
|
+
return {};
|
|
456
|
+
case "error":
|
|
457
|
+
default:
|
|
458
|
+
escalations.resolve(toolUseId, "rejected");
|
|
459
|
+
line("warn", "permission request failed", `answer=error outcome=rejected error=${errorKind(settlement.error)}`);
|
|
460
|
+
return {};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
// An unexpected fault in the hook itself: never leave the call pending,
|
|
465
|
+
// never grant.
|
|
466
|
+
if (claimed)
|
|
467
|
+
escalations.resolve(toolUseId, "rejected");
|
|
468
|
+
else
|
|
469
|
+
escalations.abandon(toolUseId);
|
|
470
|
+
logger.error(`classifier escalation: hook failed level=error sessionId=${sessionId} toolUseId=${toolUseId} toolName=${toolName} error=${errorKind(error)}`);
|
|
471
|
+
return {};
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* The error's class and code, never its message: a client may echo the
|
|
477
|
+
* request's params — the command — back in it (R6.3).
|
|
478
|
+
*/
|
|
479
|
+
function errorKind(error) {
|
|
480
|
+
if (!(error instanceof Error))
|
|
481
|
+
return typeof error;
|
|
482
|
+
const code = error.code;
|
|
483
|
+
return typeof code === "number" || typeof code === "string"
|
|
484
|
+
? `${error.name}:${String(code).replace(/[^\w.-]/gu, "")}`
|
|
485
|
+
: error.name;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Race the question against the hook's abort signal and the 590 s bound. The
|
|
489
|
+
* question gets its own signal, aborted whenever it loses the race, so the
|
|
490
|
+
* client's card is withdrawn rather than left open. The timer and the abort
|
|
491
|
+
* listener are released as soon as the race settles.
|
|
492
|
+
*/
|
|
493
|
+
async function askWithBounds(askOperator, req, signal) {
|
|
494
|
+
if (signal.aborted)
|
|
495
|
+
return { kind: "aborted" };
|
|
496
|
+
const question = new AbortController();
|
|
497
|
+
let timer;
|
|
498
|
+
let onAbort;
|
|
499
|
+
try {
|
|
500
|
+
const settlement = await new Promise((resolve) => {
|
|
501
|
+
timer = backgroundTimer(() => resolve({ kind: "timeout" }), ANSWER_WAIT_MS);
|
|
502
|
+
onAbort = () => resolve({ kind: "aborted" });
|
|
503
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
504
|
+
// An async wrapper so a synchronous throw is a rejection like any other.
|
|
505
|
+
void (async () => askOperator(req, question.signal))().then((answer) => resolve({ kind: "answer", answer }), (error) => resolve({ kind: "error", error }));
|
|
506
|
+
});
|
|
507
|
+
if (settlement.kind !== "answer")
|
|
508
|
+
question.abort();
|
|
509
|
+
return settlement;
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
if (onAbort)
|
|
514
|
+
signal.removeEventListener("abort", onAbort);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const GRANT_ALLOW_REASON = "approved by the operator after a classifier denial";
|
|
518
|
+
/** The hook input's mode as a log token: never free text from the input. */
|
|
519
|
+
function modeToken(mode) {
|
|
520
|
+
if (typeof mode !== "string")
|
|
521
|
+
return "none";
|
|
522
|
+
const token = mode.replace(/[^\w-]/gu, "").slice(0, 32);
|
|
523
|
+
return token || "none";
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The `PreToolUse` side: a call matching a grant is allowed before the
|
|
527
|
+
* classifier sees it — measured in Task 1 to skip the classifier. Anything else
|
|
528
|
+
* returns `{}` and the CLI decides as usual.
|
|
529
|
+
*
|
|
530
|
+
* A grant answers a classifier denial, so it applies only while the CLI reports
|
|
531
|
+
* `auto` for this very call (story 029). In any other mode — or with no mode —
|
|
532
|
+
* it is held, not consumed: that mode's own checks decide, and the grant is
|
|
533
|
+
* still there when the session returns to `auto`. The mode is read from the
|
|
534
|
+
* hook input, never from the adapter's session state, which can lag a switch.
|
|
535
|
+
*/
|
|
536
|
+
export function createGrantPreToolUseHook(session, logger) {
|
|
537
|
+
return async (input, toolUseID) => {
|
|
538
|
+
if (input.hook_event_name !== "PreToolUse")
|
|
539
|
+
return {};
|
|
540
|
+
const fp = fingerprint(input.tool_name, input.tool_input);
|
|
541
|
+
if (fp === undefined)
|
|
542
|
+
return {};
|
|
543
|
+
const mode = input.permission_mode;
|
|
544
|
+
if (mode !== "auto") {
|
|
545
|
+
if (session.classifierGrants.has(fp)) {
|
|
546
|
+
logger.log(`classifier escalation: grant held level=info sessionId=${session.sessionId} toolUseId=${toolUseID ?? input.tool_use_id} toolName=${input.tool_name} mode=${modeToken(mode)}`);
|
|
547
|
+
}
|
|
548
|
+
return {};
|
|
549
|
+
}
|
|
550
|
+
const kind = session.classifierGrants.consume(fp);
|
|
551
|
+
if (!kind)
|
|
552
|
+
return {};
|
|
553
|
+
logger.log(`classifier escalation: grant consumed level=info sessionId=${session.sessionId} toolUseId=${toolUseID ?? input.tool_use_id} toolName=${input.tool_name} grant=${kind}`);
|
|
554
|
+
return {
|
|
555
|
+
hookSpecificOutput: {
|
|
556
|
+
hookEventName: "PreToolUse",
|
|
557
|
+
permissionDecision: "allow",
|
|
558
|
+
permissionDecisionReason: GRANT_ALLOW_REASON,
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
};
|
|
562
|
+
}
|
|
@@ -12,6 +12,8 @@ export declare const PERMISSION_OPTION_ID: {
|
|
|
12
12
|
readonly exitPlanClearAuto: "exit-plan-clear-auto";
|
|
13
13
|
readonly exitPlanClearBypass: "exit-plan-clear-bypass";
|
|
14
14
|
readonly exitPlanClearAcceptEdits: "exit-plan-clear-accept-edits";
|
|
15
|
+
readonly classifierAllowOnce: "classifier-allow-once";
|
|
16
|
+
readonly classifierAllowSession: "classifier-allow-session";
|
|
15
17
|
readonly reject: "reject";
|
|
16
18
|
};
|
|
17
19
|
export interface PermissionOptionContext {
|
|
@@ -32,6 +34,12 @@ export interface PermissionOptionContext {
|
|
|
32
34
|
export declare function allowOnce(name?: string): PermissionOption;
|
|
33
35
|
export declare function allowWithUpdates(name: string): PermissionOption;
|
|
34
36
|
export declare function reject(name?: string): PermissionOption;
|
|
37
|
+
/**
|
|
38
|
+
* The three answers to an auto-mode classifier denial. Both "Yes" options are
|
|
39
|
+
* `allow_once`: `allow_always` reads as a persistent choice, and neither answer
|
|
40
|
+
* ever writes a rule.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildClassifierEscalationOptions(): PermissionOption[];
|
|
35
43
|
export declare function withOptionalUpdate(changeSet: DurablePermissionChangeSet | undefined, updateName: string | undefined, allowName?: string, rejectName?: string): PermissionOption[];
|
|
36
44
|
export declare function plainString(value: unknown): string | undefined;
|
|
37
45
|
export declare function exactLocalAllowRule(changeSet: DurablePermissionChangeSet | undefined, toolName: string, ruleContent?: string): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../../src/permissions/options/shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtE,eAAO,MAAM,oBAAoB
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../../src/permissions/options/shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtE,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;CAevB,CAAC;AAEX,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,gBAAgB,CAAC,EAAE,0BAA0B,CAAC;IAC9C,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC;kDAC8C;IAC9C,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,wBAAgB,SAAS,CAAC,IAAI,SAAQ,GAAG,gBAAgB,CAExD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAE/D;AAED,wBAAgB,MAAM,CAAC,IAAI,SAAO,GAAG,gBAAgB,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,gCAAgC,IAAI,gBAAgB,EAAE,CAUrE;AAED,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,0BAA0B,GAAG,SAAS,EACjD,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,SAAS,SAAQ,EACjB,UAAU,SAAO,GAChB,gBAAgB,EAAE,CAKpB;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE9D;AAED,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,0BAA0B,GAAG,SAAS,EACjD,QAAQ,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAkBT;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,0BAA0B,GAAG,SAAS,EACjD,QAAQ,EAAE,MAAM,GACf,OAAO,CAYT;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,SAAO,GAAG,gBAAgB,EAAE,CAEvF"}
|
|
@@ -10,6 +10,8 @@ export const PERMISSION_OPTION_ID = {
|
|
|
10
10
|
exitPlanClearAuto: "exit-plan-clear-auto",
|
|
11
11
|
exitPlanClearBypass: "exit-plan-clear-bypass",
|
|
12
12
|
exitPlanClearAcceptEdits: "exit-plan-clear-accept-edits",
|
|
13
|
+
classifierAllowOnce: "classifier-allow-once",
|
|
14
|
+
classifierAllowSession: "classifier-allow-session",
|
|
13
15
|
reject: "reject",
|
|
14
16
|
};
|
|
15
17
|
export function allowOnce(name = "Yes") {
|
|
@@ -21,6 +23,22 @@ export function allowWithUpdates(name) {
|
|
|
21
23
|
export function reject(name = "No") {
|
|
22
24
|
return { optionId: PERMISSION_OPTION_ID.reject, name, kind: "reject_once" };
|
|
23
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* The three answers to an auto-mode classifier denial. Both "Yes" options are
|
|
28
|
+
* `allow_once`: `allow_always` reads as a persistent choice, and neither answer
|
|
29
|
+
* ever writes a rule.
|
|
30
|
+
*/
|
|
31
|
+
export function buildClassifierEscalationOptions() {
|
|
32
|
+
return [
|
|
33
|
+
{ optionId: PERMISSION_OPTION_ID.classifierAllowOnce, name: "Yes", kind: "allow_once" },
|
|
34
|
+
{
|
|
35
|
+
optionId: PERMISSION_OPTION_ID.classifierAllowSession,
|
|
36
|
+
name: "Yes for this session",
|
|
37
|
+
kind: "allow_once",
|
|
38
|
+
},
|
|
39
|
+
reject(),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
24
42
|
export function withOptionalUpdate(changeSet, updateName, allowName = "Yes", rejectName = "No") {
|
|
25
43
|
const options = [allowOnce(allowName)];
|
|
26
44
|
if (changeSet && updateName)
|
|
@@ -12,5 +12,6 @@ export interface ClaudePermissionPresentationInput {
|
|
|
12
12
|
decisionReason?: string;
|
|
13
13
|
defaultToNo?: boolean;
|
|
14
14
|
}
|
|
15
|
+
export declare function humanText(value: unknown, maxLength: number, singleLine?: boolean): string | undefined;
|
|
15
16
|
export declare function buildClaudePermissionPresentation(value: ClaudePermissionPresentationInput): Pick<RequestPermissionRequest, "toolCall" | "_meta">;
|
|
16
17
|
//# sourceMappingURL=presentation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"presentation.d.ts","sourceRoot":"","sources":["../../src/permissions/presentation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAoB,MAAM,0BAA0B,CAAC;AAG3F,MAAM,WAAW,iCAAiC;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;
|
|
1
|
+
{"version":3,"file":"presentation.d.ts","sourceRoot":"","sources":["../../src/permissions/presentation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAoB,MAAM,0BAA0B,CAAC;AAG3F,MAAM,WAAW,iCAAiC;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,wBAAgB,SAAS,CACvB,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,MAAM,EACjB,UAAU,UAAQ,GACjB,MAAM,GAAG,SAAS,CAkBpB;AAiBD,wBAAgB,iCAAiC,CAC/C,KAAK,EAAE,iCAAiC,GACvC,IAAI,CAAC,wBAAwB,EAAE,UAAU,GAAG,OAAO,CAAC,CAgEtD"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { toolInfoFromToolUse } from "../tools.js";
|
|
2
|
-
function humanText(value, maxLength, singleLine = false) {
|
|
2
|
+
export function humanText(value, maxLength, singleLine = false) {
|
|
3
3
|
if (typeof value !== "string")
|
|
4
4
|
return undefined;
|
|
5
5
|
const withoutControls = Array.from(value)
|
|
@@ -8,4 +8,10 @@ export interface ClaudePermissionDecision {
|
|
|
8
8
|
}
|
|
9
9
|
/** Decode, validate, and interpret an ACP response exactly once. */
|
|
10
10
|
export declare function decodeClaudePermissionResponse(response: RequestPermissionResponse, toolName: string, input: Record<string, unknown>, toolUseID: string, offeredOptions: readonly PermissionOption[], durableChangeSet?: DurablePermissionChangeSet): ClaudePermissionDecision;
|
|
11
|
+
export type ClassifierEscalationAnswer = "once" | "session" | "reject" | "cancelled";
|
|
12
|
+
/**
|
|
13
|
+
* Map the answer to a classifier-denial request. Only the three ids that
|
|
14
|
+
* request offers are accepted; anything else is an error, never an approval.
|
|
15
|
+
*/
|
|
16
|
+
export declare function decodeClassifierEscalationResponse(response: RequestPermissionResponse): ClassifierEscalationAnswer;
|
|
11
17
|
//# sourceMappingURL=response.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/permissions/response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAC1E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACvF,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/permissions/response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAC1E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACvF,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,oBAAoB,CAAC;AAIrE,MAAM,WAAW,wBAAwB;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,gBAAgB,CAAC,EAAE,cAAc,CAAC;CACnC;AAED,oEAAoE;AACpE,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,yBAAyB,EACnC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,SAAS,gBAAgB,EAAE,EAC3C,gBAAgB,CAAC,EAAE,0BAA0B,GAC5C,wBAAwB,CAgB1B;AAED,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,CAAC;AAErF;;;GAGG;AACH,wBAAgB,kCAAkC,CAChD,QAAQ,EAAE,yBAAyB,GAClC,0BAA0B,CAY5B"}
|