@theokit/agents 13.0.0-next.11 → 13.0.0-next.13

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/dist/{agent-compiler-DRfBbirH.d.ts → agent-compiler-BztdbNPn.d.ts} +60 -12
  3. package/dist/auth.d.ts +86 -1
  4. package/dist/auth.js +36 -0
  5. package/dist/auth.js.map +1 -1
  6. package/dist/{bridge-entry-Bk4rgMF8.d.ts → bridge-entry-BOoyL9Sh.d.ts} +4 -4
  7. package/dist/bridge.d.ts +5 -5
  8. package/dist/bridge.js +7 -4
  9. package/dist/{chunk-KTOWSF2S.js → chunk-I7CFQD4E.js} +27 -10
  10. package/dist/chunk-I7CFQD4E.js.map +1 -0
  11. package/dist/chunk-M5J3Q6YC.js +17 -0
  12. package/dist/chunk-M5J3Q6YC.js.map +1 -0
  13. package/dist/{chunk-RKWCXVYG.js → chunk-MJ6FRILJ.js} +67 -5
  14. package/dist/chunk-MJ6FRILJ.js.map +1 -0
  15. package/dist/{chunk-BHW7CSF4.js → chunk-REMEZ6RV.js} +3 -3
  16. package/dist/{chunk-BHW7CSF4.js.map → chunk-REMEZ6RV.js.map} +1 -1
  17. package/dist/{chunk-AHLGDLUX.js → chunk-ZNTH3EMC.js} +148 -50
  18. package/dist/chunk-ZNTH3EMC.js.map +1 -0
  19. package/dist/config.d.ts +1 -1
  20. package/dist/{define-agent-Cu9uZY7B.d.ts → define-agent-Daz8XjV0.d.ts} +2 -2
  21. package/dist/{delegation-scoring-CjzhqW2q.d.ts → delegation-scoring-BEwcEnd-.d.ts} +1 -1
  22. package/dist/hooks.d.ts +0 -32
  23. package/dist/hooks.js +36 -13
  24. package/dist/hooks.js.map +1 -1
  25. package/dist/index.d.ts +80 -15
  26. package/dist/index.js +9 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/{setting-sources-gate-D2NS1GsB.d.ts → setting-sources-gate-DFu51i50.d.ts} +93 -3
  29. package/dist/testing.d.ts +3 -3
  30. package/dist/testing.js +1 -1
  31. package/dist/tools.d.ts +3 -3
  32. package/dist/tools.js +19 -4
  33. package/dist/tools.js.map +1 -1
  34. package/package.json +1 -1
  35. package/dist/chunk-AHLGDLUX.js.map +0 -1
  36. package/dist/chunk-KTOWSF2S.js.map +0 -1
  37. package/dist/chunk-RKWCXVYG.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,349 @@
1
1
  # @theokit/agents
2
2
 
3
+ ## 13.0.0-next.13
4
+
5
+ ### Minor Changes
6
+
7
+ - 6b07960: New `grantGate(store, classify)` in `@theokit/agents/auth` — the supported way to put a
8
+ `PermissionStore` in force.
9
+
10
+ The store shipped with a careful grant key, a "deny by default, always" docblock and no reader.
11
+ Measured: `isGranted` had zero callers outside its own unit test, and `PermissionStore` appeared in
12
+ zero files across six sibling repositories. An operator reading `.theokit/tool-permissions.json` to
13
+ learn what an agent may run was reading a control that was not in force — a grant and its revocation
14
+ produced identical behaviour.
15
+
16
+ `grantGate` adapts the store to `pre_tool_call`, which is documented as the only hook with veto
17
+ power and runs before the tool by construction, so a refusal is a refusal before the side effect. No
18
+ new gate and no framework wiring: nothing is enforced unless a consumer attaches the handler, and an
19
+ agent that does not is unaffected.
20
+
21
+ `classify` returns `{ governed: true, query }` **or** `{ governed: false }` — a tagged union whose
22
+ BOTH arms carry the discriminant. A bare `undefined` would say "this tool needs no permission" and
23
+ "I forgot this tool" in the same word, and on a security gate the second must not silently pass.
24
+
25
+ The discriminant is on both arms because the first shape — discriminated by whether a `governed` KEY
26
+ was present — **failed open**: a consumer writing a policy record `{ governed: true, scope }` and
27
+ spreading it into the query got the tool waved through, and so did `governed: undefined`. TypeScript
28
+ does not stop that; excess properties pass freely through a variable or a spread. Fail-closed is now
29
+ measured across `true` / `undefined` / `false` / `null`, and only `false` passes.
30
+
31
+ Named `grantGate`, not `permissionGate`, because `@theokit/sdk` already exports `PermissionGate`,
32
+ `PermissionGateContext`, `PermissionGateDecision`, `PermissionEngine` and `PermissionPlugin`. The
33
+ rename also says something true: this gates on a standing grant the operator made, and the SDK's
34
+ permission engine is a separate system — neither satisfies the other.
35
+
36
+ A classifier that throws DENIES, naming the throw, rather than ending the turn. A corrupt store
37
+ denies with a message that says so — "the permission store could not be read, so no grant applies" —
38
+ distinct from "no standing grant matches", so an operator can tell the two apart.
39
+
40
+ The read error's own text is deliberately NOT in that message: the veto travels to the model, and
41
+ `lastReadError.message` carries the absolute store path and its file mode. It stays on
42
+ `store.lastReadError` for the operator, which is where the actionable remedy (`chmod 600 …`) lives.
43
+ The scope IS still interpolated, so this narrows the exposure rather than eliminating it.
44
+
45
+ **Composing with a `pre_tool_call` you already have**: the field is singular, so assigning the gate
46
+ over an existing handler loses one of the two silently. Compose explicitly —
47
+ `async (ctx) => (await gate(ctx)) ?? (await mine(ctx))`; first veto wins.
48
+
49
+ `PermissionStore`'s docblock now states that the class enforces nothing on its own, and records the
50
+ precedence among the surfaces IN THIS PACKAGE that can refuse a tool — saying plainly that the list
51
+ is not exhaustive, because `@theokit/sdk` has its own permission system that neither knows about
52
+ these nor is known by them.
53
+
54
+ - 5211721: `moderateOutputStream` now delivers the redacted text to the client instead of computing it and
55
+ replaying the original events.
56
+
57
+ It called `runOutputGuards` and discarded the return value, so a guard that redacted correctly had
58
+ its work thrown away: measured against the built artifact, a guard returning `[REDACTED]` delivered
59
+ `sk-abc123`. Only `block` reached the client honestly.
60
+
61
+ **Signature change**: `moderateOutputStream` takes a fourth argument,
62
+ `rebuildText: (text: string, replaced: E) => E`, which builds one event carrying the
63
+ moderated text, given the text-carrying event it replaces. It is required rather than optional —
64
+ optional would let the function compute a redaction it cannot apply, which is the defect being
65
+ removed. Only the caller knows how to construct its own events.
66
+
67
+ **`extractText` MUST match exactly one event kind.** When it matches several, they COLLAPSE INTO
68
+ ONE — measured: `[thinking('CoT: the key is sk-abc'), message(' Here you go.')]` yields a single
69
+ `message` reading `"CoT: the key is [R] Here you go."`, with no `thinking` event surviving. A
70
+ consumer who wants reasoning moderated runs a SECOND pass over that kind rather than widening one
71
+ extractor.
72
+
73
+ `replaced` does not prevent that collapse, and an earlier draft of this entry said it did. What it
74
+ buys is narrower: the surviving event keeps the KIND and metadata of the text-carrying event it
75
+ replaces, instead of being rebuilt from the text alone. `replaced` is ALWAYS the event being replaced.
76
+ It was typed `E | undefined` for a case that cannot happen — a stream where no event carried text
77
+ returns from the absence check before the guards run, so `rebuildText` is not reached at all. It is
78
+ also never a non-text event, because a caller spreading one would emit a duplicate of it.
79
+
80
+ **Second signature change**: a fifth argument, `rebuildResult: (text, result) => R`, applies the
81
+ moderated text to the generator's RETURN value. A stream has two channels and the first release of
82
+ this fix moderated one: the events were redacted while `step.value` — the aggregate `run()` returns
83
+ — still carried the original text. Measured: the guard computed `"the key is [R]"` and
84
+ `run().response` was `"the key is sk-abc123"`, so **`run()`, the primary non-streaming API, kept
85
+ delivering the secret**. That was this fix's own defect one channel over. Required for the same
86
+ reason `rebuildText` is; passed rather than re-derived, because re-running the guards on the
87
+ aggregate would apply a non-idempotent guard twice.
88
+
89
+ When the text is unchanged, the buffered events are replayed verbatim as before. When it changed,
90
+ the **last** text-carrying event is REPLACED by a newly built event carrying the whole moderated
91
+ string, and the earlier text events are dropped. Events carrying no text are never dropped. Note
92
+ "replaced", not "modified": any non-text payload the surviving event carried is lost, as is that of
93
+ the dropped ones — a consumer whose text events carry per-event metadata should moderate one kind
94
+ only, or rebuild from `replaced`. An event whose extracted text is the EMPTY STRING is still
95
+ text-carrying and can be the one replaced.
96
+
97
+ **Known consequence:** when text events straddle a non-text event, their relative order does not
98
+ survive a redaction. Given `text('tok ') , tool_call , text('sk-abc')` the client now receives
99
+ `tool_call , text('tok [R]')` — text that preceded the tool call follows it. Landing on the last
100
+ text-carrying event keeps a trailing terminator in place and keeps any completion claim after the
101
+ work that produced it; what it cannot keep is the interleaving, because the redaction is about the
102
+ whole string and the boundaries are gone by the time it exists. A test pins this so it is found
103
+ here rather than in a transcript that stopped making sense.
104
+
105
+ A guard that rewrites **unconditionally** — a disclaimer appender, a trim, an NFC normaliser —
106
+ takes this path on every stream that DOES carry text. The cost is not proportional to how much the
107
+ guard changed. It does NOT add a text event to a round that produced none — this entry claimed so,
108
+ and the absence check refuses it: a tool-only round yields its tool call and nothing else.
109
+
110
+ - 98b2565: `resolveSettingSources` now returns `readonly GatedSettingSource[]`, and
111
+ `CompiledAgentOptions.settingSources` takes that type — so a setting root no `TrustPosture`
112
+ authorised no longer fits the field.
113
+
114
+ `define-agent.ts` claimed that field "can only ever hold roots that some posture authorized".
115
+ Measured against the emitted `.d.ts`: `setOnce(draft, 'settingSources', ['mdm','team','user','plugins'], 'cap')`
116
+ typechecked **cast-free**. Writing a `Capability` is the documented way to extend the builder, and a
117
+ capability writes the draft directly — so the gate was reachable around, for `project`, the root it
118
+ exists to protect.
119
+
120
+ A brand rather than a runtime check, because the obvious runtime check does not work: reading
121
+ `draft.provenance` to refuse a capability's write would also refuse the LEGITIMATE builder path,
122
+ which writes through `setOnce` too. What differs is where the value came from, and that is what a
123
+ brand carries.
124
+
125
+ **It refuses the accident, not the determined caller** — `as never` defeats it, like every brand.
126
+ Saying so is the point: the comment it replaces claimed an invariant nothing enforced.
127
+
128
+ **New: `settingSources.plugins`**, taking the same `ProjectSettingsGrant` as `project`.
129
+ `PluginsManager.refresh` loads executable bundles from the same cwd-controlled tree, usually
130
+ arriving with the clone, so it gets the same gate and not a weaker one. This is the root the SDK
131
+ genuinely reads and the facade withheld.
132
+
133
+ `team` and `mdm` stay absent, and that is the item's original premise dying under measurement: the
134
+ SDK never reads them — `includesSetting` is called with exactly `"project"` and `"plugins"` — so
135
+ forwarding them would be a capability in the type and nothing at runtime.
136
+
137
+ **Migration**: a consumer constructing `CompiledAgentOptions` by hand must build roots through
138
+ `resolveSettingSources` instead of a string array. That is the supported construction and always was.
139
+
140
+ **Also: a narrowed `claudeCode.import` is now REFUSED on an SDK that cannot read it.**
141
+
142
+ That field's docblock said the narrowed form was "refused at resolve time" below `@theokit/sdk`
143
+ 5.4.0. Nothing read a version for it — the only checks in this layer are the hook gate (a different
144
+ option) and a `compatSources` warning that returns silently for any major ≥ 5. So on
145
+ 5.0.0 ≤ SDK < 5.4.0, inside this package's declared `^4.52.1 || ^5.0.0`, a narrowed `import` was
146
+ forwarded, dropped by the runtime in silence, and the foreign root was **not read at all** — a
147
+ consumer asking for "the skills but not the hooks" got nothing, which is further from what they
148
+ asked for than the un-narrowed form. `compatSources` landed in 5.0.0 and the narrowing in 5.4.0;
149
+ treating the two versions as one was the defect.
150
+
151
+ `CompatImportUnsupportedError` now refuses, naming both versions and what would otherwise happen.
152
+ It refuses rather than warns because a silent nothing is discovered by wondering why a skill is
153
+ missing. An unreadable version is refused too: "cannot tell" and "is supported" must not collapse.
154
+
155
+ **And `commands` is subtracted before the compat sources reach the SDK.** The two vocabularies
156
+ diverge by one name on purpose — `.claude/commands/*.md` is read by this package and never by the
157
+ SDK — and `setting-sources-gate.ts` prescribed the subtraction as advice to consumers while the
158
+ projection that needed it did not do it. Measured: `import: ['commands']` forwarded a list
159
+ containing zero names the SDK defines, which is its own empty-list case — the exact ambiguity
160
+ `resolveCompatSources` refuses one layer up. A source whose surfaces all belong to this layer
161
+ is now dropped from the SDK's list rather than sent empty.
162
+
163
+ `CompatImportUnsupportedError` is exported from `@theokit/agents/bridge`, so a consumer can catch the
164
+ refusal by class rather than by matching its message.
165
+
166
+ - 2bc5d84: `delegate()` now applies the guardrails its spec declares. It accepted them and never consulted them.
167
+
168
+ Measured against the built artifact with a guard declaring both halves: the input reached the model
169
+ with its injection intact and the caller received `sk-abc123`. `bridge/agent-orchestrator.ts`
170
+ contained **zero** occurrences of `guardrail` — control on the same sweep: `loop/agent-runner.ts`
171
+ has 9 — and called `runReflectiveLoop` bare. The operator had declared guardrails and the run was
172
+ green.
173
+
174
+ This is the same defect class as the streamed-redaction fix in this release, on a sibling public
175
+ API, and it is model-reachable: `tools/delegate-tool.ts` wraps `delegate()`, so an agent can invoke
176
+ a sub-agent whose declared guards do nothing.
177
+
178
+ `checkInput` runs after `onDelegationStart` and `checkOutput` after `onDelegationComplete` — each
179
+ moderating what actually crosses the boundary rather than a string a hook may then rewrite. A
180
+ blocking input guard throws before the model is called at all, so a refused delegation costs
181
+ nothing.
182
+
183
+ `response` only. `toolCalls[].output` is tool output rather than model text, and `agent-runner.ts`
184
+ excludes it from `extractText` on the same reasoning; the docblock says so, because leaving it alone
185
+ should be a decision somebody reads rather than an omission somebody discovers.
186
+
187
+ A spec declaring no guardrails behaves byte-identically.
188
+
189
+ **A guardrail block now crosses the delegate tool as a refusal, not a crash.** `errorCodeOf` mapped
190
+ only the three delegation errors, so `GuardrailViolationError` — reachable from `delegate()` for the
191
+ first time because of this change — hit the "not a delegation outcome, a defect" arm and was
192
+ rethrown, ending the parent's turn. The tool's own description, shipped to the model, promises
193
+ `{ ok: false, error, message }` on a refusal.
194
+
195
+ It crosses as `guardrail_violation` with a FIXED message: `the delegated task was refused by a
196
+ policy guard`. The message travels only for codes on an explicit ALLOWLIST — a budget or a timeout
197
+ is a fact about the work, and the number in it is what the model acts on. A code nobody lists
198
+ withholds, so forgetting is safe in the direction that matters. A guardrail message is not — it reads
199
+ `Guardrail "pii-detector" blocked output: ssn found`, naming the guard and its exact trigger, and a
200
+ model given that learns which words to avoid rather than that it should stop. The operator keeps the
201
+ full typed error, which carries `guardName`, `phase` and `reason`.
202
+
203
+ - 019f828: Output guards now moderate `thinking` events, not only `text_delta`.
204
+
205
+ `AgentRunner` handed `moderateOutputStream` an extractor matching `text_delta` and nothing else,
206
+ while `thinking` is a public `AgentStreamEvent` that reaches the client like any other. Measured: a
207
+ guard declared over the agent's output delivered `thinking "the key is sk-abc123"` verbatim.
208
+
209
+ **This closes one channel and does not close all of them.** `DoneEvent.result` carries the model's
210
+ whole answer and is still unmoderated — measured on the same turn, `text_delta` came out
211
+ `"here: [R]"` while `done.result` came out `"here: sk-abc123"`. `task_progress.text` is a fourth and
212
+ reaches the web wire. A third pass does not extend to them: there is one `done` per round, so a pass
213
+ keyed on it would collapse every round's into one, and they need a different mechanism. Tracked
214
+ separately; stated here because a security note that overstates its coverage is worse than one that
215
+ does not exist.
216
+
217
+ The third channel of a shape fixed twice already in this release — a streamed redaction that was
218
+ computed and discarded, and `delegate()` consulting no guards at all.
219
+
220
+ **Two passes, not one wider extractor.** Widening `extractText` to match both kinds is the obvious
221
+ move and the wrong one: two kinds under one extractor COLLAPSE into a single event, so the reasoning
222
+ would be promoted into a visible one — the moderation creating the disclosure it exists to close.
223
+ Composing two passes is what `moderateOutputStream`'s own docblock prescribes, and each pass seeing
224
+ one kind is what keeps them apart.
225
+
226
+ The visible pass owns the aggregate: `DelegationResult.response` accumulates from `text_delta`
227
+ upstream, so the reasoning pass passes the result through rather than replacing it.
228
+
229
+ A blocking guard on either channel still throws before any event is emitted. An agent with no output
230
+ guard is byte-identical.
231
+
232
+ - 0731584: `resolveCompatSources` now returns `readonly GatedCompatSource[]`, and
233
+ `CompiledAgentOptions.compatSources` takes that type — so a compat source no `TrustPosture`
234
+ authorised no longer fits the field.
235
+
236
+ **BREAKING for hand-built compiled options**, exactly as its sibling was. Build compat sources
237
+ through `resolveCompatSources`.
238
+
239
+ The twin of the `settingSources` brand, and it exists because that fix closed one of the two fields
240
+ one `SettingSourcesSelection` feeds and left the other bare. Measured, with the `settingSources`
241
+ route as the control: the control errored, and `setOnce(draft, 'compatSources', ['claude-code'],
242
+ 'cap')` compiled cast-free — while `agent-compiler.ts` told the reader that field "can only hold a
243
+ source some posture granted".
244
+
245
+ It carries more authority than its twin, not less. `applyLocalSources` forwards it to
246
+ `Agent.create({ local: { compatSources } })`, which reads `<cwd>/.claude/` — `hooks.json` included,
247
+ and that executes shell.
248
+
249
+ **Signature narrowing**: `moderateOutputStream`'s `rebuildText` is now
250
+ `(text: string, replaced: E) => E`. It was typed `E | undefined` for a case that cannot happen — a
251
+ stream where no event carried text returns from the absence check before the guards run, so
252
+ `rebuildText` is never reached. The branch handling that case was dead code, and three shipping
253
+ artifacts described it as live.
254
+
255
+ **Fixed**: `isPort` discriminated on the presence of `run`, so an object carrying both `compiled`
256
+ and a `run` — reachable through a spread, which is how targets are built in practice — took the port
257
+ branch and skipped `delegate()` entirely: no declared guardrails, no inherited parent veto, no
258
+ budget clamp. A tie now goes to the spec, because the spec branch is the guarded one.
259
+
260
+ ### Patch Changes
261
+
262
+ - bb0f451: A guardrail refusal thrown inside a round is no longer renamed into a delegation failure.
263
+
264
+ `runReflectiveLoop` wrapped any error that was not already a delegation error into
265
+ `DelegationError`, whose message reads `Delegation to agent "X" failed: ${cause.message}`. That code
266
+ is on the delegate tool's message allowlist — a delegation failure's text is a fact about the work —
267
+ so the wrapper carried the guard's own words to the model: `Guardrail "pii-detector" blocked output:
268
+ ssn found`, naming the guard and its exact trigger.
269
+
270
+ Measured through `createDelegateTool` with a consumer-supplied `streamFactory` that throws
271
+ mid-round. `streamFactory` is a public option, so this was reachable rather than theoretical.
272
+
273
+ `GuardrailViolationError` now passes through as itself, alongside the two delegation errors that
274
+ already did, so the tool classifies it `guardrail_violation` and withholds the message.
275
+
276
+ Fixed by classification rather than by suppressing text downstream: a guard refusal is not a
277
+ delegation failure, and a layer that renames an error cannot be expected to maintain a list of what
278
+ the new name must hide.
279
+
280
+ - ac29eff: A permission-gate veto now emits a debug line.
281
+
282
+ `grantGate` refused and emitted nothing — no log, no counter, no debug line. An operator could
283
+ observe the refusal only through the tool result the model received, and the two causes the veto
284
+ message distinguishes ("no standing grant matches" versus "the permission store could not be read,
285
+ so no grant applies") are indistinguishable from there.
286
+
287
+ That is pillar 3 of the wiring triad missing on a refusal seam. `bridge/approval-posture.ts`, the
288
+ sibling gate, already logged through this exact seam.
289
+
290
+ The QUERY is logged — tool, scope, and which of the two causes fired — and the grant is not: a query
291
+ names what an operator needs to diagnose, while the store's contents are the thing being protected.
292
+ A classifier that threw logs as its own event rather than as an ordinary veto, so a consumer-code
293
+ defect is not read as a denied tool.
294
+
295
+ ## 13.0.0-next.12
296
+
297
+ ### Minor Changes
298
+
299
+ - a7e35bb: A guardrail returning `action: 'redact'` with no replacement `text` now throws
300
+ `MalformedGuardrailResultError` instead of silently redacting nothing.
301
+
302
+ `GuardrailResult.text` is optional, so such a guard compiles and reads like a working one. The
303
+ pipeline tested `r.text !== undefined` and moved on, so the caller received the original text and
304
+ believed a guard had run on it — the operator believing a protection is in place when none is.
305
+
306
+ **This is a behaviour change.** A guard relying on the previous no-op will now throw. That is
307
+ deliberate: the alternative is unredacted output reaching a model because a guard was written wrong.
308
+ `text: ''` is unaffected and always was a real redaction — a guard choosing to erase everything.
309
+
310
+ `MalformedGuardrailResultError` is exported from `@theokit/agents`, carries the guard's name and the
311
+ phase, and is not retryable.
312
+
313
+ This also reaches the streaming path (`moderateOutputStream`), which shares the same pipeline: a
314
+ malformed guard there now throws where it previously continued.
315
+
316
+ ### Patch Changes
317
+
318
+ - 2bc27d3: `inheritHooks` no longer lets a member's `transform_tool_result` or `pre_user_send` handler replace
319
+ its parent's — both now chain parent-first, matching the six events that already composed.
320
+
321
+ `inheritHooks` documents its security property as "the parent's refusal is evaluated first, and a
322
+ member can only ever ADD a reason to refuse". For these two events the plain object spread did the
323
+ opposite: a member declaring either handler silently discarded the parent's. The reachable surface is the EXPORTED `inheritHooks`, called with two handler maps.
324
+ `delegate()` passes `undefined` for the member (`agent-orchestrator.ts:175`), so that path composed
325
+ nothing and was never affected — a distinction the first version of this note got wrong.
326
+
327
+ `pre_user_send` composes additively — both contributions reach the model, parent first — because
328
+ `PreUserSendResult` carries only `recalledContext` and the seam exposes no prompt mutation.
329
+
330
+ - eaac7b0: The "will NOT fire" warning for a declared-but-unwired hook event now names where the capability
331
+ already lives, instead of ending "the handler does not exist yet".
332
+
333
+ Two of the three unwired events are served today by purpose-built seams — `Guardrail.checkOutput`
334
+ for `transform_llm_output`, and `createToolHooksPlugin({ processInput })` for `pre_user_send` — so
335
+ the old message told consumers to wait for work that will not come. The third, `on_session_end`, is
336
+ named as genuinely uncovered, with the reason: its handler returns `void` and cannot refuse an
337
+ ending, so wiring it would produce a hook that runs and cannot decide.
338
+
339
+ Nothing is wired. `HOOK_EVENTS`, `WIRED_EVENTS` and `OBSERVATIONAL_EVENTS` keep the same members.
340
+
341
+ - ec899f4: An observational hook handler is now assigned to its own key rather than chosen by comparison.
342
+
343
+ The dispatch loop used a two-branch conditional over a list of two event names, so a third
344
+ observational event would have landed on `post_assistant_reply` — silently, with no test objecting.
345
+ No behaviour changes for the events wired today; the fix removes the trap for the next one added.
346
+
3
347
  ## 13.0.0-next.11
4
348
 
5
349
  ### Patch Changes
@@ -1,7 +1,7 @@
1
- import { InlineSkill, SystemPromptResolver, SettingSource, MemorySettings, SkillsSettings, ContextSettings } from '@theokit/sdk';
1
+ import { InlineSkill, SystemPromptResolver, MemorySettings, SkillsSettings, ContextSettings } from '@theokit/sdk';
2
2
  import { TheokitAgentError } from '@theokit/sdk/errors';
3
3
  import { R as ReasoningEffort, a as MemoryOptions, P as ProjectContextOptions, M as McpServersMap, H as HumanInTheLoopOptions, C as CheckpointOptions, T as ToolOptions, A as ApprovalOptions, B as BudgetOptions } from './types-C16Wuh9E.js';
4
- import { R as ResolvedCompatSource } from './setting-sources-gate-D2NS1GsB.js';
4
+ import { G as GatedSettingSource, a as GatedCompatSource } from './setting-sources-gate-DFu51i50.js';
5
5
 
6
6
  /**
7
7
  * M9 (theokit-ai-first) — guardrail contract + typed errors.
@@ -36,15 +36,36 @@ interface Guardrail {
36
36
  }
37
37
  /** Which boundary phase a violation happened in. */
38
38
  type GuardrailPhase = 'input' | 'output';
39
- /** Thrown (fail-fast) when a guard returns `action: 'block'`. Typed per error-handling.md. */
40
39
  /**
41
- * M80 — extends {@link TheokitAgentError}, not plain `Error`.
40
+ * A guard declared `redact` and supplied no replacement text.
42
41
  *
43
- * `isTransientError` is defined over `TheokitAgentError`, so a class outside that hierarchy is
44
- * INVISIBLE to it and the only recourse left to a consumer is matching on message text — a regex
45
- * over an eight-level `cause` chain, which is what one actually wrote. `code` is stable across a
46
- * rename of the class; `isRetryable` is DECLARED rather than defaulted, because a default would be a
47
- * retry policy nobody chose.
42
+ * Distinct from {@link GuardrailViolationError} on purpose: that one says the guard REFUSED
43
+ * something, which is a decision working as designed. This says the guard is MALFORMED — it asked
44
+ * for a redaction and gave nothing to redact with, so nothing was redacted.
45
+ *
46
+ * Until B-008 this condition was silent: `pipeline.ts` tested `r.text !== undefined` and moved on, so
47
+ * the caller received the original text and believed a guard had run on it. That is the shape this
48
+ * package's hook engine calls "worse than no hook at all" — a belief in a protection that is not
49
+ * there. Throwing is fail-fast per `rules/error-handling.md § 2`, and the alternative was leaving
50
+ * unredacted output to reach a model because a guard was written wrong.
51
+ *
52
+ * `''` is NOT this case. An empty replacement is a guard choosing to erase everything, which is the
53
+ * strongest redaction available, and treating it as absent would invert the defect.
54
+ */
55
+ declare class MalformedGuardrailResultError extends TheokitAgentError {
56
+ readonly guardName: string;
57
+ readonly phase: GuardrailPhase;
58
+ readonly name = "MalformedGuardrailResultError";
59
+ constructor(guardName: string, phase: GuardrailPhase);
60
+ }
61
+ /**
62
+ * Thrown (fail-fast) when a guard returns `action: 'block'`. Typed per error-handling.md.
63
+ *
64
+ * M80 — extends {@link TheokitAgentError}, not plain `Error`. `isTransientError` is defined over
65
+ * `TheokitAgentError`, so a class outside that hierarchy is INVISIBLE to it and the only recourse
66
+ * left to a consumer is matching on message text — a regex over an eight-level `cause` chain, which
67
+ * is what one actually wrote. `code` is stable across a rename of the class; `isRetryable` is
68
+ * DECLARED rather than defaulted, because a default would be a retry policy nobody chose.
48
69
  */
49
70
  declare class GuardrailViolationError extends TheokitAgentError {
50
71
  readonly guardName: string;
@@ -149,6 +170,33 @@ declare class HookGateUnsupportedError extends TheokitAgentError {
149
170
  readonly name = "HookGateUnsupportedError";
150
171
  constructor(version: string | undefined);
151
172
  }
173
+ /**
174
+ * A narrowed `import` on an SDK that cannot read it.
175
+ *
176
+ * Refuses rather than warns, and the asymmetry with {@link warnIfSdkCannotReadCompatSources} is
177
+ * deliberate: an unrecognised `compatSources` shape means the foreign root is not read, so a
178
+ * consumer who asked for "the skills but not the hooks" silently gets NOTHING — strictly further
179
+ * from what they asked for than the un-narrowed form, which at least reads something. Failing loud
180
+ * is recoverable; a silent nothing is discovered by wondering why a skill is missing.
181
+ *
182
+ * ## The cost, which this file argues against two functions above
183
+ *
184
+ * An unreadable version is refused too, on the principle {@link assertSdkCanGateHooks} states:
185
+ * "cannot tell" and "is supported" must not collapse. The sibling WARNING takes the opposite view
186
+ * for itself — *"a bundled or vendored SDK may not resolve that subpath, and refusing to create an
187
+ * agent over a diagnostic would be the cure being worse than the disease."*
188
+ *
189
+ * Both are right for what they guard, and the difference is what the check IS. A diagnostic that
190
+ * cannot read a version should stay quiet; a GATE that cannot read one has not established the
191
+ * thing it exists to establish. But the cost is real and belongs here rather than in a reviewer's
192
+ * report: a consumer who bundles the SDK so `@theokit/sdk/package.json` does not resolve cannot
193
+ * create an agent with a narrowed `import`, even on 5.4.0+. Their exit is to omit `import` and read
194
+ * the whole root.
195
+ */
196
+ declare class CompatImportUnsupportedError extends TheokitAgentError {
197
+ readonly name = "CompatImportUnsupportedError";
198
+ constructor(version: string | undefined);
199
+ }
152
200
 
153
201
  /**
154
202
  * Agent compiler — transforms decorator metadata into SDK calls.
@@ -239,14 +287,14 @@ interface CompiledAgentOptions {
239
287
  * Projected into `Agent.create({ local: { settingSources } })` by `assembleM8CreateOptions`
240
288
  * (merged with `cwd`, decoupled from inline skills). Absent ⇒ inline (code) config only.
241
289
  */
242
- settingSources?: readonly SettingSource[];
290
+ settingSources?: readonly GatedSettingSource[];
243
291
  /**
244
292
  * Foreign configuration dialects, already authorised (usetheokit/theokit#634).
245
293
  *
246
294
  * Resolved at compile time by `resolveCompatSources`, exactly like `settingSources`: a value here
247
295
  * can only hold a source some posture granted, so the adapter projects rather than decides.
248
296
  */
249
- compatSources?: readonly ResolvedCompatSource[];
297
+ compatSources?: readonly GatedCompatSource[];
250
298
  /**
251
299
  * #686 — the consumer's pre-spawn approval gate, forwarded to `Agent.create({ local: { hooks } })`.
252
300
  *
@@ -299,4 +347,4 @@ interface CompiledAgentOptions {
299
347
  skillsResolver?: SkillsSelection;
300
348
  }
301
349
 
302
- export { type CompiledAgentOptions as C, type Guardrail as G, type HookApprovalGate as H, type SkillsSelection as S, type ToolWalkResult as T, type CompiledTool as a, CostBudgetExceededError as b, type GuardrailAction as c, type GuardrailPhase as d, type GuardrailResult as e, GuardrailViolationError as f, type HookApprovalRequest as g, HookGateUnsupportedError as h, type SkillsRequestContext as i, type ToolboxWalkResult as j, compileTools as k, resolveEnabledSkills as r };
350
+ export { type CompiledAgentOptions as C, type Guardrail as G, type HookApprovalGate as H, MalformedGuardrailResultError as M, type SkillsSelection as S, type ToolWalkResult as T, type CompiledTool as a, CompatImportUnsupportedError as b, CostBudgetExceededError as c, type GuardrailAction as d, type GuardrailPhase as e, type GuardrailResult as f, GuardrailViolationError as g, type HookApprovalRequest as h, HookGateUnsupportedError as i, type SkillsRequestContext as j, type ToolboxWalkResult as k, compileTools as l, resolveEnabledSkills as r };
package/dist/auth.d.ts CHANGED
@@ -443,4 +443,89 @@ declare class PermissionStore {
443
443
  expired(): readonly Grant[];
444
444
  }
445
445
 
446
- export { type AgentCredentialInput, type AuthMethod, AuthProvider, CODEX_CLIENT_ID_ENV_VAR, CODEX_PROVIDER, CredentialNotFoundError, type ProviderDescriptor as CredentialProviderDescriptor, type CredentialResolution, type CredentialSourcesInput, DEFAULT_PROVIDERS, DeclaredProviderError, type DeviceAuthProvider, type Grant, type GrantOptions, type PermissionQuery, PermissionStore, type PermissionStoreOptions, type PromptHooks, ProviderKeyMismatchError, ProviderPrefixMismatchError, type ResolveCredentialInput, type SourceOrigin, credentialSources, loginWithDevice, requireCredential, resolveAgentCredential, resolveCredential };
446
+ /**
447
+ * Minimal shape mirrored from `@theokit/sdk`.
448
+ *
449
+ * The reason used to read "type-only, no runtime import, so the SDK peer stays optional", and that
450
+ * does not justify mirroring anything: `bridge/hook-handlers.ts` imports these very two SDK types
451
+ * (`PreToolCallContext`, `PreToolCallDecision`) with `import type` and costs the optional peer
452
+ * nothing. A type-only import IS type-only.
453
+ *
454
+ * The real reason is the NAME, and it is written out three paragraphs down: `@theokit/sdk` already
455
+ * exports `PermissionGateContext` with a different shape, so a consumer importing both would get a
456
+ * duplicate identifier or silently the wrong one. What is mirrored here is also a deliberate
457
+ * SUBSET — the fields this gate reads — rather than a copy kept in step with the SDK's.
458
+ *
459
+ * A mirror can drift into a consumer-side compile error that no gate here would see first, so the
460
+ * assignment this docblock instructs a consumer to make is pinned by
461
+ * `tests/type/the-grant-gate-fits-the-hook-it-is-for.test-d.ts`.
462
+ *
463
+ * NOT named `PermissionGateContext`: `@theokit/sdk` already exports a type by that name, with a
464
+ * different shape (`{ toolName, mode }`), alongside `PermissionGate`, `PermissionGateDecision`,
465
+ * `PermissionEngine` and `PermissionPlugin`. A consumer importing both would get a duplicate
466
+ * identifier, or silently the wrong shape.
467
+ *
468
+ * The rename says something true rather than merely avoiding a clash. This gates on a STANDING
469
+ * GRANT the operator made; the SDK's permission engine is a separate system with its own rules, and
470
+ * the two do not consult each other — an engine `allow` does not satisfy a missing grant, and a
471
+ * grant does not satisfy the engine.
472
+ */
473
+ interface GrantGateContext {
474
+ readonly name: string;
475
+ readonly args: Record<string, unknown>;
476
+ readonly agentId: string;
477
+ readonly runId: string;
478
+ /**
479
+ * The run's resolved permission mode, carried by the real context. Mirrored so the shapes match
480
+ * and so its absence from the gate's logic is a decision rather than an oversight: `bypass` does
481
+ * NOT disable this gate, because a standing grant is the operator's, not the run's.
482
+ */
483
+ readonly permissionMode?: string;
484
+ }
485
+ /** This tool is deliberately outside the gate's remit — distinct from "I forgot to map it". */
486
+ interface NotGoverned {
487
+ readonly governed: false;
488
+ }
489
+ /** This tool IS governed, by the grant key inside. */
490
+ interface Governed {
491
+ readonly governed: true;
492
+ readonly query: PermissionQuery;
493
+ }
494
+ /**
495
+ * BOTH arms carry `governed`, and that is the whole point.
496
+ *
497
+ * The first version was `PermissionQuery | NotGoverned` — discriminated by whether the `governed`
498
+ * KEY was present. It failed open, measured against the built artifact: a consumer who writes a
499
+ * policy record `{ governed: true, scope }` and spreads it into the query — the most natural
500
+ * reading of "yes, govern this" — got `undefined` back, and an ungranted destructive command ran.
501
+ * `governed: undefined` did the same. TypeScript does not stop it: a fresh literal with an excess
502
+ * property errors, the same object through a variable or a spread does not.
503
+ *
504
+ * The presence check arrived by obeying a linter. `'governed' in c && c.governed === false` was
505
+ * flagged as "always true given the type", which was true of the DECLARED type and false of every
506
+ * value that reaches it. A type-based lint rule cannot see excess properties, and the safety of a
507
+ * gate is not a type-level fact.
508
+ *
509
+ * With the discriminant on both arms there is no key to be accidentally present: `governed` is
510
+ * declared either way, the check is on its VALUE, and `governed: true` without a `query` is a
511
+ * compile error rather than a silent pass.
512
+ */
513
+ type Classification = Governed | NotGoverned;
514
+ /**
515
+ * A veto, in the shape `pre_tool_call` returns.
516
+ *
517
+ * Exported because it appears in {@link grantGate}'s public signature — an unexported type there
518
+ * leaves a consumer unable to name the return value. `hooks/secure-store.ts` documents the same
519
+ * case.
520
+ */
521
+ interface Veto {
522
+ readonly block: true;
523
+ readonly message: string;
524
+ }
525
+ /**
526
+ * @param store the grants to consult; re-read per call, so a grant made mid-run is seen
527
+ * @param classify maps a tool call to the grant key it needs, or declares it ungoverned
528
+ */
529
+ declare function grantGate(store: PermissionStore, classify: (ctx: GrantGateContext) => Classification): (ctx: GrantGateContext) => Promise<Veto | undefined>;
530
+
531
+ export { type AgentCredentialInput, type AuthMethod, AuthProvider, CODEX_CLIENT_ID_ENV_VAR, CODEX_PROVIDER, type Classification, CredentialNotFoundError, type ProviderDescriptor as CredentialProviderDescriptor, type CredentialResolution, type CredentialSourcesInput, DEFAULT_PROVIDERS, DeclaredProviderError, type DeviceAuthProvider, type Governed, type Grant, type GrantGateContext, type GrantOptions, type NotGoverned, type PermissionQuery, PermissionStore, type PermissionStoreOptions, type PromptHooks, ProviderKeyMismatchError, ProviderPrefixMismatchError, type ResolveCredentialInput, type SourceOrigin, type Veto, credentialSources, grantGate, loginWithDevice, requireCredential, resolveAgentCredential, resolveCredential };
package/dist/auth.js CHANGED
@@ -2,6 +2,9 @@ import {
2
2
  readSecureJson,
3
3
  writeSecureJson
4
4
  } from "./chunk-D2EFYZBV.js";
5
+ import {
6
+ debugLog
7
+ } from "./chunk-M5J3Q6YC.js";
5
8
  import {
6
9
  __name
7
10
  } from "./chunk-Z4QWC7IK.js";
@@ -572,6 +575,38 @@ var PermissionStore = class {
572
575
  `);
573
576
  }
574
577
  };
578
+
579
+ // src/auth/permission-gate.ts
580
+ function grantGate(store, classify) {
581
+ return (ctx) => {
582
+ try {
583
+ const classification = classify(ctx);
584
+ if (classification.governed === false) return Promise.resolve(void 0);
585
+ if (store.isGranted(classification.query)) return Promise.resolve(void 0);
586
+ const because = store.lastReadError === void 0 ? "no standing grant matches" : "the permission store could not be read, so no grant applies";
587
+ debugLog("[theokit] permission gate vetoed", {
588
+ tool: ctx.name,
589
+ scope: classification.query.scope,
590
+ because
591
+ });
592
+ return Promise.resolve({
593
+ block: true,
594
+ message: `"${ctx.name}" is not permitted in ${classification.query.scope}: ${because}`
595
+ });
596
+ } catch (cause) {
597
+ const why = cause instanceof Error ? cause.message : String(cause);
598
+ debugLog("[theokit] permission gate could not classify", {
599
+ tool: ctx.name,
600
+ why
601
+ });
602
+ return Promise.resolve({
603
+ block: true,
604
+ message: `permission gate could not classify "${ctx.name}": ${why}`
605
+ });
606
+ }
607
+ };
608
+ }
609
+ __name(grantGate, "grantGate");
575
610
  export {
576
611
  AuthProvider,
577
612
  CODEX_CLIENT_ID_ENV_VAR,
@@ -590,6 +625,7 @@ export {
590
625
  deviceLogin,
591
626
  ensureFreshCredential2 as ensureFreshCredential,
592
627
  extractAccountId,
628
+ grantGate,
593
629
  loginWithDevice,
594
630
  openaiDeviceLogin3 as openaiDeviceLogin,
595
631
  persistOAuthTokens2 as persistOAuthTokens,