@sema-agent/core 7.3.1 → 7.4.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 +35 -0
- package/dist/agents/peer-admission.d.ts +18 -3
- package/dist/agents/peer-admission.js +79 -4
- package/dist/agents/peer-held-queue.d.ts +101 -0
- package/dist/agents/peer-held-queue.js +229 -0
- package/dist/agents/peer-idle.d.ts +109 -0
- package/dist/agents/peer-idle.js +240 -0
- package/dist/agents/peer-notice-route.d.ts +33 -0
- package/dist/agents/peer-notice-route.js +46 -0
- package/dist/agents/peer-notices.d.ts +103 -0
- package/dist/agents/peer-notices.js +206 -0
- package/dist/agents/peer-session-drain.d.ts +39 -4
- package/dist/agents/peer-session-drain.js +248 -42
- package/dist/agents/send-message-tool.d.ts +8 -1
- package/dist/agents/send-message-tool.js +96 -30
- package/dist/agents/subagent.js +1 -0
- package/dist/core/auto-mode-defaults.d.ts +11 -0
- package/dist/core/auto-mode-defaults.js +2 -0
- package/dist/core/auto-mode.d.ts +59 -0
- package/dist/core/auto-mode.js +57 -1
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +8 -0
- package/dist/core/hooks.d.ts +30 -0
- package/dist/core/hooks.js +43 -8
- package/dist/core/mailbox-store.d.ts +33 -1
- package/dist/core/mailbox-store.js +42 -2
- package/dist/core/runner/assemble-result.d.ts +5 -0
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/denial-limit-arms.d.ts +149 -0
- package/dist/core/runner/denial-limit-arms.js +91 -0
- package/dist/core/runner/edited-files-ledger.d.ts +33 -0
- package/dist/core/runner/edited-files-ledger.js +14 -0
- package/dist/core/runner/prepare-hands-readface.d.ts +5 -0
- package/dist/core/runner/prepare-hands-readface.js +1 -0
- package/dist/core/runner/prepare-task.d.ts +62 -1
- package/dist/core/runner/prepare-task.js +120 -89
- package/dist/core/runner/runtask.js +10 -0
- package/dist/core/sensitive-path-policy.d.ts +27 -6
- package/dist/core/sensitive-path-policy.js +57 -2
- package/dist/core/task-notification.d.ts +24 -2
- package/dist/core/task-notification.js +6 -1
- package/dist/core/tool-policy.d.ts +55 -4
- package/dist/core/tool-policy.js +28 -5
- package/dist/core/types.d.ts +207 -10
- package/dist/index.d.ts +10 -5
- package/dist/index.js +8 -3
- package/dist/orchestration/workflow.js +7 -3
- package/dist/tools/fs/fs-write.d.ts +4 -4
- package/dist/tools/fs/fs-write.js +30 -11
- package/dist/tools/fs/index.d.ts +7 -1
- package/dist/tools/fs/index.js +1 -1
- package/dist/tools/fs/safety.d.ts +29 -8
- package/dist/tools/fs/safety.js +11 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +169 -1
|
@@ -6,6 +6,8 @@ import { uuidv7 } from "../internal/harness.js";
|
|
|
6
6
|
import { MAILBOX_TOMBSTONED_RECIPIENT_CODE, mailboxCrossProcessMountVerdict } from "../core/mailbox-store.js";
|
|
7
7
|
import { peerSendVerdictSeverity } from "./cross-session-judge.js";
|
|
8
8
|
import { parsePeerSessionAddress, peerSessionBoxHandle, resolvePeerSessions } from "./peer-directory.js";
|
|
9
|
+
import { peerIdleMachineFor } from "./peer-idle.js";
|
|
10
|
+
import { PEER_IDLE_OUTSTANDING_CAP, PEER_IDLE_SUBSCRIPTION_TTL_MS, peerIdleNoticeLabel } from "./peer-notices.js";
|
|
9
11
|
import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
|
|
10
12
|
import { inlineUntrusted } from "../core/untrusted-text.js";
|
|
11
13
|
import { neutralizePeerBody } from "./cross-session-envelope.js";
|
|
@@ -50,6 +52,11 @@ function targetLaneKey(scope, targetId) {
|
|
|
50
52
|
const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
|
|
51
53
|
export function createSendMessageTool(opts) {
|
|
52
54
|
const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
|
|
55
|
+
const notifyWhenIdleParameter = {
|
|
56
|
+
notify_when_idle: Type.Optional(Type.Boolean({
|
|
57
|
+
description: "Ask a peer SESSION of this engine to send you ONE notice when it next goes idle (finishes its turn with nothing queued) or exits — opt-in, one-shot, no polling. With a message: deliver it now AND subscribe. Without a message (omit it): a pure subscription that costs the other session nothing. Peer sessions only (not agents or teammates), and only from the main conversation.",
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
53
60
|
const peerLane = (() => {
|
|
54
61
|
if (opts.peerDirectory === undefined)
|
|
55
62
|
return { active: false };
|
|
@@ -58,6 +65,14 @@ export function createSendMessageTool(opts) {
|
|
|
58
65
|
const verdict = mailboxCrossProcessMountVerdict(opts.mailbox);
|
|
59
66
|
return verdict.ok ? { active: true } : { active: false, reason: verdict.reason };
|
|
60
67
|
})();
|
|
68
|
+
const baseParameters = Type.Object({
|
|
69
|
+
to: Type.String({ description: 'Recipient: the agent\'s name, or its task_id (a…) returned by the Agent tool with run_in_background. "main" is reserved for the spawning conversation.' }),
|
|
70
|
+
message: Type.String({ description: "The follow-up request. The agent continues from its full prior context." }),
|
|
71
|
+
summary: Type.Optional(Type.String({ maxLength: 200, description: "A 5-10 word summary shown as a preview in the UI (required when message is a string)" })),
|
|
72
|
+
});
|
|
73
|
+
const sendMessageParameters = peerLane.active
|
|
74
|
+
? Type.Object({ ...baseParameters.properties, message: Type.Optional(Type.String({ description: `${baseParameters.properties.message.description ?? ""} Optional only for a pure notify_when_idle subscription.`.trim() })), ...notifyWhenIdleParameter })
|
|
75
|
+
: baseParameters;
|
|
61
76
|
const retrievalMounted = opts.retrievalToolMounted;
|
|
62
77
|
const retrievalHedge = retrievalMounted === undefined ? " (where that tool is mounted)" : "";
|
|
63
78
|
const RETRIEVE_NOW = retrievalMounted === false
|
|
@@ -144,19 +159,16 @@ export function createSendMessageTool(opts) {
|
|
|
144
159
|
(peerLane.active
|
|
145
160
|
? ` Peer SESSIONS — other sessions of this engine for the same user${opts.peerListingMounted === false ? "" : opts.peerListingMounted === true ? " (list them with ListAgents)" : " (list them with ListAgents where that tool is mounted)"} — are addressed by name, by \`name [ref]\` when a name is ambiguous, or by their \`session.<id>\` address. A message to a peer is parked durably in its session box and read at its next turn boundary (an offline peer reads it when it next runs); the receipt confirms the box accepted it, not that the peer acted on it.`
|
|
146
161
|
: ""),
|
|
147
|
-
parameters:
|
|
148
|
-
to: Type.String({ description: 'Recipient: the agent\'s name, or its task_id (a…) returned by the Agent tool with run_in_background. "main" is reserved for the spawning conversation.' }),
|
|
149
|
-
message: Type.String({ description: "The follow-up request. The agent continues from its full prior context." }),
|
|
150
|
-
summary: Type.Optional(Type.String({ maxLength: 200, description: "A 5-10 word summary shown as a preview in the UI (required when message is a string)" })),
|
|
151
|
-
}),
|
|
162
|
+
parameters: sendMessageParameters,
|
|
152
163
|
execute: async (args, ctx) => {
|
|
153
164
|
const a = args;
|
|
154
165
|
const operatorUserInitiated = ctx[OPERATOR_CONTINUATION_CTX] === true;
|
|
155
166
|
const to = String(a.to ?? "").trim();
|
|
156
167
|
const message = String(a.message ?? "").trim();
|
|
168
|
+
const notifyWhenIdle = a.notify_when_idle === true;
|
|
157
169
|
if (!to)
|
|
158
170
|
return { content: "Message not sent: 'to' was empty. Pass the agent's task_id (a…).", details: { error: "empty to" }, isError: true };
|
|
159
|
-
if (!message)
|
|
171
|
+
if (!message && !notifyWhenIdle)
|
|
160
172
|
return { content: "Message not sent: 'message' was empty.", details: { error: "empty message" }, isError: true };
|
|
161
173
|
if (to === "*") {
|
|
162
174
|
return {
|
|
@@ -173,7 +185,7 @@ export function createSendMessageTool(opts) {
|
|
|
173
185
|
};
|
|
174
186
|
}
|
|
175
187
|
const summaryArg = typeof a.summary === "string" ? a.summary.trim() : "";
|
|
176
|
-
if (summaryArg === "") {
|
|
188
|
+
if (summaryArg === "" && message !== "") {
|
|
177
189
|
return {
|
|
178
190
|
content: "Message not sent: summary is required when message is a string — pass a 5-10 word summary of the follow-up.",
|
|
179
191
|
details: { error: "summary_required", to },
|
|
@@ -192,7 +204,7 @@ export function createSendMessageTool(opts) {
|
|
|
192
204
|
if (isObserverTaskId(to)) {
|
|
193
205
|
return { content: OBSERVER_SENDMESSAGE_TARGET_REFUSAL, details: { error: "observer_target", to }, isError: true };
|
|
194
206
|
}
|
|
195
|
-
const admissionConfig = resolvePeerAdmissionConfig(opts.admission);
|
|
207
|
+
const admissionConfig = resolvePeerAdmissionConfig(opts.admission, opts.onNotice);
|
|
196
208
|
const guardScope = ctx.principal ?? opts.scope ?? opts.peerSelfSession?.scope;
|
|
197
209
|
const selfRef = ctx.peerSelfRef ?? opts.peerSelf;
|
|
198
210
|
const directMountSessionId = ctx.sessionId ?? opts.sessionId ?? opts.peerSelfSession?.sessionId;
|
|
@@ -244,11 +256,8 @@ export function createSendMessageTool(opts) {
|
|
|
244
256
|
if (record.inboundPosture === "unavailable") {
|
|
245
257
|
return peerRefusal("peer_send.recipient_refuses", `${peerLabel} advertises that it does not accept cross-session messages (its crossSessionInbound is "refuse"). Nothing was parked.`);
|
|
246
258
|
}
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
const sendVerdict = judgePeerAdmission(peerScope, recipientKey, { senderKey, body: storedContent, prospectiveChain, ownTokens: [recipientKey] }, admissionConfig, undefined, "send");
|
|
250
|
-
if (!sendVerdict.ok)
|
|
251
|
-
return admissionRefusal(sendVerdict.reason, peerLabel);
|
|
259
|
+
const modeClass = opts.peerSelfSession?.modeClass?.();
|
|
260
|
+
const now = Date.now();
|
|
252
261
|
let boxFull = false;
|
|
253
262
|
try {
|
|
254
263
|
boxFull = (await opts.mailbox.peekCount(peerScope, handle)) >= admissionConfig.maxQueuedPeerMessages;
|
|
@@ -258,23 +267,56 @@ export function createSendMessageTool(opts) {
|
|
|
258
267
|
if (boxFull) {
|
|
259
268
|
return { content: `Message not sent: ${inlineUntrusted(peerLabel, 200)}'s session box is at its queued-message limit (${admissionConfig.maxQueuedPeerMessages.toFixed(0)}) — the message was NOT queued; it drains the box at its next turn. Resend later. ${DEDUP_RETRY_NOTE.slice(0, 200)}`, details: { error: "queue_full", code: "queue_full", to }, isError: true };
|
|
260
269
|
}
|
|
261
|
-
const modeClass = opts.peerSelfSession?.modeClass?.();
|
|
262
|
-
const peerMeta = {
|
|
263
|
-
...(peerSelfSessionId !== undefined ? { fromSession: peerSelfSessionId } : {}),
|
|
264
|
-
...(modeClass === "bypass" || modeClass === "prompting" ? { fromMode: modeClass } : {}),
|
|
265
|
-
...(senderKey !== undefined ? { senderKey } : {}),
|
|
266
|
-
kind: "peer_message",
|
|
267
|
-
};
|
|
268
|
-
const now = Date.now();
|
|
269
270
|
let seq;
|
|
270
|
-
|
|
271
|
-
|
|
271
|
+
if (message !== "") {
|
|
272
|
+
const recipientKey = peerAxisToken(peerScope, "s", record.sessionId);
|
|
273
|
+
const storedContent = `[${summary}] ${clipCarrierMessage(message)}`;
|
|
274
|
+
const sendVerdict = judgePeerAdmission(peerScope, recipientKey, { senderKey, body: storedContent, prospectiveChain, ownTokens: [recipientKey] }, admissionConfig, undefined, "send");
|
|
275
|
+
if (!sendVerdict.ok)
|
|
276
|
+
return admissionRefusal(sendVerdict.reason, peerLabel);
|
|
277
|
+
const peerMeta = {
|
|
278
|
+
...(peerSelfSessionId !== undefined ? { fromSession: peerSelfSessionId } : {}),
|
|
279
|
+
...(modeClass === "bypass" || modeClass === "prompting" ? { fromMode: modeClass } : {}),
|
|
280
|
+
...(senderKey !== undefined ? { senderKey } : {}),
|
|
281
|
+
kind: "peer_message",
|
|
282
|
+
};
|
|
283
|
+
try {
|
|
284
|
+
seq = await opts.mailbox.append(peerScope, handle, { from: ownRowName ?? opts.peerSelfSession?.name ?? senderLabel, content: storedContent, sentAt: now, hopChain: prospectiveChain, peerMeta });
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
if (e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE) {
|
|
288
|
+
return peerRefusal("peer_send.invalid_target", `${peerLabel} is being deleted — its box no longer accepts messages. Nothing was parked; do not retry.`);
|
|
289
|
+
}
|
|
290
|
+
return peerRefusal("peer_send.other", `the session box refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked. ${DEDUP_RETRY_NOTE}`);
|
|
291
|
+
}
|
|
272
292
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
293
|
+
let subscription;
|
|
294
|
+
let subscriptionSeq;
|
|
295
|
+
if (notifyWhenIdle) {
|
|
296
|
+
if (peerSelfSessionId === undefined) {
|
|
297
|
+
subscription = { ok: false, code: "peer_send.other", severity: "error", message: "this mount has no session identity, so no idle notice could be addressed back to it — no subscription was made." };
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
const ask = peerIdleMachineFor(peerScope, peerSelfSessionId).requester.request(record.sessionId, peerIdleNoticeLabel(record.name) ?? handle);
|
|
301
|
+
if (!ask.ok) {
|
|
302
|
+
subscription = { ok: false, code: "peer_send.subscription_cap", severity: peerSendVerdictSeverity("peer_send.subscription_cap"), message: `you already hold ${PEER_IDLE_OUTSTANDING_CAP} outstanding idle subscriptions — no new one was made; wait for a notice or for one to expire.` };
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
const subMeta = {
|
|
306
|
+
fromSession: peerSelfSessionId,
|
|
307
|
+
...(modeClass === "bypass" || modeClass === "prompting" ? { fromMode: modeClass } : {}),
|
|
308
|
+
kind: "idle_subscription",
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
subscriptionSeq = await opts.mailbox.append(peerScope, handle, { from: ownRowName ?? opts.peerSelfSession?.name ?? senderLabel, content: `[idle subscription] ${ownRowName ?? opts.peerSelfSession?.name ?? senderLabel} asked to be told once when this session next goes idle`, sentAt: now, hopChain: [], peerMeta: subMeta });
|
|
312
|
+
subscription = { ok: true, disposition: record.liveness !== "live" ? "parked_offline" : "queued", via: "store" };
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
peerIdleMachineFor(peerScope, peerSelfSessionId).requester.settle(record.sessionId, "unavailable");
|
|
316
|
+
subscription = { ok: false, code: e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE ? "peer_send.invalid_target" : "peer_send.other", severity: "error", message: `the session box refused the subscription record (${(e instanceof Error ? e.message : String(e)).slice(0, 200)}) — no subscription was made.` };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
276
319
|
}
|
|
277
|
-
return peerRefusal("peer_send.other", `the session box refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked. ${DEDUP_RETRY_NOTE}`);
|
|
278
320
|
}
|
|
279
321
|
let rowRestored = "present";
|
|
280
322
|
try {
|
|
@@ -294,12 +336,26 @@ export function createSendMessageTool(opts) {
|
|
|
294
336
|
? " Note: that session was DELETED while this message was being parked — its box is being retired and this message will not be read; do not resend to it."
|
|
295
337
|
: "";
|
|
296
338
|
const offline = record.liveness !== "live";
|
|
339
|
+
const subscriptionNote = subscription === undefined
|
|
340
|
+
? ""
|
|
341
|
+
: subscription.ok
|
|
342
|
+
? ` Idle subscription recorded (one-shot, expires unheard after ${PEER_IDLE_SUBSCRIPTION_TTL_MS / 3_600_000} h): you will get ONE automated notice when that session next goes idle or exits${offline ? " — it is offline now, so the ask waits in its box with the message" : ""}. Do not poll for it.`
|
|
343
|
+
: ` Idle subscription NOT made (${subscription.code}): ${subscription.message}`;
|
|
344
|
+
if (seq === undefined) {
|
|
345
|
+
if (subscription === undefined || !subscription.ok) {
|
|
346
|
+
return { content: `Subscription not made: ${subscription?.message ?? "nothing to do"}`, details: { error: subscription?.code ?? "peer_send.other", code: subscription?.code ?? "peer_send.other", to, ...(subscription !== undefined ? { subscription } : {}) }, isError: true };
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
content: `Idle subscription recorded for ${peerLabel} (record seq ${subscriptionSeq}): you will get ONE automated notice when that session next goes idle or exits (one-shot; it expires unheard after ${PEER_IDLE_SUBSCRIPTION_TTL_MS / 3_600_000} h). No message was sent. Do not poll for it; continue with your task.${rowNote}`,
|
|
350
|
+
details: { type: "send-message", status: "peer_idle_subscribed", to, address: handle, subscription, ...(subscriptionSeq !== undefined ? { subscriptionSeq } : {}), ...(rowRestored !== "present" ? { directoryRow: rowRestored } : {}) },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
297
353
|
const verdict = { ok: true, disposition: offline ? "parked_offline" : "queued", via: "store" };
|
|
298
354
|
return {
|
|
299
355
|
content: offline
|
|
300
|
-
? `Message parked for ${peerLabel}: that session is offline, so the message was parked durably in its session box (seq ${seq}) and will be delivered when it next runs — subject to the deployment's mailbox retirement policy, which does not notify you. Continue with your task; do not resend the same content.${rowNote}`
|
|
301
|
-
: `Message queued for ${peerLabel} (seq ${seq}): it is parked durably in that session's box and will be read at its next turn boundary. This receipt confirms acceptance into the box, not the peer's judgment of the message. Continue with your task; do not resend the same content.${rowNote}`,
|
|
302
|
-
details: { type: "send-message", status: offline ? "peer_parked_offline" : "peer_queued", to, address: handle, seq, verdict, ...(rowRestored !== "present" ? { directoryRow: rowRestored } : {}) },
|
|
356
|
+
? `Message parked for ${peerLabel}: that session is offline, so the message was parked durably in its session box (seq ${seq}) and will be delivered when it next runs — subject to the deployment's mailbox retirement policy, which does not notify you. Continue with your task; do not resend the same content.${subscriptionNote}${rowNote}`
|
|
357
|
+
: `Message queued for ${peerLabel} (seq ${seq}): it is parked durably in that session's box and will be read at its next turn boundary. This receipt confirms acceptance into the box, not the peer's judgment of the message. Continue with your task; do not resend the same content.${subscriptionNote}${rowNote}`,
|
|
358
|
+
details: { type: "send-message", status: offline ? "peer_parked_offline" : "peer_queued", to, address: handle, seq, verdict, ...(subscription !== undefined ? { subscription } : {}), ...(subscriptionSeq !== undefined ? { subscriptionSeq } : {}), ...(rowRestored !== "present" ? { directoryRow: rowRestored } : {}) },
|
|
303
359
|
};
|
|
304
360
|
};
|
|
305
361
|
const settlePeerResolution = async (r) => {
|
|
@@ -329,6 +385,16 @@ export function createSendMessageTool(opts) {
|
|
|
329
385
|
}
|
|
330
386
|
}
|
|
331
387
|
};
|
|
388
|
+
if (notifyWhenIdle) {
|
|
389
|
+
if (!peerLane.active) {
|
|
390
|
+
return peerRefusal("peer_send.invalid_target", peerLane.reason !== undefined ? `notify_when_idle is only supported for peer sessions of this engine, and the cross-session lane is unavailable on this mount — ${peerLane.reason}. Nothing was sent.` : `notify_when_idle is only supported for peer sessions of this engine, and cross-session addressing is not mounted here (no peer-session directory). Nothing was sent.`);
|
|
391
|
+
}
|
|
392
|
+
if (senderIsChild) {
|
|
393
|
+
return peerRefusal("peer_send.invalid_target", "notify_when_idle is only available from the main conversation of this session (not from a subagent or teammate). Nothing was sent.");
|
|
394
|
+
}
|
|
395
|
+
const settled = await settlePeerResolution(await resolvePeerTarget());
|
|
396
|
+
return settled ?? peerRefusal("peer_send.invalid_target", `notify_when_idle is only supported for peer sessions of this engine (not teammates, subagents or agents), and no peer session is registered as "${inlineUntrusted(to, 120)}". Nothing was sent.`);
|
|
397
|
+
}
|
|
332
398
|
if (parsePeerSessionAddress(to) !== undefined) {
|
|
333
399
|
if (!peerLane.active) {
|
|
334
400
|
return peerRefusal("peer_send.invalid_target", peerLane.reason !== undefined ? `cross-session addressing is unavailable on this mount — ${peerLane.reason}. Nothing was sent.` : `cross-session addressing is not mounted here (no peer-session directory), so "${inlineUntrusted(to, 120)}" cannot be delivered.`);
|
package/dist/agents/subagent.js
CHANGED
|
@@ -2113,6 +2113,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2113
2113
|
: ctx.parentCwd !== undefined
|
|
2114
2114
|
? { parentCwd: ctx.parentCwd }
|
|
2115
2115
|
: {}),
|
|
2116
|
+
...(reviveClaim === undefined && ctx.fileHistoryLineage !== undefined ? { fileHistoryLineage: ctx.fileHistoryLineage } : {}),
|
|
2116
2117
|
...(ctx.onSubagentSpawn ? { onSubagentSpawn: ctx.onSubagentSpawn } : {}),
|
|
2117
2118
|
};
|
|
2118
2119
|
const childThinking = def?.thinking ?? ctx.thinkingLevel;
|
|
@@ -11,3 +11,14 @@ export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2000;
|
|
|
11
11
|
* recipe canonicalizer needs its VALUE and must not load the assembly face (and its SHA-locked assets)
|
|
12
12
|
* to get it; `auto-mode-prompt.js` re-exports it, so the public name and its module face are unchanged. */
|
|
13
13
|
export declare const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
|
|
14
|
+
/** CC 2.1.250 `FO = {maxConsecutive: 3, maxTotal: 20}` — the classifier DENIAL LIMITS applied when
|
|
15
|
+
* `RunnerDeps.autoMode.denialLimit` omits a bound. Count-then-judge: the block that reaches a bound is
|
|
16
|
+
* itself the one that falls back to a person (the 3rd consecutive block asks; the 20th total asks). */
|
|
17
|
+
export declare const AUTO_MODE_DENIAL_LIMIT_DEFAULTS: Readonly<{
|
|
18
|
+
maxConsecutive: number;
|
|
19
|
+
maxTotal: number;
|
|
20
|
+
}>;
|
|
21
|
+
/** CC 2.1.250 `AKe = 120000` — the fallback ask's auto-deny window (ms) when `autoDenyAfterMs` is omitted.
|
|
22
|
+
* `0` disarms the window (the ask waits on the approver alone — the upstream original "fall back to
|
|
23
|
+
* prompting" shape). */
|
|
24
|
+
export declare const AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS = 120000;
|
|
@@ -3,3 +3,5 @@ export const AUTO_MODE_DEFAULT_FAILURE_THRESHOLD = 3;
|
|
|
3
3
|
export const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
|
|
4
4
|
export const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2_000;
|
|
5
5
|
export const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
|
|
6
|
+
export const AUTO_MODE_DENIAL_LIMIT_DEFAULTS = Object.freeze({ maxConsecutive: 3, maxTotal: 20 });
|
|
7
|
+
export const AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS = 120_000;
|
package/dist/core/auto-mode.d.ts
CHANGED
|
@@ -98,3 +98,62 @@ export interface AutoModeDecider {
|
|
|
98
98
|
* One instance per run/session — the breaker state is the session's "退回非 auto" latch.
|
|
99
99
|
*/
|
|
100
100
|
export declare function createAutoModeDecider(opts: AutoModeDeciderOptions): AutoModeDecider;
|
|
101
|
+
/** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
|
|
102
|
+
* optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
|
|
103
|
+
* loudly at construction (never clamped, never read as the default). */
|
|
104
|
+
export interface AutoModeDenialLimitOptions {
|
|
105
|
+
/** Consecutive classifier blocks (no classifier/human allow between them) that fall back to a person.
|
|
106
|
+
* Default 3. A positive integer. */
|
|
107
|
+
maxConsecutive?: number;
|
|
108
|
+
/** Total classifier blocks per run that fall back to a person and reset the budget. Default 20. A
|
|
109
|
+
* positive integer. */
|
|
110
|
+
maxTotal?: number;
|
|
111
|
+
/** The fallback ask's auto-deny window, ms. Default 120_000; `0` = no window (the ask waits on the
|
|
112
|
+
* approver alone). A non-negative integer no greater than 2147483647 (a host timer truncates larger
|
|
113
|
+
* delays and fires at once — the fail-closed direction, and invisible). */
|
|
114
|
+
autoDenyAfterMs?: number;
|
|
115
|
+
}
|
|
116
|
+
/** The additive member a denial-limit fallback ask carries (`PermissionResult` ask arm, `AskRequest`):
|
|
117
|
+
* the counts that tripped the bound and the auto-deny window this particular ask runs under. */
|
|
118
|
+
export interface DenialLimitFallback {
|
|
119
|
+
/** Consecutive blocks INCLUDING the one that tripped the bound. */
|
|
120
|
+
readonly consecutive: number;
|
|
121
|
+
/** Total blocks this run INCLUDING the one that tripped the bound (read before the total-bound reset). */
|
|
122
|
+
readonly total: number;
|
|
123
|
+
/** Which bound tripped. */
|
|
124
|
+
readonly limit: "consecutive" | "total";
|
|
125
|
+
/** The auto-deny window for THIS ask, ms; `0` = none (disarmed by configuration, or the timed card was
|
|
126
|
+
* already shown for this streak). The ask resolver reads this member to arm its deadline. */
|
|
127
|
+
readonly autoDenyAfterMs: number;
|
|
128
|
+
}
|
|
129
|
+
export type DenialLimitVerdict = {
|
|
130
|
+
limitReached: false;
|
|
131
|
+
} | {
|
|
132
|
+
limitReached: true;
|
|
133
|
+
fallback: DenialLimitFallback;
|
|
134
|
+
};
|
|
135
|
+
/** The per-run denial tracker. Lives beside {@link AutoModeDecider} (same owner, same lifetime). */
|
|
136
|
+
export interface AutoModeDenialTracker {
|
|
137
|
+
/** A classifier `block`: count first, judge second (CC `eme` → `tme`). Returns whether THIS block is
|
|
138
|
+
* the one that falls back to a person, with the fallback's own snapshot. */
|
|
139
|
+
recordBlock(): DenialLimitVerdict;
|
|
140
|
+
/** A classifier `allow`, or a person's allow of a fallback ask (CC `yR`): consecutive → 0 and the
|
|
141
|
+
* timed-card mark cleared. A rule/fast-path allow must NOT call this. */
|
|
142
|
+
recordAllow(): void;
|
|
143
|
+
/** The current counts (diagnostics / pins). */
|
|
144
|
+
snapshot(): {
|
|
145
|
+
consecutive: number;
|
|
146
|
+
total: number;
|
|
147
|
+
timedFallbackShown: boolean;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Build the per-run tracker. Bad knob values throw (the deployment face is read at prepare; a refusal
|
|
151
|
+
* there is the loud exit the knob doctrine requires). */
|
|
152
|
+
export declare function createAutoModeDenialTracker(opts?: AutoModeDenialLimitOptions): AutoModeDenialTracker;
|
|
153
|
+
/** The fallback ask's text — CC's sentence family, single-sourced for every mint site (the main gate
|
|
154
|
+
* and the inherited-lane arms): `Classifier denial limit exceeded, falling back to prompting: <limit
|
|
155
|
+
* sentence>` + a blank line + `Latest blocked action: <the classifier's own reason, or the tool name>`.
|
|
156
|
+
* The reason is the classifier MODEL's text — the caller neutralizes it before it gets here. */
|
|
157
|
+
export declare function denialLimitFallbackMessage(fallback: DenialLimitFallback, latestBlockedAction: string): string;
|
|
158
|
+
/** The limit sentence alone (CC's two forms) — shared by the fallback ask and the headless terminal. */
|
|
159
|
+
export declare function denialLimitSentence(fallback: DenialLimitFallback): string;
|
package/dist/core/auto-mode.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS } from "./auto-mode-defaults.js";
|
|
1
|
+
import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS } from "./auto-mode-defaults.js";
|
|
2
2
|
export function parseAutoModeResponse(text) {
|
|
3
3
|
const t = text
|
|
4
4
|
.replace(/<thinking>[\s\S]*?<\/thinking>/g, "")
|
|
@@ -99,3 +99,59 @@ class AutoModeTimeout extends Error {
|
|
|
99
99
|
super("auto-mode classify timeout");
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
103
|
+
export function createAutoModeDenialTracker(opts = {}) {
|
|
104
|
+
const bound = (name, v, dflt) => {
|
|
105
|
+
if (v === undefined)
|
|
106
|
+
return dflt;
|
|
107
|
+
if (!Number.isInteger(v) || v < 1) {
|
|
108
|
+
throw new Error(`RunnerDeps.autoMode.denialLimit.${name} must be a positive integer (got ${JSON.stringify(v)}) — omit it for the default ${dflt}`);
|
|
109
|
+
}
|
|
110
|
+
return v;
|
|
111
|
+
};
|
|
112
|
+
const maxConsecutive = bound("maxConsecutive", opts.maxConsecutive, AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxConsecutive);
|
|
113
|
+
const maxTotal = bound("maxTotal", opts.maxTotal, AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxTotal);
|
|
114
|
+
let autoDenyAfterMs = AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS;
|
|
115
|
+
if (opts.autoDenyAfterMs !== undefined) {
|
|
116
|
+
if (!Number.isInteger(opts.autoDenyAfterMs) || opts.autoDenyAfterMs < 0 || opts.autoDenyAfterMs > MAX_TIMER_DELAY_MS) {
|
|
117
|
+
throw new Error(`RunnerDeps.autoMode.denialLimit.autoDenyAfterMs must be an integer between 0 and ${MAX_TIMER_DELAY_MS} (got ${JSON.stringify(opts.autoDenyAfterMs)}) — 0 disarms the window; omit it for the default ${AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS}`);
|
|
118
|
+
}
|
|
119
|
+
autoDenyAfterMs = opts.autoDenyAfterMs;
|
|
120
|
+
}
|
|
121
|
+
let consecutive = 0;
|
|
122
|
+
let total = 0;
|
|
123
|
+
let timedFallbackShown = false;
|
|
124
|
+
return {
|
|
125
|
+
recordBlock() {
|
|
126
|
+
consecutive += 1;
|
|
127
|
+
total += 1;
|
|
128
|
+
if (consecutive < maxConsecutive && total < maxTotal)
|
|
129
|
+
return { limitReached: false };
|
|
130
|
+
const totalTripped = total >= maxTotal;
|
|
131
|
+
const windowMs = timedFallbackShown || totalTripped ? 0 : autoDenyAfterMs;
|
|
132
|
+
const fallback = { consecutive, total, limit: totalTripped ? "total" : "consecutive", autoDenyAfterMs: windowMs };
|
|
133
|
+
if (totalTripped) {
|
|
134
|
+
consecutive = 0;
|
|
135
|
+
total = 0;
|
|
136
|
+
timedFallbackShown = false;
|
|
137
|
+
}
|
|
138
|
+
else if (windowMs > 0) {
|
|
139
|
+
timedFallbackShown = true;
|
|
140
|
+
}
|
|
141
|
+
return { limitReached: true, fallback };
|
|
142
|
+
},
|
|
143
|
+
recordAllow() {
|
|
144
|
+
consecutive = 0;
|
|
145
|
+
timedFallbackShown = false;
|
|
146
|
+
},
|
|
147
|
+
snapshot: () => ({ consecutive, total, timedFallbackShown }),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function denialLimitFallbackMessage(fallback, latestBlockedAction) {
|
|
151
|
+
return `Classifier denial limit exceeded, falling back to prompting: ${denialLimitSentence(fallback)}\n\nLatest blocked action: ${latestBlockedAction}`;
|
|
152
|
+
}
|
|
153
|
+
export function denialLimitSentence(fallback) {
|
|
154
|
+
return fallback.limit === "total"
|
|
155
|
+
? `${fallback.total} actions were blocked this session. Please review the transcript before continuing.`
|
|
156
|
+
: `${fallback.consecutive} consecutive actions were blocked. Please review the transcript before continuing.`;
|
|
157
|
+
}
|
|
@@ -125,7 +125,7 @@ export function buildRiskDescriptor(input) {
|
|
|
125
125
|
if (shell || toolName === "Bash") {
|
|
126
126
|
const cmd = isPlainRecord(args) ? safeDataValue(args, "command") : undefined;
|
|
127
127
|
if (typeof cmd === "string" && cmd.length > 0)
|
|
128
|
-
summary = renderUntrustedCommandText(redactSecrets(
|
|
128
|
+
summary = renderUntrustedCommandText(stripFormatCharacters(redactSecrets(cmd)), SUMMARY_CMD_MAX);
|
|
129
129
|
const bg = isPlainRecord(args) ? safeDataValue(args, "run_in_background") : undefined;
|
|
130
130
|
if (bg === true)
|
|
131
131
|
summary = `[background persistent process — no per-step recheck] ${summary ?? ""}`.trimEnd();
|
|
@@ -140,7 +140,7 @@ export function buildRiskDescriptor(input) {
|
|
|
140
140
|
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
141
141
|
const encDigest = (s) => s.replace(/%/g, "%25").replace(/=/g, "%3D").replace(/ /g, "%20");
|
|
142
142
|
const k = encDigest(renderUntrustedCommandText(key, 40));
|
|
143
|
-
const val = encDigest(renderUntrustedCommandText(redactSecrets(
|
|
143
|
+
const val = encDigest(renderUntrustedCommandText(stripFormatCharacters(redactSecrets(String(v))), SUMMARY_VALUE_MAX));
|
|
144
144
|
parts.push(`${k}=${val}`);
|
|
145
145
|
}
|
|
146
146
|
}
|
|
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
|
|
|
103
103
|
* src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
|
|
104
104
|
* no longer minted.
|
|
105
105
|
*/
|
|
106
|
-
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.durable_gate_unavailable", "config.peer_lane_unmounted", "peer.inbound_disposition", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
|
|
106
|
+
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
|
|
107
107
|
/** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
|
|
108
108
|
* `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
|
|
109
109
|
* sink is a supported shape, and narrowing that field would break it. */
|
|
@@ -102,8 +102,12 @@ export const ENGINE_NOTICE_CODES = [
|
|
|
102
102
|
"config.tool_model_gate_unknown_class",
|
|
103
103
|
"config.tool_model_gate_env_invalid",
|
|
104
104
|
"config.durable_gate_unavailable",
|
|
105
|
+
"config.peer_admission_out_of_range",
|
|
105
106
|
"config.peer_lane_unmounted",
|
|
106
107
|
"peer.inbound_disposition",
|
|
108
|
+
"peer.held_settled",
|
|
109
|
+
"peer.idle_subscription",
|
|
110
|
+
"classifier.denial_limit",
|
|
107
111
|
"delegation.transcript_integrity",
|
|
108
112
|
"mcp.revocation_probe_failed",
|
|
109
113
|
"workflow.governance_key_stripped",
|
|
@@ -142,6 +146,7 @@ const NOTICE_AUDIENCE_TABLE = {
|
|
|
142
146
|
"memory.hold_disposed": "user",
|
|
143
147
|
"task.user_steer_undrained": "user",
|
|
144
148
|
"task.user_followup_undrained": "user",
|
|
149
|
+
"classifier.denial_limit": "user",
|
|
145
150
|
"task.turn_interrupted": "user",
|
|
146
151
|
"steering.parked_input_blocked": "user",
|
|
147
152
|
"task.halt_unconsumed": "user",
|
|
@@ -159,7 +164,10 @@ const NOTICE_AUDIENCE_TABLE = {
|
|
|
159
164
|
"config.tool_model_gate_unknown_class": "operator",
|
|
160
165
|
"config.tool_model_gate_env_invalid": "operator",
|
|
161
166
|
"config.peer_lane_unmounted": "operator",
|
|
167
|
+
"config.peer_admission_out_of_range": "operator",
|
|
162
168
|
"peer.inbound_disposition": "user",
|
|
169
|
+
"peer.held_settled": "user",
|
|
170
|
+
"peer.idle_subscription": "user",
|
|
163
171
|
"delegation.transcript_integrity": "operator",
|
|
164
172
|
"mcp.revocation_probe_failed": "operator",
|
|
165
173
|
"workflow.governance_key_stripped": "operator",
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ActorAssertion, DocumentContent, ImageContent, TextContent } from
|
|
|
2
2
|
import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
|
|
3
3
|
import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
|
|
4
4
|
import { type AskClass } from "./ask-class.js";
|
|
5
|
+
import { type AutoModeDenialTracker } from "./auto-mode.js";
|
|
5
6
|
import type { WiringLegKind } from "./wiring-manifest.js";
|
|
6
7
|
/**
|
|
7
8
|
* In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
|
|
@@ -429,6 +430,10 @@ export interface PermissionDeniedPayload {
|
|
|
429
430
|
* screen; a policy's direct deny, a hook deny, and the crash/plan-mode/compliance emissions carry
|
|
430
431
|
* none. See {@link import("./tool-policy.js").AskDenyResolution}. */
|
|
431
432
|
resolution?: import("./tool-policy.js").AskDenyResolution;
|
|
433
|
+
/** #548 — the deny is the classifier denial-limit fallback's AUTO-DENY (core's own window elapsed;
|
|
434
|
+
* see {@link import("./tool-policy.js").ResolvedAsk.autoDenied}). Carried verbatim from the resolver;
|
|
435
|
+
* absent on every other deny. */
|
|
436
|
+
autoDenied?: true;
|
|
432
437
|
/** {@link HookSeatSignal} — this invocation's own abort signal. On an OBSERVATION seat the deny has
|
|
433
438
|
* already happened and nothing this callback does can change it, so the signal says exactly one
|
|
434
439
|
* thing: stop reading, nobody is waiting for your answer any more. */
|
|
@@ -1458,7 +1463,32 @@ export interface ToolGateInput {
|
|
|
1458
1463
|
*/
|
|
1459
1464
|
autoMode?: {
|
|
1460
1465
|
decider: import("./auto-mode.js").AutoModeDecider;
|
|
1466
|
+
/**
|
|
1467
|
+
* #548 — the per-run DENIAL-LIMIT tracker beside the decider (CC 2.1.250 `FO`/`Wie`; same owner,
|
|
1468
|
+
* same lifetime). Absent ⇒ the pre-#548 chain byte for byte: every block is a deny, without bound.
|
|
1469
|
+
* Present ⇒ a block first counts, then judges; the block that reaches a bound becomes the fallback
|
|
1470
|
+
* ask (`requiresRealApproval: true` + `denialLimitFallback`), and a classifier allow or a person's
|
|
1471
|
+
* allow of that ask resets the consecutive count. The Runner builds it from
|
|
1472
|
+
* `RunnerDeps.autoMode.denialLimit`; a host driving the gate directly may hand its own.
|
|
1473
|
+
*/
|
|
1474
|
+
denialTracking?: AutoModeDenialTracker;
|
|
1461
1475
|
};
|
|
1476
|
+
/**
|
|
1477
|
+
* #548 — the HEADLESS arm of the denial-limit fallback: the fallback ask was resolved by a deny no
|
|
1478
|
+
* person made (no approver wired; a blanket `"allow"` refused as no approver; an approver that
|
|
1479
|
+
* reported unavailable with no park to take it). The fallback had nowhere to go, so the caller — which
|
|
1480
|
+
* owns the run — stops it (`TaskResult.errorCode = "classifier.denial_limit"`, CC's "too many
|
|
1481
|
+
* classifier denials in headless mode" abort). The deny itself still stands; this gate only names the
|
|
1482
|
+
* fact. A TOP-LEVEL seat, deliberately NOT under `autoMode`: the fallback can arrive from an ANCESTOR's
|
|
1483
|
+
* frozen tracker through the fold (a delegated child with no armed classifier of its own), and that
|
|
1484
|
+
* child's run is the one that has to stop. Observe-only for the gate: a throwing seat never alters
|
|
1485
|
+
* the deny.
|
|
1486
|
+
*/
|
|
1487
|
+
onHeadlessDenialLimit?: (info: {
|
|
1488
|
+
toolName: string;
|
|
1489
|
+
toolCallId: string;
|
|
1490
|
+
fallback: import("./auto-mode.js").DenialLimitFallback;
|
|
1491
|
+
}) => void;
|
|
1462
1492
|
/**
|
|
1463
1493
|
* design/153 §2/§7.4 (件4 复审, MED): true means this call's ask is MARKED — an inherited ancestor
|
|
1464
1494
|
* constraint already determined "no synchronous layer may resolve this ask" (the ancestor's frozen
|
package/dist/core/hooks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { coreMintedResolutionOf, decisionText, describeThrown, isAskDenyResolution, refuseOutOfContractDecision } from "./tool-policy.js";
|
|
1
|
+
import { coreMintedAutoDeniedOf, coreMintedResolutionOf, decisionText, describeThrown, isAskDenyResolution, refuseOutOfContractDecision } from "./tool-policy.js";
|
|
2
2
|
import { brandPolicyAskClass } from "./ask-class.js";
|
|
3
|
+
import { denialLimitFallbackMessage } from "./auto-mode.js";
|
|
3
4
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
4
5
|
import { mintSystemReminder } from "./reminder-mint.js";
|
|
5
6
|
import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
|
|
@@ -443,6 +444,7 @@ export async function runToolGate(input) {
|
|
|
443
444
|
let hookAsk;
|
|
444
445
|
let parkFailed;
|
|
445
446
|
let askDenyResolution;
|
|
447
|
+
let askAutoDenied = false;
|
|
446
448
|
const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined);
|
|
447
449
|
const hookSeatMs = resolveHookTimeoutMs(input.hookTimeoutMs, (err) => traceHookCrash(input, err, notifier));
|
|
448
450
|
const seatBound = (abortEnds) => ({
|
|
@@ -804,12 +806,14 @@ export async function runToolGate(input) {
|
|
|
804
806
|
decision.action === "ask" &&
|
|
805
807
|
decision.decisionReason !== "hook" &&
|
|
806
808
|
decision.matchedAskRule === undefined &&
|
|
809
|
+
decision.denialLimitFallback === undefined &&
|
|
807
810
|
req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
|
|
808
811
|
input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
|
|
809
812
|
const verdict = await input.autoMode.decider
|
|
810
813
|
.decide({ req, askMessage: decisionText(decision) }, input.abortSignal)
|
|
811
814
|
.catch(() => ({ kind: "unavailable", cause: "error" }));
|
|
812
815
|
if (verdict.kind === "allow") {
|
|
816
|
+
input.autoMode.denialTracking?.recordAllow();
|
|
813
817
|
decision = {
|
|
814
818
|
action: "allow",
|
|
815
819
|
message: "auto-mode classifier allowed this call",
|
|
@@ -820,12 +824,25 @@ export async function runToolGate(input) {
|
|
|
820
824
|
else if (verdict.kind === "block") {
|
|
821
825
|
const reason = verdict.reason ? inlineUntrusted(verdict.reason) : "";
|
|
822
826
|
const category = verdict.category ? inlineUntrusted(verdict.category) : "";
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
827
|
+
const tracked = input.autoMode.denialTracking?.recordBlock();
|
|
828
|
+
if (tracked?.limitReached === true) {
|
|
829
|
+
decision = {
|
|
830
|
+
...decision,
|
|
831
|
+
message: denialLimitFallbackMessage(tracked.fallback, reason || category || toolName),
|
|
832
|
+
decisionReason: "classifier",
|
|
833
|
+
requiresRealApproval: true,
|
|
834
|
+
denialLimitFallback: tracked.fallback,
|
|
835
|
+
};
|
|
836
|
+
denySource = "classifier";
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
decision = {
|
|
840
|
+
action: "deny",
|
|
841
|
+
message: `auto-mode classifier blocked this call${reason ? `: ${reason}` : category ? `: [${category}]` : ""}`,
|
|
842
|
+
decisionReason: "classifier",
|
|
843
|
+
};
|
|
844
|
+
denySource = "classifier";
|
|
845
|
+
}
|
|
829
846
|
}
|
|
830
847
|
}
|
|
831
848
|
if (input.sandboxAdmission !== undefined &&
|
|
@@ -910,6 +927,8 @@ export async function runToolGate(input) {
|
|
|
910
927
|
resolvedApprover = resolved.approver;
|
|
911
928
|
if (resolved.action === "deny" && isAskDenyResolution(resolved.resolution))
|
|
912
929
|
askDenyResolution = resolved.resolution;
|
|
930
|
+
if (resolved.action === "deny" && resolved.autoDenied === true)
|
|
931
|
+
askAutoDenied = true;
|
|
913
932
|
decision = resolved;
|
|
914
933
|
if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
|
|
915
934
|
const parkArgs = [req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.segmentCoverage : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.matchedAskRule : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeMandated : undefined];
|
|
@@ -921,6 +940,21 @@ export async function runToolGate(input) {
|
|
|
921
940
|
return { suspend: suspended, preToolContext };
|
|
922
941
|
}
|
|
923
942
|
}
|
|
943
|
+
if (askBeforeResolve.action === "ask" && askBeforeResolve.denialLimitFallback !== undefined) {
|
|
944
|
+
if (decision.action === "allow") {
|
|
945
|
+
input.autoMode?.denialTracking?.recordAllow();
|
|
946
|
+
}
|
|
947
|
+
else if (askDenyResolution === "no_approver" ||
|
|
948
|
+
askDenyResolution === "blanket_allow_refused" ||
|
|
949
|
+
askDenyResolution === "approver_unavailable" ||
|
|
950
|
+
resolved.approverUnavailable === true) {
|
|
951
|
+
try {
|
|
952
|
+
input.onHeadlessDenialLimit?.({ toolName, toolCallId, fallback: askBeforeResolve.denialLimitFallback });
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
924
958
|
if (decision.action === "allow" && decision.updatedInput !== undefined) {
|
|
925
959
|
let editArgs = decision.updatedInput;
|
|
926
960
|
let editDenied;
|
|
@@ -1062,7 +1096,8 @@ export async function runToolGate(input) {
|
|
|
1062
1096
|
currentInput = decision.updatedInput;
|
|
1063
1097
|
}
|
|
1064
1098
|
const denyResolution = askDenyResolution ?? coreMintedResolutionOf(decision, { toolCallId, toolName });
|
|
1065
|
-
|
|
1099
|
+
const denyAutoDenied = askAutoDenied || coreMintedAutoDeniedOf(decision, { toolCallId, toolName });
|
|
1100
|
+
await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(denyAutoDenied ? { autoDenied: true } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) });
|
|
1066
1101
|
const denySettledBy = decision.settledBy;
|
|
1067
1102
|
const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
|
|
1068
1103
|
return {
|