@sema-agent/core 5.55.0 → 5.57.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 +140 -0
- package/dist/agents/send-message-tool.d.ts +11 -0
- package/dist/agents/send-message-tool.js +81 -13
- package/dist/agents/subagent.js +250 -89
- package/dist/agents/team.d.ts +10 -1
- package/dist/agents/team.js +1 -0
- package/dist/brain/anthropic.js +15 -5
- package/dist/brain/circuit-breaker.js +2 -1
- package/dist/brain/degrading.js +4 -1
- package/dist/brain/failover.js +16 -1
- package/dist/brain/open-responses.js +15 -5
- package/dist/brain/openai.js +16 -5
- package/dist/brain/request-params.d.ts +30 -27
- package/dist/brain/request-params.js +1 -7
- package/dist/brain/route-adjudicator.d.ts +190 -0
- package/dist/brain/route-adjudicator.js +189 -0
- package/dist/brain/route-conformance.d.ts +55 -0
- package/dist/brain/route-conformance.js +136 -0
- package/dist/brain/routing.js +8 -3
- package/dist/core/auto-compaction.d.ts +17 -4
- package/dist/core/auto-compaction.js +3 -0
- package/dist/core/context-edit.d.ts +55 -6
- package/dist/core/context-edit.js +12 -1
- package/dist/core/hooks.d.ts +293 -11
- package/dist/core/hooks.js +158 -11
- package/dist/core/human-input-projection.d.ts +20 -2
- package/dist/core/human-input-projection.js +9 -0
- package/dist/core/mcp.js +4 -4
- package/dist/core/memory-engine/engine.d.ts +15 -5
- package/dist/core/memory-engine/engine.js +3 -1
- package/dist/core/permission-rule-consent.d.ts +45 -0
- package/dist/core/permission-rule-consent.js +40 -11
- package/dist/core/permission-rule-model.d.ts +110 -75
- package/dist/core/permission-rule-model.js +61 -28
- package/dist/core/permission-rules.d.ts +23 -15
- package/dist/core/permission-rules.js +40 -31
- package/dist/core/runner/prepare-task.d.ts +8 -0
- package/dist/core/runner/prepare-task.js +66 -26
- package/dist/core/runner/runtask.d.ts +4 -1
- package/dist/core/runner/runtask.js +206 -21
- package/dist/core/runner/session-rule-policy.js +5 -5
- package/dist/core/scheduler.d.ts +5 -0
- package/dist/core/session-reconcile.d.ts +32 -0
- package/dist/core/session-reconcile.js +15 -0
- package/dist/core/side-query.d.ts +12 -5
- package/dist/core/task-notification.d.ts +34 -7
- package/dist/core/task-notification.js +11 -1
- package/dist/core/task-registry-agent.d.ts +20 -3
- package/dist/core/task-registry-agent.js +31 -2
- package/dist/core/tool-policy.d.ts +14 -9
- package/dist/core/tool-policy.js +27 -22
- package/dist/core/types.d.ts +69 -11
- package/dist/core/untrusted-text.js +8 -0
- package/dist/engine/compaction/compaction.d.ts +77 -7
- package/dist/engine/compaction/compaction.js +98 -9
- package/dist/engine/compaction/utils.d.ts +4 -0
- package/dist/engine/compaction/utils.js +6 -0
- package/dist/engine/harness/agent-harness.d.ts +84 -0
- package/dist/engine/harness/agent-harness.js +114 -13
- package/dist/engine/harness/messages.d.ts +4 -2
- package/dist/engine/harness/messages.js +7 -2
- package/dist/engine/harness/types.d.ts +16 -6
- package/dist/engine/llm/types.d.ts +65 -0
- package/dist/engine/loop/types.d.ts +7 -0
- package/dist/engine/session/import-validate.js +10 -0
- package/dist/engine/session/session.js +2 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/internal/llm.d.ts +1 -1
- package/dist/orchestration/run-spec.js +8 -1
- package/dist/prompts/default.d.ts +12 -6
- package/dist/prompts/default.js +2 -0
- package/dist/scenarios/scenario-registry.d.ts +5 -1
- package/dist/scenarios/scenario-registry.js +4 -2
- package/dist/tools/fs/index.js +8 -1
- package/dist/tools/scheduler-tools.js +28 -6
- package/dist/tools/web.d.ts +15 -0
- package/dist/tools/web.js +8 -2
- package/dist/tools/worktree.js +2 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +19 -1
package/dist/core/hooks.d.ts
CHANGED
|
@@ -25,6 +25,23 @@ import type { WiringLegKind } from "./wiring-manifest.js";
|
|
|
25
25
|
*
|
|
26
26
|
* All feedback the model should see (a block reason, injected context) is wrapped as a
|
|
27
27
|
* `<system-reminder>` via {@link formatHookFeedback} so the model can read and self-correct.
|
|
28
|
+
*
|
|
29
|
+
* **FAILURE POSTURE, and a correction to a widely-quoted anchor (recorded 2026-08-23).** Three repos
|
|
30
|
+
* have reasoned from the sentence "CC fails open on hook failures, exit code 2 excepted". That sentence
|
|
31
|
+
* is TRUE OF ONE LANE and false as a general statement, and the difference lands exactly on this seam.
|
|
32
|
+
* CC runs hooks of seven kinds; the `command` kind is a shell script, and for it a spawn failure, a
|
|
33
|
+
* non-zero exit and a per-hook timeout are all non-blocking — the call proceeds. But CC ALSO has an
|
|
34
|
+
* in-process CALLBACK lane, which is the one structurally equivalent to this interface, and there a
|
|
35
|
+
* PreToolUse callback that throws or times out is FAIL-CLOSED, with its own message stating that the
|
|
36
|
+
* tool call was not executed. (Two further details the short sentence hides: the decision channel is
|
|
37
|
+
* stdout JSON, with the exit code only a fallback — so exit 0 can block and a non-zero non-2 exit with
|
|
38
|
+
* valid JSON does not; and the callback lane's UserPromptSubmit timeout BLOCKS the prompt.)
|
|
39
|
+
*
|
|
40
|
+
* So this seam's fail-closed postures are not a sema invention to be justified against CC — they are
|
|
41
|
+
* the same posture CC takes on the same kind of lane: a `preToolUse` that throws or does not answer
|
|
42
|
+
* refuses the call, a `userPromptSubmit` that throws or does not answer refuses the prompt, and the
|
|
43
|
+
* OBSERVATION seats swallow and disclose because an observer has nothing to fail closed about. Anyone
|
|
44
|
+
* quoting the short sentence to argue this layer "should" fail open is quoting the script lane.
|
|
28
45
|
*/
|
|
29
46
|
export interface Hooks {
|
|
30
47
|
preToolUse?(toolName: string, input: unknown, ctx: HookToolContext): PreToolUseResult | undefined | Promise<PreToolUseResult | undefined>;
|
|
@@ -159,9 +176,179 @@ export interface Hooks {
|
|
|
159
176
|
* in for CC `decision_reason_type`): no `retry` capability (recorded — retry semantics vs our adjudicate
|
|
160
177
|
* chain need their own design), no additionalContext. A throwing callback is swallowed + traced via
|
|
161
178
|
* `onError(phase:"hook")` — an observer bug must never alter the deny outcome (design/134 discipline).
|
|
179
|
+
*
|
|
180
|
+
* **HOW WIDE THIS SEAT IS, stated honestly (anchoring re-check 2026-08-23).** The exclusion sentence
|
|
181
|
+
* above is quoted from the SDK's TYPE-LEVEL documentation, and the whole R5 scoping argument was
|
|
182
|
+
* built on it without ever being checked against a shipped bundle. Against CC 2.1.223's runtime the
|
|
183
|
+
* picture is different in a way that matters for how this comment may be USED: CC emits its
|
|
184
|
+
* PermissionDenied event from exactly ONE site, guarded to the auto-mode CLASSIFIER's refusals —
|
|
185
|
+
* a rule deny, a mode deny and a headless auto-deny emit nothing at all. So "a hook's own deny is
|
|
186
|
+
* excluded" is TRIVIALLY true there (almost everything is excluded), and it must not be read as
|
|
187
|
+
* evidence that this seat's TRIGGER SURFACE matches CC's. It does not: sema fires on four engine
|
|
188
|
+
* emissions with seven `source` values, deliberately WIDER. The width is the useful direction for an
|
|
189
|
+
* observation seat — the blind spot R3 named is real and a narrower seat would not close it — and it
|
|
190
|
+
* is a divergence rather than a parity claim. The exclusion itself stays, on its own merit: a hook's
|
|
191
|
+
* deny is the HOOK's decision, and this seat exists to attribute the ENGINE's.
|
|
162
192
|
*/
|
|
163
193
|
permissionDenied?(payload: PermissionDeniedPayload): void | Promise<void>;
|
|
194
|
+
/**
|
|
195
|
+
* The TIME BOUND every seat on this object runs under, in milliseconds. Absent ⇒
|
|
196
|
+
* {@link DEFAULT_HOOK_TIMEOUT_MS}. It is the analog of the CC hook record's own `timeout` field —
|
|
197
|
+
* declared once per hook record there, once per hook OBJECT here, because this seam's record IS the
|
|
198
|
+
* object. The unit is ms rather than CC's seconds: every other deadline this library takes ends in
|
|
199
|
+
* `Ms`, and one unit per codebase beats one unit per upstream.
|
|
200
|
+
*
|
|
201
|
+
* **Why the seam has a bound at all.** Every callback above is DEPLOYMENT code awaited on the
|
|
202
|
+
* engine's critical path, and one that never returns used to stop the whole run — the tool gate
|
|
203
|
+
* awaited `preToolUse` bare, so a hung screening face left the call unsettled and the task's own
|
|
204
|
+
* timeout/cancel could not end it. The diagnosis was an internal asymmetry rather than a guess: the
|
|
205
|
+
* SAME callback installed as an inherited constraint on a delegated child was already bounded, because
|
|
206
|
+
* that path folds it into a `ToolPolicy` and the policy wait is abort-raced — and the sibling seam in
|
|
207
|
+
* this very file (`reversibilityProbe`) had already been given a default deadline for the identical
|
|
208
|
+
* reason. Policies were bounded, probes were bounded, hooks were not.
|
|
209
|
+
*
|
|
210
|
+
* **What a bound does NOT change.** A seat that answers keeps its answer, byte for byte; a seat that
|
|
211
|
+
* throws keeps its existing crash posture (this member changes nothing about a throw). The bound is
|
|
212
|
+
* consulted only when a seat has not answered yet, and what happens then is per-seat and stated on
|
|
213
|
+
* each member's own contract — decision seats fail CLOSED (`preToolUse` refuses the call the way a
|
|
214
|
+
* crash does; `userPromptSubmit` refuses the prompt), observation seats are ABANDONED and the engine
|
|
215
|
+
* proceeds as if the seat had returned nothing. Either way the deployment is told on its `onError`
|
|
216
|
+
* lane, because a silently-dropped seat is the failure mode this member exists to make visible.
|
|
217
|
+
*
|
|
218
|
+
* **The seat also gets a signal.** An expired wait would otherwise leave the callback running with
|
|
219
|
+
* nobody reading its answer — an orphan of the deployment's own making. So every context carries an
|
|
220
|
+
* optional `signal` that aborts when this bound elapses (or the task aborts), and an implementation
|
|
221
|
+
* that honors it can cancel the read it was waiting on. Honoring it is optional; a seat that ignores
|
|
222
|
+
* it is exactly as bounded as before, the engine simply stops waiting.
|
|
223
|
+
*
|
|
224
|
+
* **WHAT THE BOUND IS ON, stated narrowly because the difference is not academic.** It bounds the
|
|
225
|
+
* engine's WAIT, not the callback's CPU. A hook that blocks the thread SYNCHRONOUSLY — a spin loop, a
|
|
226
|
+
* synchronous exec, a synchronous parse of something enormous — cannot be preempted by this bound or
|
|
227
|
+
* by any other in-process mechanism, because the timer that would fire is queued behind the code that
|
|
228
|
+
* is not yielding; the ordering of the timer's installation makes no difference to that. In-process
|
|
229
|
+
* is the seam's whole premise (it is what makes a hook able to rewrite args and return a verdict),
|
|
230
|
+
* and preemption is what it trades away. A deployment that must survive a hook it does not trust to
|
|
231
|
+
* yield runs that hook somewhere with its own thread — a worker, a process, a service — and awaits
|
|
232
|
+
* the result from here. What this member DOES bound is every asynchronous wait, which is where the
|
|
233
|
+
* failure this was written for lives: a callback that awaits an unreachable host, a lock, a queue.
|
|
234
|
+
*
|
|
235
|
+
* **Value discipline** (bad values are loud, never silently reinterpreted): a non-finite, negative, or
|
|
236
|
+
* above-`setTimeout`-ceiling (2147483647) value is REFUSED to the default and reported on the
|
|
237
|
+
* deployment's error lane — the same treatment, and the same ceiling, the gate's `approvalTimeoutMs`
|
|
238
|
+
* seat gives its own garbage. `0` is honored as written (every seat expires immediately), because a
|
|
239
|
+
* deployment that writes zero has said something, and guessing that it meant "unbounded" would be the
|
|
240
|
+
* one reading this member cannot support.
|
|
241
|
+
*/
|
|
242
|
+
timeoutMs?: number;
|
|
164
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* The bound a {@link Hooks} seat runs under when the deployment declares none ({@link Hooks.timeoutMs}).
|
|
246
|
+
*
|
|
247
|
+
* The value is CC's own hook default (600s) rather than a number invented here. It is deliberately
|
|
248
|
+
* GENEROUS: the seat bound's job is to make a wedge terminal, not to police how long a legitimate hook
|
|
249
|
+
* may think — the task's own abort signal is what ends a hung DECISION seat promptly on a run that has a
|
|
250
|
+
* deadline, and this ceiling is what ends it on a run that does not. A deployment whose hooks should
|
|
251
|
+
* answer in a second says so with `timeoutMs`.
|
|
252
|
+
*/
|
|
253
|
+
export declare const DEFAULT_HOOK_TIMEOUT_MS = 600000;
|
|
254
|
+
/**
|
|
255
|
+
* How long an observation seat still gets when the task is cancelled `elapsed` ms into its wait — the
|
|
256
|
+
* grace, capped by what is LEFT of the declared deadline.
|
|
257
|
+
*
|
|
258
|
+
* The `remaining` argument, not the whole deadline, is the correction: arming a fresh
|
|
259
|
+
* `min(GRACE, timeoutMs)` from the abort moment restarts the clock, so an abort landing near the
|
|
260
|
+
* deadline pushes the seat's absolute end PAST the number the deployment declared — up to double it for
|
|
261
|
+
* a sub-second bound — on precisely the cancelled-task settlement path the grace exists to shorten. The
|
|
262
|
+
* two contract sentences this file already carries ("never an extension", "a deployment that declared a
|
|
263
|
+
* SHORTER `timeoutMs` keeps its own number") were true only in the aborted-on-entry case, where elapsed
|
|
264
|
+
* is zero and the two formulas coincide. Now they are true everywhere.
|
|
265
|
+
*
|
|
266
|
+
* Clamped at zero: a deadline already spent is no grace at all, not a negative timer.
|
|
267
|
+
*/
|
|
268
|
+
export declare function postAbortGraceMs(remainingMs: number): number;
|
|
269
|
+
/**
|
|
270
|
+
* Read {@link Hooks.timeoutMs} as a usable deadline: the declared value when it is one, the default
|
|
271
|
+
* when it is absent, and the default LOUDLY when it is garbage (bad-value loudness — a knob's bad value
|
|
272
|
+
* must never resolve silently to the default).
|
|
273
|
+
*
|
|
274
|
+
* `owner` is the record the value was read off; passing it bounds the disclosure to once per record
|
|
275
|
+
* (see {@link badHookTimeoutReported}). Omit it where the caller already bounds its own channel.
|
|
276
|
+
*/
|
|
277
|
+
export declare function resolveHookTimeoutMs(supplied: number | undefined, report?: (err: unknown) => void, owner?: object): number;
|
|
278
|
+
/** The ten {@link Hooks} callback seats, named so a bound wait can say WHICH one did not answer. */
|
|
279
|
+
export type HookSeatName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "stop" | "stopFailure" | "preCompact" | "postCompact" | "permissionDenied";
|
|
280
|
+
/** What one bounded seat invocation produced: the seat's own value, or the fact that the wait ended
|
|
281
|
+
* first. A seat that THREW is neither — the throw propagates out of {@link runHookSeat} unchanged, so
|
|
282
|
+
* each site's existing crash arm keeps owning it. */
|
|
283
|
+
export type HookSeatOutcome<T> = {
|
|
284
|
+
readonly expired: false;
|
|
285
|
+
readonly value: T;
|
|
286
|
+
} | {
|
|
287
|
+
readonly expired: true;
|
|
288
|
+
readonly cause: "timeout" | "aborted";
|
|
289
|
+
};
|
|
290
|
+
/** How one seat invocation is bounded. */
|
|
291
|
+
export interface HookSeatBound {
|
|
292
|
+
/** The deployment's declared bound ({@link Hooks.timeoutMs}); re-validated here, so a caller that
|
|
293
|
+
* already validated pays nothing and a caller that did not cannot pass garbage through. */
|
|
294
|
+
timeoutMs?: number;
|
|
295
|
+
/** The task abort signal. It is ALWAYS forwarded to the seat (so an implementation can cancel its own
|
|
296
|
+
* underlying wait) and it ALWAYS shortens the wait — the two seat kinds differ only in HOW MUCH.
|
|
297
|
+
* Supply it wherever the caller has one: a seat with no signal is bounded by its deadline alone, and
|
|
298
|
+
* a ten-minute default deadline is not a cancellation story. */
|
|
299
|
+
signal?: AbortSignal;
|
|
300
|
+
/** True for a seat whose answer is MOOT once the task is ending — the DECISION seats. Those settle as
|
|
301
|
+
* `aborted` the instant the task is cancelled. An observation seat leaves this false on purpose:
|
|
302
|
+
* `postToolUseFailure` fires precisely BECAUSE the task aborted (`isInterrupt`), and cancelling it
|
|
303
|
+
* outright would delete the observation the seat exists to deliver — it gets
|
|
304
|
+
* {@link POST_ABORT_GRACE_MS} instead of the full deadline. */
|
|
305
|
+
abortEnds?: boolean;
|
|
306
|
+
/** Where a bad {@link timeoutMs} is disclosed. The EXPIRY itself is not reported here — each site
|
|
307
|
+
* words its own, because the sentence a deployment needs includes what the engine then did. */
|
|
308
|
+
onBadTimeout?: (err: unknown) => void;
|
|
309
|
+
/** The hook record the bound was read off, for once-per-record bad-value disclosure. */
|
|
310
|
+
owner?: object;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Run ONE {@link Hooks} seat invocation under its bound — the single home of "a deployment callback
|
|
314
|
+
* gets a deadline", so the ten seats cannot grow ten dialects of one wait.
|
|
315
|
+
*
|
|
316
|
+
* Shape notes, each one load-bearing:
|
|
317
|
+
* · the seat is invoked SYNCHRONOUSLY, in this turn, exactly where the bare `await` used to be — a
|
|
318
|
+
* callback that reads its own mutable state before its first `await` must see the turn it always saw;
|
|
319
|
+
* · a SYNCHRONOUS throw propagates unchanged (it never reached a deadline, and the site's crash arm is
|
|
320
|
+
* the contract for it), and so does an asynchronous rejection;
|
|
321
|
+
* · the seat's signal fires when — and ONLY when — this function stops waiting, and its promise is then
|
|
322
|
+
* abandoned with a rejection sink attached (an abandoned seat that fails later must not surface as an
|
|
323
|
+
* unhandled rejection somewhere else). The task's own signal is never forwarded raw: for an
|
|
324
|
+
* OBSERVATION seat the task being cancelled is not a reason to stop, it is the reason the seat is
|
|
325
|
+
* firing at all;
|
|
326
|
+
* · the TASK signal ends the wait on both kinds of seat, differently. A DECISION seat
|
|
327
|
+
* ({@link HookSeatBound.abortEnds}) settles as `aborted` the moment the task is cancelled, INCLUDING
|
|
328
|
+
* when it was already cancelled on entry — a verdict about a call that will not run decides nothing,
|
|
329
|
+
* and waiting the full deadline for one holds a cancelled task open for as long as the deadline says.
|
|
330
|
+
* On the entry case the callback is still INVOKED (an audit face keeps seeing the attempt) and its
|
|
331
|
+
* answer is discarded: settling on the cancellation rather than on whichever of the two happened to
|
|
332
|
+
* land first is what makes the arm a rule instead of a race.
|
|
333
|
+
* An OBSERVATION seat is not cancelled by the abort — it fires precisely BECAUSE the task aborted
|
|
334
|
+
* (`isInterrupt`), and racing it against that same abort would delete the observation it exists to
|
|
335
|
+
* deliver — but it does not get the full deadline either: the abort collapses its wait to
|
|
336
|
+
* {@link POST_ABORT_GRACE_MS}. Prompt observers still land; a hung one can no longer hold a cancelled
|
|
337
|
+
* task's settlement for the length of the whole bound.
|
|
338
|
+
*/
|
|
339
|
+
export declare function runHookSeat<T>(seat: HookSeatName, bound: HookSeatBound, call: (signal: AbortSignal) => T | Promise<T>): Promise<HookSeatOutcome<T>>;
|
|
340
|
+
/**
|
|
341
|
+
* The ONE wording home for "the engine stopped waiting for a hook seat", so the ten sites cannot drift
|
|
342
|
+
* into ten sentences about one fact. `consequence` is the half only the site knows: what the engine did
|
|
343
|
+
* instead.
|
|
344
|
+
*
|
|
345
|
+
* The CAUSE is named, not glossed. A deadline that elapsed and a task that was cancelled are different
|
|
346
|
+
* facts to whoever reads this — the first says a hook is too slow or stuck and wants fixing, the second
|
|
347
|
+
* says nothing about the hook at all — and one sentence covering both would have to be false about one
|
|
348
|
+
* of them. (It was: the first version of this said "did not answer within its Nms bound" on every
|
|
349
|
+
* expiry, including the ones where the bound was never reached.)
|
|
350
|
+
*/
|
|
351
|
+
export declare function hookSeatExpiredError(seat: HookSeatName, timeoutMs: number, cause: "timeout" | "aborted", consequence: string): Error;
|
|
165
352
|
/**
|
|
166
353
|
* Where a {@link Hooks.permissionDenied} deny came from — our gate-source enum standing in for CC's
|
|
167
354
|
* `decision_reason_type` ('classifier'|'asyncAgent'|'mode'|'rule'), named after OUR adjudicate-chain
|
|
@@ -170,7 +357,9 @@ export interface Hooks {
|
|
|
170
357
|
* (headless auto-deny / approver said no / approval aborted).
|
|
171
358
|
* - `"hook"` — a PreToolUse hook `ask` (folded allow→ask) resolved to deny, OR the engine's fail-closed
|
|
172
359
|
* interception of a hook that THREW (RB-442). (A hook's own `deny` short-circuit is EXCLUDED from the
|
|
173
|
-
* event
|
|
360
|
+
* event — a decision the hook made; the crash interception is a decision the ENGINE made. The
|
|
361
|
+
* exclusion is ours on that merit; see {@link Hooks.permissionDenied} for why it must NOT be read
|
|
362
|
+
* as a parity claim about this seat's trigger surface.)
|
|
174
363
|
* - `"safety"` — the gate's deterministic egress/irreversibility tighten (design/70 / design/77 §4)
|
|
175
364
|
* raised the `ask` that resolved to deny, from an explicit per-tool mark.
|
|
176
365
|
* - `"shellGate"` — same tighten-deny, but the tool's irreversibility tier was installed by the COARSE
|
|
@@ -205,6 +394,10 @@ export interface PermissionDeniedPayload {
|
|
|
205
394
|
* screen; a policy's direct deny, a hook deny, and the crash/plan-mode/compliance emissions carry
|
|
206
395
|
* none. See {@link import("./tool-policy.js").AskDenyResolution}. */
|
|
207
396
|
resolution?: import("./tool-policy.js").AskDenyResolution;
|
|
397
|
+
/** {@link HookSeatSignal} — this invocation's own abort signal. On an OBSERVATION seat the deny has
|
|
398
|
+
* already happened and nothing this callback does can change it, so the signal says exactly one
|
|
399
|
+
* thing: stop reading, nobody is waiting for your answer any more. */
|
|
400
|
+
signal?: AbortSignal;
|
|
208
401
|
}
|
|
209
402
|
/**
|
|
210
403
|
* 1.256 复审 MED-1 — observe-only payload isolation for {@link Hooks.permissionDenied}: clone the tool
|
|
@@ -227,7 +420,28 @@ export interface StopFailureContext {
|
|
|
227
420
|
errorKind?: string;
|
|
228
421
|
/** Turns completed when the failure ended the run. */
|
|
229
422
|
turns: number;
|
|
423
|
+
/** {@link HookSeatSignal} — this invocation's own abort signal. */
|
|
424
|
+
signal?: AbortSignal;
|
|
230
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* The abort signal every hook seat's context carries (`ctx.signal`), documented once here and
|
|
428
|
+
* referenced from each seat.
|
|
429
|
+
*
|
|
430
|
+
* It carries exactly one meaning: **the engine has stopped waiting for this invocation.** It is not the
|
|
431
|
+
* task's abort signal and it never fires early on the task's behalf — a DECISION seat's wait ends when
|
|
432
|
+
* the task is cancelled, so its signal fires then; an OBSERVATION seat's wait ends at its deadline or
|
|
433
|
+
* after the post-abort grace, so its signal fires there and NOT at the cancellation itself. The
|
|
434
|
+
* difference is deliberate: a `postToolUseFailure` fires precisely BECAUSE the task aborted, and a
|
|
435
|
+
* signal that went off at the cancellation would tell a cooperative observer to abandon the very
|
|
436
|
+
* observation it was invoked to deliver.
|
|
437
|
+
*
|
|
438
|
+
* Honoring it is optional and it changes nothing about the engine's own behavior — the engine stops
|
|
439
|
+
* waiting either way. What it is for is the deployment's side of that: a seat that was awaiting a
|
|
440
|
+
* network read, a lock or a subprocess can cancel it instead of leaving an orphan running with nobody
|
|
441
|
+
* left to read its answer. Absent when a host drives a seat outside the engine's own invocation (no
|
|
442
|
+
* bound, nothing to signal).
|
|
443
|
+
*/
|
|
444
|
+
export type HookSeatSignal = AbortSignal;
|
|
231
445
|
/** The failed tool execution a PostToolUseFailure callback inspects (design/134). */
|
|
232
446
|
export interface HookToolFailure {
|
|
233
447
|
/** Model-facing error text of the failed call (the error tool result's text content, joined). */
|
|
@@ -273,6 +487,8 @@ export interface PreCompactContext {
|
|
|
273
487
|
trigger: "auto" | "manual" | "forced";
|
|
274
488
|
/** The summarization instructions in effect (spec/deployment-level), when set. */
|
|
275
489
|
customInstructions?: string;
|
|
490
|
+
/** {@link HookSeatSignal} — this invocation's own abort signal. */
|
|
491
|
+
signal?: AbortSignal;
|
|
276
492
|
}
|
|
277
493
|
/** A PreCompact result: skip this compaction (auto/manual only) and/or extend the summary instructions. */
|
|
278
494
|
export interface PreCompactResult {
|
|
@@ -293,6 +509,8 @@ export interface PostCompactContext {
|
|
|
293
509
|
summary: string;
|
|
294
510
|
tokensBefore?: number;
|
|
295
511
|
tokensAfter?: number;
|
|
512
|
+
/** {@link HookSeatSignal} — this invocation's own abort signal. */
|
|
513
|
+
signal?: AbortSignal;
|
|
296
514
|
}
|
|
297
515
|
/** Context for the {@link Hooks.stop} hook (CC `stop_hook_active` parity). */
|
|
298
516
|
export interface StopHookContext {
|
|
@@ -326,6 +544,8 @@ export interface StopHookContext {
|
|
|
326
544
|
* Absent when the runner cannot supply one (a stop gate wired without a session).
|
|
327
545
|
*/
|
|
328
546
|
getBranch?: () => Promise<readonly SessionTreeEntry[]>;
|
|
547
|
+
/** {@link HookSeatSignal} — this invocation's own abort signal. */
|
|
548
|
+
signal?: AbortSignal;
|
|
329
549
|
}
|
|
330
550
|
/** A Stop hook result: block the run from ending, with a model-readable reason. */
|
|
331
551
|
export interface StopHookResult {
|
|
@@ -333,11 +553,20 @@ export interface StopHookResult {
|
|
|
333
553
|
* model gets another turn to address it. */
|
|
334
554
|
block?: string;
|
|
335
555
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
556
|
+
* Extra model-readable context injected at the stop point — DECOUPLED from `block`. When present it
|
|
557
|
+
* is injected (as a `<system-reminder>`, neutral framing: "Stop hook additional context: …") whether
|
|
558
|
+
* or not the hook blocked, and an `additionalContext` WITHOUT `block` still continues the run one
|
|
559
|
+
* more turn.
|
|
560
|
+
*
|
|
561
|
+
* **It DOES count toward the consecutive-block cap, and it never resets it.** This sentence used to
|
|
562
|
+
* say the opposite ("the cap is driven by `block` alone", cited to CC 2.1.201), and it was wrong on
|
|
563
|
+
* both halves. Re-read against CC: the `additionalContext` branch pushes into the SAME array as the
|
|
564
|
+
* blocking-error branch, the loop counts that array's length against the cap, and CC never resets
|
|
565
|
+
* mid-run. The engine was fixed to match; the sentence here was not, so the module that DEFINES this
|
|
566
|
+
* seat and the module that IMPLEMENTS it stated opposite behaviors for years. What the cap bounds is
|
|
567
|
+
* "the run did not end when it otherwise would have", and a context-only push-back is exactly that —
|
|
568
|
+
* a hook whose most common failure mode is "no parseable verdict, here is some text" would otherwise
|
|
569
|
+
* run to `maxTurns` looking busy rather than stuck.
|
|
341
570
|
*/
|
|
342
571
|
additionalContext?: string;
|
|
343
572
|
}
|
|
@@ -524,6 +753,28 @@ export interface HookToolContext {
|
|
|
524
753
|
* environment to report). Present ⇒ inspect the individual members: each one is itself optional.
|
|
525
754
|
*/
|
|
526
755
|
env?: HookEnvCapabilities;
|
|
756
|
+
/**
|
|
757
|
+
* The TASK's tracked working directory at this call — the base the hand tools will resolve a RELATIVE
|
|
758
|
+
* path against, read live off the same ref the tool policy reads (so a hook and a policy judging the
|
|
759
|
+
* same call judge it against the same base).
|
|
760
|
+
*
|
|
761
|
+
* It is NOT {@link HookEnvCapabilities.cwd}, and the difference is the reason this member exists: that
|
|
762
|
+
* one is the ENVIRONMENT's own working directory, which never moves when the shell `cd`s or the run
|
|
763
|
+
* enters a worktree. A hook is documented — on the capability face itself — as the seat that judges
|
|
764
|
+
* "is this write inside the workspace", and it was the only seat in the gate that could not resolve a
|
|
765
|
+
* relative target the way the tool would. CC ships its own `cwd` to every hook event for the same
|
|
766
|
+
* reason; this is that field, named for the value it actually carries.
|
|
767
|
+
*
|
|
768
|
+
* ABSENT when the run has no tracked cwd to report — no real write hands (read-only or no shell), a
|
|
769
|
+
* host driving {@link runToolGate} without one, or the delegation-fold twin (which runs inside a
|
|
770
|
+
* DESCENDANT's policy fold and would otherwise report the install site's directory, i.e. the wrong
|
|
771
|
+
* machine's answer). Absence means "this engine cannot tell you", never "the root".
|
|
772
|
+
*/
|
|
773
|
+
cwd?: string;
|
|
774
|
+
/** {@link HookSeatSignal} — this invocation's own abort signal. A FRESH one per screening pass, like
|
|
775
|
+
* the context object it rides: phase 1 and the approval-edit re-screen are two bounded waits, and a
|
|
776
|
+
* shared signal would let the first pass's expiry cancel the second. */
|
|
777
|
+
signal?: AbortSignal;
|
|
527
778
|
}
|
|
528
779
|
/** The executed tool result a PostToolUse hook inspects. */
|
|
529
780
|
export interface HookToolOutput {
|
|
@@ -572,12 +823,16 @@ export interface PostToolUseResult {
|
|
|
572
823
|
export interface UserPromptSubmitContext {
|
|
573
824
|
/** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
|
|
574
825
|
identity?: HookInvocationIdentity;
|
|
826
|
+
/** {@link HookSeatSignal} — this invocation's own abort signal. */
|
|
827
|
+
signal?: AbortSignal;
|
|
575
828
|
}
|
|
576
829
|
/** #281 件A — the context of a {@link Hooks.postToolBatch} invocation (third parameter, additive —
|
|
577
830
|
* deliberately NOT folded into `meta`, whose presence already means "the engine injected here"). */
|
|
578
831
|
export interface PostToolBatchContext {
|
|
579
832
|
/** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
|
|
580
833
|
identity?: HookInvocationIdentity;
|
|
834
|
+
/** {@link HookSeatSignal} — this invocation's own abort signal. */
|
|
835
|
+
signal?: AbortSignal;
|
|
581
836
|
}
|
|
582
837
|
/** A UserPromptSubmit hook result: block the submission, or inject context ahead of the prompt. */
|
|
583
838
|
export interface UserPromptSubmitResult {
|
|
@@ -822,6 +1077,26 @@ export interface ToolGateInput {
|
|
|
822
1077
|
* system-prompt declaration names. Absent (a host driving the gate directly) ⇒ bare tags. */
|
|
823
1078
|
reminderMark?: string;
|
|
824
1079
|
preToolUse?: Hooks["preToolUse"];
|
|
1080
|
+
/**
|
|
1081
|
+
* The bound the {@link preToolUse} screenings run under — {@link Hooks.timeoutMs}, carried in from
|
|
1082
|
+
* the hook record the caller resolved it off (the gate receives the callback, not the record). Absent
|
|
1083
|
+
* ⇒ {@link DEFAULT_HOOK_TIMEOUT_MS}. A garbage value is refused loudly to that default via
|
|
1084
|
+
* {@link onHookError}, exactly as {@link approvalTimeoutMs} is.
|
|
1085
|
+
*
|
|
1086
|
+
* On elapse the screening FAILS CLOSED — the call does not execute — and the block reason names the
|
|
1087
|
+
* hook and the bound. That direction is not a new posture for this seat: a hook that THROWS is
|
|
1088
|
+
* already the fail-closed deny here, and a hook that never answers has told the gate strictly less
|
|
1089
|
+
* than one that crashed. A face the deployment declared observational is not exempt, for the same
|
|
1090
|
+
* reason its throw is not: the declaration says its VERDICTS do not count, and a wait that never
|
|
1091
|
+
* ends is not a verdict.
|
|
1092
|
+
*/
|
|
1093
|
+
hookTimeoutMs?: number;
|
|
1094
|
+
/** The task's live tracked cwd reader, put on every {@link HookToolContext} this gate call builds
|
|
1095
|
+
* (see {@link HookToolContext.cwd}). A FUNCTION rather than a string for the reason the policy's own
|
|
1096
|
+
* threading gives: capturing the value when the gate's wiring is built would freeze the task's
|
|
1097
|
+
* starting directory and answer for a run that has since `cd`ed. Absent ⇒ the contexts carry no
|
|
1098
|
+
* `cwd`, which is the honest answer for a run with no tracked one. */
|
|
1099
|
+
trackedCwd?: () => string | undefined;
|
|
825
1100
|
/** The read-only env capability face put on every {@link HookToolContext} this gate call
|
|
826
1101
|
* builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
|
|
827
1102
|
* passed through unchanged; omitted when the deployment wired no execution environment. */
|
|
@@ -941,12 +1216,13 @@ export interface ToolGateInput {
|
|
|
941
1216
|
/**
|
|
942
1217
|
* design/134 R5: observer for the gate's DENY short-circuit ({@link Hooks.permissionDenied}), pre-wrapped
|
|
943
1218
|
* by the caller (swallow + onError) so it never throws. Fired ONCE, at the single deny exit of the
|
|
944
|
-
* adjudicate chain — a policy deny or a resolved-ask deny. NOT fired for a PreToolUse hook deny
|
|
945
|
-
*
|
|
946
|
-
*
|
|
1219
|
+
* adjudicate chain — a policy deny or a resolved-ask deny. NOT fired for a PreToolUse hook deny (the
|
|
1220
|
+
* exclusion on {@link Hooks.permissionDenied}, which also records why that exclusion is not a parity
|
|
1221
|
+
* claim) or a durable suspend (the ask path). runToolGate still try/catches defensively: an observer
|
|
1222
|
+
* must never alter the deny outcome.
|
|
947
1223
|
*
|
|
948
1224
|
* RB-442: ALSO fired for the engine's fail-closed interception of a THROWING PreToolUse hook, with
|
|
949
|
-
* `source:"hook"` — the
|
|
1225
|
+
* `source:"hook"` — the exclusion is the hook's own deny DECISION, not an engine decision.
|
|
950
1226
|
*/
|
|
951
1227
|
permissionDenied?: (payload: PermissionDeniedPayload) => void | Promise<void>;
|
|
952
1228
|
/**
|
|
@@ -1198,7 +1474,13 @@ export interface ToolGateInput {
|
|
|
1198
1474
|
* so a broken screening face is distinguishable from a deliberate refusal, and the folded form has to
|
|
1199
1475
|
* reach the same lane from inside a descendant's fold.
|
|
1200
1476
|
*/
|
|
1201
|
-
export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void
|
|
1477
|
+
export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void,
|
|
1478
|
+
/** The install site's {@link Hooks.timeoutMs}. The folded form was ALREADY bounded by the descendant's
|
|
1479
|
+
* abort race — that asymmetry (bounded as an inherited constraint, unbounded in its own gate) is what
|
|
1480
|
+
* named the hole in the first place — but an abort bound is not a deadline: on a descendant with no
|
|
1481
|
+
* walltime and no cancel, the race never fires. So the same seat bound rides here too, and the two
|
|
1482
|
+
* installations of one callback are now bounded the same way for the same reason. */
|
|
1483
|
+
timeoutMs?: number): ToolPolicy;
|
|
1202
1484
|
/**
|
|
1203
1485
|
* The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
|
|
1204
1486
|
* the single source for "could a persisted allow rule clear this ask?". Allow rules silence the
|
package/dist/core/hooks.js
CHANGED
|
@@ -6,6 +6,92 @@ import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
|
|
|
6
6
|
import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
|
|
7
7
|
import { createSafeNotifier } from "./safe-notify.js";
|
|
8
8
|
import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
|
|
9
|
+
export const DEFAULT_HOOK_TIMEOUT_MS = 600_000;
|
|
10
|
+
const MAX_HOOK_TIMEOUT_MS = 2_147_483_647;
|
|
11
|
+
const POST_ABORT_GRACE_MS = 1_000;
|
|
12
|
+
export function postAbortGraceMs(remainingMs) {
|
|
13
|
+
return Math.max(0, Math.min(POST_ABORT_GRACE_MS, remainingMs));
|
|
14
|
+
}
|
|
15
|
+
const badHookTimeoutReported = new WeakSet();
|
|
16
|
+
export function resolveHookTimeoutMs(supplied, report, owner) {
|
|
17
|
+
if (supplied === undefined)
|
|
18
|
+
return DEFAULT_HOOK_TIMEOUT_MS;
|
|
19
|
+
if (Number.isFinite(supplied) && supplied >= 0 && supplied <= MAX_HOOK_TIMEOUT_MS)
|
|
20
|
+
return supplied;
|
|
21
|
+
if (report !== undefined && !(owner !== undefined && badHookTimeoutReported.has(owner))) {
|
|
22
|
+
if (owner !== undefined)
|
|
23
|
+
badHookTimeoutReported.add(owner);
|
|
24
|
+
try {
|
|
25
|
+
report(new Error(`Hooks.timeoutMs must be a non-negative finite number no greater than ${MAX_HOOK_TIMEOUT_MS} (got ${String(supplied)}) — ` +
|
|
26
|
+
`every hook seat falls back to the ${DEFAULT_HOOK_TIMEOUT_MS}ms default`));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return DEFAULT_HOOK_TIMEOUT_MS;
|
|
32
|
+
}
|
|
33
|
+
export async function runHookSeat(seat, bound, call) {
|
|
34
|
+
void seat;
|
|
35
|
+
const timeoutMs = resolveHookTimeoutMs(bound.timeoutMs, bound.onBadTimeout, bound.owner);
|
|
36
|
+
const taskSignal = bound.signal;
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const pending = Promise.resolve(call(controller.signal));
|
|
39
|
+
const startedAt = performance.now();
|
|
40
|
+
return await new Promise((resolve, reject) => {
|
|
41
|
+
let settled = false;
|
|
42
|
+
let listening = false;
|
|
43
|
+
let timer;
|
|
44
|
+
const clearTimer = () => {
|
|
45
|
+
if (timer !== undefined)
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
timer = undefined;
|
|
48
|
+
};
|
|
49
|
+
const finish = (act) => {
|
|
50
|
+
if (settled)
|
|
51
|
+
return;
|
|
52
|
+
settled = true;
|
|
53
|
+
clearTimer();
|
|
54
|
+
if (listening)
|
|
55
|
+
taskSignal?.removeEventListener("abort", onAbort);
|
|
56
|
+
act();
|
|
57
|
+
};
|
|
58
|
+
const expire = (cause) => finish(() => {
|
|
59
|
+
void pending.then(undefined, () => {
|
|
60
|
+
});
|
|
61
|
+
resolve({ expired: true, cause });
|
|
62
|
+
queueMicrotask(() => {
|
|
63
|
+
try {
|
|
64
|
+
controller.abort();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
const collapseToGrace = () => {
|
|
71
|
+
if (settled)
|
|
72
|
+
return;
|
|
73
|
+
clearTimer();
|
|
74
|
+
timer = setTimeout(() => expire("aborted"), postAbortGraceMs(timeoutMs - (performance.now() - startedAt)));
|
|
75
|
+
};
|
|
76
|
+
const onAbort = () => (bound.abortEnds === true ? expire("aborted") : collapseToGrace());
|
|
77
|
+
timer = setTimeout(() => expire("timeout"), timeoutMs);
|
|
78
|
+
if (taskSignal !== undefined) {
|
|
79
|
+
if (taskSignal.aborted) {
|
|
80
|
+
onAbort();
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
taskSignal.addEventListener("abort", onAbort, { once: true });
|
|
84
|
+
listening = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
pending.then((value) => finish(() => resolve({ expired: false, value })), (err) => finish(() => reject(err instanceof Error ? err : new Error(String(err)))));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export function hookSeatExpiredError(seat, timeoutMs, cause, consequence) {
|
|
91
|
+
return new Error(cause === "timeout"
|
|
92
|
+
? `the deployment's ${seat} hook did not answer within its ${timeoutMs}ms bound (Hooks.timeoutMs) — ${consequence}`
|
|
93
|
+
: `the deployment's ${seat} hook had not answered when the task was cancelled — ${consequence}`);
|
|
94
|
+
}
|
|
9
95
|
export function cloneObserverInput(input) {
|
|
10
96
|
try {
|
|
11
97
|
return structuredClone(input);
|
|
@@ -202,17 +288,35 @@ function preToolUseCrashReason(subject, err) {
|
|
|
202
288
|
`This is a failure of the deployment's hook, NOT of the tool itself — an identical retry reaches the ` +
|
|
203
289
|
`same hook and fails the same way. Hook error: ${cause || "(no message)"}`);
|
|
204
290
|
}
|
|
291
|
+
function preToolUseUnansweredReason(subject, timeoutMs, cause) {
|
|
292
|
+
return cause === "timeout"
|
|
293
|
+
? `a PreToolUse hook did not answer within its ${timeoutMs}ms bound while screening ${subject}; the call was ` +
|
|
294
|
+
`NOT executed (fail-closed). This is a failure of the deployment's hook, NOT of the tool itself — the ` +
|
|
295
|
+
`screening face never returned a verdict, so nothing was decided about this call either way.`
|
|
296
|
+
: `the task was cancelled while a PreToolUse hook was still screening ${subject}; the call was NOT executed ` +
|
|
297
|
+
`(fail-closed). No verdict was reached — this is the run ending, not the tool failing.`;
|
|
298
|
+
}
|
|
205
299
|
function screenPreToolUseResult(r) {
|
|
206
300
|
if (r === undefined)
|
|
207
301
|
return undefined;
|
|
208
302
|
return refuseOutOfContractDecision(r, { reasonIsNonInput: true });
|
|
209
303
|
}
|
|
210
|
-
export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
|
|
304
|
+
export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash, timeoutMs) {
|
|
211
305
|
return brandPolicyAskClass({
|
|
212
306
|
check: async (req) => {
|
|
213
307
|
let r;
|
|
308
|
+
const boundMs = resolveHookTimeoutMs(timeoutMs, onCrash);
|
|
214
309
|
try {
|
|
215
|
-
|
|
310
|
+
const seat = await runHookSeat("preToolUse", { timeoutMs: boundMs }, (sig) => preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, signal: sig, ...(env !== undefined ? { env } : {}) }));
|
|
311
|
+
if (seat.expired) {
|
|
312
|
+
try {
|
|
313
|
+
onCrash?.(hookSeatExpiredError("preToolUse", boundMs, seat.cause, `the inherited screening of "${req.toolName}" was refused fail-closed`));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
}
|
|
317
|
+
return { action: "deny", message: preToolUseUnansweredReason(`this call to "${req.toolName}"`, boundMs, seat.cause), decisionReason: "hook" };
|
|
318
|
+
}
|
|
319
|
+
r = screenPreToolUseResult(seat.value);
|
|
216
320
|
}
|
|
217
321
|
catch (err) {
|
|
218
322
|
try {
|
|
@@ -284,9 +388,14 @@ export function persistedRuleMandateOf(marks) {
|
|
|
284
388
|
export async function runToolGate(input) {
|
|
285
389
|
const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
|
|
286
390
|
const { toolCallId, toolName } = event;
|
|
287
|
-
const hookCtx = () => ({
|
|
391
|
+
const hookCtx = (seatSignal) => ({
|
|
288
392
|
toolCallId,
|
|
289
393
|
toolName,
|
|
394
|
+
...(seatSignal !== undefined ? { signal: seatSignal } : {}),
|
|
395
|
+
...(() => {
|
|
396
|
+
const c = input.trackedCwd?.();
|
|
397
|
+
return typeof c === "string" ? { cwd: c } : {};
|
|
398
|
+
})(),
|
|
290
399
|
...(input.hookEnv !== undefined ? { env: input.hookEnv } : {}),
|
|
291
400
|
...(input.identity !== undefined ? { identity: input.identity } : {}),
|
|
292
401
|
});
|
|
@@ -296,17 +405,47 @@ export async function runToolGate(input) {
|
|
|
296
405
|
let parkFailed;
|
|
297
406
|
let askDenyResolution;
|
|
298
407
|
const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined);
|
|
408
|
+
const hookSeatMs = resolveHookTimeoutMs(input.hookTimeoutMs, (err) => traceHookCrash(input, err, notifier));
|
|
409
|
+
const seatBound = (abortEnds) => ({
|
|
410
|
+
timeoutMs: hookSeatMs,
|
|
411
|
+
...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}),
|
|
412
|
+
...(abortEnds ? { abortEnds: true } : {}),
|
|
413
|
+
});
|
|
414
|
+
const preToolUseUnansweredBlock = async (subject, cause) => {
|
|
415
|
+
const reason = preToolUseUnansweredReason(subject, hookSeatMs, cause);
|
|
416
|
+
if (cause === "timeout") {
|
|
417
|
+
traceHookCrash(input, hookSeatExpiredError("preToolUse", hookSeatMs, cause, `${subject} was NOT executed (fail-closed) and the screening face was signalled to stop`), notifier);
|
|
418
|
+
}
|
|
419
|
+
return reason;
|
|
420
|
+
};
|
|
421
|
+
const notifyPermissionDeniedSeat = async (payload) => {
|
|
422
|
+
if (input.permissionDenied === undefined)
|
|
423
|
+
return;
|
|
424
|
+
await notifier.notifyAsync(async () => {
|
|
425
|
+
const seat = await runHookSeat("permissionDenied", seatBound(false), (sig) => input.permissionDenied?.({ ...payload, signal: sig }));
|
|
426
|
+
if (seat.expired)
|
|
427
|
+
throw hookSeatExpiredError("permissionDenied", hookSeatMs, seat.cause, "the deny observation was abandoned; the deny itself is unchanged");
|
|
428
|
+
}, "toolGate.permissionDenied");
|
|
429
|
+
};
|
|
299
430
|
if (preToolUse) {
|
|
300
431
|
let r;
|
|
432
|
+
let unanswered;
|
|
301
433
|
try {
|
|
302
|
-
|
|
434
|
+
const seat = await runHookSeat("preToolUse", seatBound(true), (sig) => preToolUse(toolName, currentInput, hookCtx(sig)));
|
|
435
|
+
if (seat.expired)
|
|
436
|
+
unanswered = seat.cause;
|
|
437
|
+
else
|
|
438
|
+
r = screenPreToolUseResult(seat.value);
|
|
303
439
|
}
|
|
304
440
|
catch (err) {
|
|
305
441
|
const reason = preToolUseCrashReason(`this call to "${toolName}"`, err);
|
|
306
442
|
traceHookCrash(input, err, notifier);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
443
|
+
await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) });
|
|
444
|
+
return { block: true, reason: formatHookFeedback(reason, input.reminderMark), preToolContext };
|
|
445
|
+
}
|
|
446
|
+
if (unanswered !== undefined) {
|
|
447
|
+
const reason = await preToolUseUnansweredBlock(`this call to "${toolName}"`, unanswered);
|
|
448
|
+
await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) });
|
|
310
449
|
return { block: true, reason: formatHookFeedback(reason, input.reminderMark), preToolContext };
|
|
311
450
|
}
|
|
312
451
|
if (r) {
|
|
@@ -714,8 +853,13 @@ export async function runToolGate(input) {
|
|
|
714
853
|
}
|
|
715
854
|
if (preToolUse) {
|
|
716
855
|
let hr;
|
|
856
|
+
let hrUnanswered;
|
|
717
857
|
try {
|
|
718
|
-
|
|
858
|
+
const seat = await runHookSeat("preToolUse", seatBound(true), (sig) => preToolUse(toolName, editArgs, hookCtx(sig)));
|
|
859
|
+
if (seat.expired)
|
|
860
|
+
hrUnanswered = seat.cause;
|
|
861
|
+
else
|
|
862
|
+
hr = screenPreToolUseResult(seat.value);
|
|
719
863
|
}
|
|
720
864
|
catch (err) {
|
|
721
865
|
traceHookCrash(input, err, notifier);
|
|
@@ -723,6 +867,11 @@ export async function runToolGate(input) {
|
|
|
723
867
|
denySource = "hook";
|
|
724
868
|
break;
|
|
725
869
|
}
|
|
870
|
+
if (hrUnanswered !== undefined) {
|
|
871
|
+
editDenied = { action: "deny", message: await preToolUseUnansweredBlock(`the approved edit for "${toolName}"`, hrUnanswered) };
|
|
872
|
+
denySource = "hook";
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
726
875
|
if (hr) {
|
|
727
876
|
if (hr.additionalContext)
|
|
728
877
|
preToolContext.push(hr.additionalContext);
|
|
@@ -828,9 +977,7 @@ export async function runToolGate(input) {
|
|
|
828
977
|
currentInput = decision.updatedInput;
|
|
829
978
|
}
|
|
830
979
|
const denyResolution = askDenyResolution ?? coreMintedResolutionOf(decision, { toolCallId, toolName });
|
|
831
|
-
|
|
832
|
-
await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
|
|
833
|
-
}
|
|
980
|
+
await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) });
|
|
834
981
|
const denySettledBy = decision.settledBy;
|
|
835
982
|
const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
|
|
836
983
|
return {
|