@namzu/sdk 44.3.0 → 45.1.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.
Files changed (271) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/authorization/command-line.d.ts +66 -19
  3. package/dist/authorization/command-line.d.ts.map +1 -1
  4. package/dist/authorization/command-line.js +130 -270
  5. package/dist/authorization/command-line.js.map +1 -1
  6. package/dist/authorization/gate.d.ts +7 -0
  7. package/dist/authorization/gate.d.ts.map +1 -1
  8. package/dist/authorization/gate.js +13 -3
  9. package/dist/authorization/gate.js.map +1 -1
  10. package/dist/authorization/rules.d.ts +10 -1
  11. package/dist/authorization/rules.d.ts.map +1 -1
  12. package/dist/authorization/rules.js +55 -8
  13. package/dist/authorization/rules.js.map +1 -1
  14. package/dist/authorization/shell-lexer.d.ts +152 -0
  15. package/dist/authorization/shell-lexer.d.ts.map +1 -0
  16. package/dist/authorization/shell-lexer.js +2156 -0
  17. package/dist/authorization/shell-lexer.js.map +1 -0
  18. package/dist/authorization/skill-grant.d.ts +182 -0
  19. package/dist/authorization/skill-grant.d.ts.map +1 -0
  20. package/dist/authorization/skill-grant.js +314 -0
  21. package/dist/authorization/skill-grant.js.map +1 -0
  22. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  23. package/dist/bridge/a2a/mapper.js +2 -0
  24. package/dist/bridge/a2a/mapper.js.map +1 -1
  25. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  26. package/dist/bridge/sse/mapper.js +1 -0
  27. package/dist/bridge/sse/mapper.js.map +1 -1
  28. package/dist/directory/types.d.ts +2 -0
  29. package/dist/directory/types.d.ts.map +1 -1
  30. package/dist/directory/types.js.map +1 -1
  31. package/dist/manager/resident/outbox.d.ts +4 -4
  32. package/dist/persona/assembler.d.ts.map +1 -1
  33. package/dist/persona/assembler.js +5 -2
  34. package/dist/persona/assembler.js.map +1 -1
  35. package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
  36. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  37. package/dist/prompt/coding-agent-doctrine.js +1 -0
  38. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  39. package/dist/public-runtime.d.ts +5 -1
  40. package/dist/public-runtime.d.ts.map +1 -1
  41. package/dist/public-runtime.js +15 -1
  42. package/dist/public-runtime.js.map +1 -1
  43. package/dist/public-tools.d.ts +4 -0
  44. package/dist/public-tools.d.ts.map +1 -1
  45. package/dist/public-tools.js +12 -1
  46. package/dist/public-tools.js.map +1 -1
  47. package/dist/public-types.d.ts +9 -1
  48. package/dist/public-types.d.ts.map +1 -1
  49. package/dist/runtime/jobs/registry.d.ts +2 -2
  50. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  51. package/dist/runtime/jobs/registry.js +6 -2
  52. package/dist/runtime/jobs/registry.js.map +1 -1
  53. package/dist/runtime/query/declined.d.ts +12 -0
  54. package/dist/runtime/query/declined.d.ts.map +1 -0
  55. package/dist/runtime/query/declined.js +12 -0
  56. package/dist/runtime/query/declined.js.map +1 -0
  57. package/dist/runtime/query/executor.d.ts +36 -40
  58. package/dist/runtime/query/executor.d.ts.map +1 -1
  59. package/dist/runtime/query/executor.js +95 -53
  60. package/dist/runtime/query/executor.js.map +1 -1
  61. package/dist/runtime/query/index.d.ts.map +1 -1
  62. package/dist/runtime/query/index.js +8 -0
  63. package/dist/runtime/query/index.js.map +1 -1
  64. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  65. package/dist/runtime/query/iteration/index.js +13 -0
  66. package/dist/runtime/query/iteration/index.js.map +1 -1
  67. package/dist/runtime/query/iteration/phases/context.d.ts +13 -0
  68. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  69. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  70. package/dist/runtime/query/iteration/phases/handoff.d.ts +22 -0
  71. package/dist/runtime/query/iteration/phases/handoff.d.ts.map +1 -0
  72. package/dist/runtime/query/iteration/phases/handoff.js +65 -0
  73. package/dist/runtime/query/iteration/phases/handoff.js.map +1 -0
  74. package/dist/runtime/query/iteration/phases/index.d.ts +1 -0
  75. package/dist/runtime/query/iteration/phases/index.d.ts.map +1 -1
  76. package/dist/runtime/query/iteration/phases/index.js +1 -0
  77. package/dist/runtime/query/iteration/phases/index.js.map +1 -1
  78. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  79. package/dist/runtime/query/iteration/phases/tool-review.js +56 -3
  80. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  81. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  82. package/dist/runtime/query/resume-pending.js +3 -2
  83. package/dist/runtime/query/resume-pending.js.map +1 -1
  84. package/dist/runtime/query/review-policy.d.ts +11 -0
  85. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  86. package/dist/runtime/query/review-policy.js +36 -3
  87. package/dist/runtime/query/review-policy.js.map +1 -1
  88. package/dist/runtime/query/tooling.d.ts +3 -0
  89. package/dist/runtime/query/tooling.d.ts.map +1 -1
  90. package/dist/runtime/query/tooling.js +1 -0
  91. package/dist/runtime/query/tooling.js.map +1 -1
  92. package/dist/schedules/cron.d.ts +21 -0
  93. package/dist/schedules/cron.d.ts.map +1 -0
  94. package/dist/schedules/cron.js +167 -0
  95. package/dist/schedules/cron.js.map +1 -0
  96. package/dist/schedules/describe.d.ts +14 -0
  97. package/dist/schedules/describe.d.ts.map +1 -0
  98. package/dist/schedules/describe.js +133 -0
  99. package/dist/schedules/describe.js.map +1 -0
  100. package/dist/schedules/errors.d.ts +11 -0
  101. package/dist/schedules/errors.d.ts.map +1 -0
  102. package/dist/schedules/errors.js +15 -0
  103. package/dist/schedules/errors.js.map +1 -0
  104. package/dist/schedules/evaluate.d.ts +35 -0
  105. package/dist/schedules/evaluate.d.ts.map +1 -0
  106. package/dist/schedules/evaluate.js +158 -0
  107. package/dist/schedules/evaluate.js.map +1 -0
  108. package/dist/schedules/index.d.ts +11 -0
  109. package/dist/schedules/index.d.ts.map +1 -0
  110. package/dist/schedules/index.js +8 -0
  111. package/dist/schedules/index.js.map +1 -0
  112. package/dist/schedules/next-fire.d.ts +50 -0
  113. package/dist/schedules/next-fire.d.ts.map +1 -0
  114. package/dist/schedules/next-fire.js +250 -0
  115. package/dist/schedules/next-fire.js.map +1 -0
  116. package/dist/schedules/spec.d.ts +30 -0
  117. package/dist/schedules/spec.d.ts.map +1 -0
  118. package/dist/schedules/spec.js +169 -0
  119. package/dist/schedules/spec.js.map +1 -0
  120. package/dist/schedules/types.d.ts +144 -0
  121. package/dist/schedules/types.d.ts.map +1 -0
  122. package/dist/schedules/types.js +11 -0
  123. package/dist/schedules/types.js.map +1 -0
  124. package/dist/schedules/tz.d.ts +44 -0
  125. package/dist/schedules/tz.d.ts.map +1 -0
  126. package/dist/schedules/tz.js +141 -0
  127. package/dist/schedules/tz.js.map +1 -0
  128. package/dist/skills/index.d.ts +1 -1
  129. package/dist/skills/index.d.ts.map +1 -1
  130. package/dist/skills/index.js +1 -1
  131. package/dist/skills/index.js.map +1 -1
  132. package/dist/skills/loader.d.ts +21 -0
  133. package/dist/skills/loader.d.ts.map +1 -1
  134. package/dist/skills/loader.js +51 -1
  135. package/dist/skills/loader.js.map +1 -1
  136. package/dist/tools/builtins/bash.d.ts.map +1 -1
  137. package/dist/tools/builtins/bash.js +18 -6
  138. package/dist/tools/builtins/bash.js.map +1 -1
  139. package/dist/tools/builtins/browser-url.d.ts +83 -0
  140. package/dist/tools/builtins/browser-url.d.ts.map +1 -0
  141. package/dist/tools/builtins/browser-url.js +240 -0
  142. package/dist/tools/builtins/browser-url.js.map +1 -0
  143. package/dist/tools/builtins/browser.d.ts +367 -0
  144. package/dist/tools/builtins/browser.d.ts.map +1 -0
  145. package/dist/tools/builtins/browser.js +704 -0
  146. package/dist/tools/builtins/browser.js.map +1 -0
  147. package/dist/tools/builtins/skill.d.ts +2 -9
  148. package/dist/tools/builtins/skill.d.ts.map +1 -1
  149. package/dist/tools/builtins/skill.js +74 -51
  150. package/dist/tools/builtins/skill.js.map +1 -1
  151. package/dist/tools/command-shell.d.ts +90 -0
  152. package/dist/tools/command-shell.d.ts.map +1 -0
  153. package/dist/tools/command-shell.js +129 -0
  154. package/dist/tools/command-shell.js.map +1 -0
  155. package/dist/tools/defineTool.d.ts +13 -0
  156. package/dist/tools/defineTool.d.ts.map +1 -1
  157. package/dist/tools/defineTool.js +30 -1
  158. package/dist/tools/defineTool.js.map +1 -1
  159. package/dist/tools/schedules/index.d.ts +5 -0
  160. package/dist/tools/schedules/index.d.ts.map +1 -0
  161. package/dist/tools/schedules/index.js +4 -0
  162. package/dist/tools/schedules/index.js.map +1 -0
  163. package/dist/tools/schedules/loop-tool.d.ts +14 -0
  164. package/dist/tools/schedules/loop-tool.d.ts.map +1 -0
  165. package/dist/tools/schedules/loop-tool.js +81 -0
  166. package/dist/tools/schedules/loop-tool.js.map +1 -0
  167. package/dist/tools/schedules/present.d.ts +16 -0
  168. package/dist/tools/schedules/present.d.ts.map +1 -0
  169. package/dist/tools/schedules/present.js +69 -0
  170. package/dist/tools/schedules/present.js.map +1 -0
  171. package/dist/tools/schedules/prompt-scan.d.ts +17 -0
  172. package/dist/tools/schedules/prompt-scan.d.ts.map +1 -0
  173. package/dist/tools/schedules/prompt-scan.js +92 -0
  174. package/dist/tools/schedules/prompt-scan.js.map +1 -0
  175. package/dist/tools/schedules/schedule-tool.d.ts +16 -0
  176. package/dist/tools/schedules/schedule-tool.d.ts.map +1 -0
  177. package/dist/tools/schedules/schedule-tool.js +327 -0
  178. package/dist/tools/schedules/schedule-tool.js.map +1 -0
  179. package/dist/tools/schedules/types.d.ts +184 -0
  180. package/dist/tools/schedules/types.d.ts.map +1 -0
  181. package/dist/tools/schedules/types.js +11 -0
  182. package/dist/tools/schedules/types.js.map +1 -0
  183. package/dist/types/authorization/index.d.ts +86 -9
  184. package/dist/types/authorization/index.d.ts.map +1 -1
  185. package/dist/types/authorization/index.js +11 -1
  186. package/dist/types/authorization/index.js.map +1 -1
  187. package/dist/types/browser/index.d.ts +280 -0
  188. package/dist/types/browser/index.d.ts.map +1 -0
  189. package/dist/types/browser/index.js +12 -0
  190. package/dist/types/browser/index.js.map +1 -0
  191. package/dist/types/hitl/index.d.ts +23 -0
  192. package/dist/types/hitl/index.d.ts.map +1 -1
  193. package/dist/types/hitl/index.js.map +1 -1
  194. package/dist/types/session/events.d.ts +6 -0
  195. package/dist/types/session/events.d.ts.map +1 -1
  196. package/dist/types/session/events.js.map +1 -1
  197. package/dist/types/session/records.d.ts +23 -0
  198. package/dist/types/session/records.d.ts.map +1 -1
  199. package/dist/types/session/records.js +9 -0
  200. package/dist/types/session/records.js.map +1 -1
  201. package/dist/types/tool/index.d.ts +108 -5
  202. package/dist/types/tool/index.d.ts.map +1 -1
  203. package/dist/types/tool/index.js.map +1 -1
  204. package/dist/types/tool/presentation.d.ts +7 -0
  205. package/dist/types/tool/presentation.d.ts.map +1 -1
  206. package/dist/utils/frontmatter.d.ts +35 -3
  207. package/dist/utils/frontmatter.d.ts.map +1 -1
  208. package/dist/utils/frontmatter.js +45 -5
  209. package/dist/utils/frontmatter.js.map +1 -1
  210. package/dist/utils/id.d.ts +8 -0
  211. package/dist/utils/id.d.ts.map +1 -1
  212. package/dist/utils/id.js +12 -0
  213. package/dist/utils/id.js.map +1 -1
  214. package/package.json +1 -1
  215. package/src/authorization/command-line.ts +148 -293
  216. package/src/authorization/gate.ts +22 -2
  217. package/src/authorization/rules.ts +67 -8
  218. package/src/authorization/shell-lexer.ts +2349 -0
  219. package/src/authorization/skill-grant.ts +400 -0
  220. package/src/bridge/a2a/mapper.ts +2 -0
  221. package/src/bridge/sse/mapper.ts +1 -0
  222. package/src/directory/types.ts +2 -0
  223. package/src/persona/assembler.ts +5 -2
  224. package/src/prompt/coding-agent-doctrine.ts +1 -0
  225. package/src/public-runtime.ts +37 -0
  226. package/src/public-tools.ts +37 -1
  227. package/src/public-types.ts +57 -0
  228. package/src/runtime/jobs/registry.ts +19 -11
  229. package/src/runtime/query/declined.ts +12 -0
  230. package/src/runtime/query/executor.ts +116 -56
  231. package/src/runtime/query/index.ts +8 -0
  232. package/src/runtime/query/iteration/index.ts +13 -0
  233. package/src/runtime/query/iteration/phases/context.ts +13 -0
  234. package/src/runtime/query/iteration/phases/handoff.ts +74 -0
  235. package/src/runtime/query/iteration/phases/index.ts +1 -0
  236. package/src/runtime/query/iteration/phases/tool-review.ts +55 -3
  237. package/src/runtime/query/resume-pending.ts +3 -2
  238. package/src/runtime/query/review-policy.ts +57 -3
  239. package/src/runtime/query/tooling.ts +4 -0
  240. package/src/schedules/cron.ts +202 -0
  241. package/src/schedules/describe.ts +138 -0
  242. package/src/schedules/errors.ts +15 -0
  243. package/src/schedules/evaluate.ts +178 -0
  244. package/src/schedules/index.ts +18 -0
  245. package/src/schedules/next-fire.ts +257 -0
  246. package/src/schedules/spec.ts +210 -0
  247. package/src/schedules/types.ts +163 -0
  248. package/src/schedules/tz.ts +155 -0
  249. package/src/skills/index.ts +1 -1
  250. package/src/skills/loader.ts +59 -1
  251. package/src/tools/builtins/bash.ts +24 -6
  252. package/src/tools/builtins/browser-url.ts +251 -0
  253. package/src/tools/builtins/browser.ts +817 -0
  254. package/src/tools/builtins/skill.ts +92 -53
  255. package/src/tools/command-shell.ts +166 -0
  256. package/src/tools/defineTool.ts +36 -1
  257. package/src/tools/schedules/index.ts +4 -0
  258. package/src/tools/schedules/loop-tool.ts +85 -0
  259. package/src/tools/schedules/present.ts +86 -0
  260. package/src/tools/schedules/prompt-scan.ts +96 -0
  261. package/src/tools/schedules/schedule-tool.ts +376 -0
  262. package/src/tools/schedules/types.ts +197 -0
  263. package/src/types/authorization/index.ts +60 -2
  264. package/src/types/browser/index.ts +341 -0
  265. package/src/types/hitl/index.ts +21 -0
  266. package/src/types/session/events.ts +6 -0
  267. package/src/types/session/records.ts +10 -0
  268. package/src/types/tool/index.ts +109 -5
  269. package/src/types/tool/presentation.ts +7 -0
  270. package/src/utils/frontmatter.ts +81 -5
  271. package/src/utils/id.ts +14 -0
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'
2
2
 
3
3
  import { SANDBOX_KILL_GRACE_MS } from '../../constants/sandbox/index.js'
4
4
  import { killTree } from '../../process/kill-tree.js'
5
+ import { hostShellSpawn } from '../../tools/command-shell.js'
5
6
  import { scrubInheritedEnv } from '../../tools/env-scrub.js'
6
7
  import { awaitWithAbort } from '../../utils/await-with-abort.js'
7
8
 
@@ -72,8 +73,8 @@ export interface StartJobParams {
72
73
  readonly env?: Readonly<Record<string, string>>
73
74
  /**
74
75
  * Start the process yourself — a sandbox does, so the job runs inside
75
- * its boundary. Absent, the registry runs `/bin/sh -c command` on the
76
- * host. The process must be the leader of its own group and must not
76
+ * its boundary. Absent, the registry runs it on the
77
+ * host in the `bash` tool's shell (`tools/command-shell.ts`). The process must be the leader of its own group and must not
77
78
  * expect stdin.
78
79
  */
79
80
  readonly spawn?: () => JobProcess
@@ -205,18 +206,25 @@ export class BackgroundJobRegistry {
205
206
  // started it, which makes the leak longer-lived, not smaller.
206
207
  const inherited = scrubInheritedEnv()
207
208
 
209
+ // The same shell as a foreground `bash` call, so the permission
210
+ // rules' reading of the line holds for the job too.
211
+ const shell = hostShellSpawn(params.command, { ...inherited.env, ...params.env })
208
212
  const started = params.spawn
209
213
  ? params.spawn()
210
214
  : {
211
- child: spawn('/bin/sh', ['-c', params.command], {
212
- cwd: params.workingDirectory,
213
- env: { ...inherited.env, ...params.env },
214
- // Leader of its own process group, which is what `killTree` needs
215
- // to reach the command and everything it forks rather than only the
216
- // wrapping shell. See `process/kill-tree.ts`.
217
- detached: process.platform !== 'win32',
218
- stdio: ['ignore', 'pipe', 'pipe'],
219
- }),
215
+ child: spawn(
216
+ shell.file ?? '/bin/sh',
217
+ shell.file === undefined ? ['-c', params.command] : [...shell.args],
218
+ {
219
+ cwd: params.workingDirectory,
220
+ env: shell.env,
221
+ // Leader of its own process group, which is what `killTree` needs
222
+ // to reach the command and everything it forks rather than only the
223
+ // wrapping shell. See `process/kill-tree.ts`.
224
+ detached: process.platform !== 'win32',
225
+ stdio: ['ignore', 'pipe', 'pipe'],
226
+ },
227
+ ),
220
228
  }
221
229
  const child = started.child
222
230
  // Started, not adopted: this process stays the parent for the job's
@@ -0,0 +1,12 @@
1
+ /**
2
+ * What the model is told when a person declines its tool calls and gave no
3
+ * words of their own.
4
+ *
5
+ * "Declined" alone reads to a model as an obstacle to route around: in a live
6
+ * session an operator refused a browser navigation and the model fetched the
7
+ * same page through web search instead. The refusal is about the outcome, not
8
+ * the tool, so the text says so — for every tool, since any of them can be
9
+ * swapped for another that reaches the same place.
10
+ */
11
+ export const DECLINED_TOOL_CALL_FEEDBACK =
12
+ 'The user declined this. Do not get the same content or result another way (another tool, another site or address, or a web search) unless you ask the user first and they agree. Say what you wanted to do, or carry on without it.'
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import type { Span } from '@opentelemetry/api'
3
3
  import type { AuthorizationGate } from '../../authorization/gate.js'
4
+ import { type SkillGrantSet, compileSkillGrant } from '../../authorization/skill-grant.js'
4
5
  import { extractFromToolCall, extractFromToolResult } from '../../compaction/extractor.js'
5
6
  import type { WorkingStateManager } from '../../compaction/manager.js'
6
7
  import { GENAI, NAMZU } from '../../constants/telemetry/index.js'
@@ -10,7 +11,8 @@ import { ProbeVetoError } from '../../probe/errors.js'
10
11
  import { probe as defaultProbeRegistry } from '../../probe/registry.js'
11
12
  import type { ProbeEnforcement } from '../../probe/registry.js'
12
13
  import type { ActivityStore } from '../../store/activity/memory.js'
13
- import { SKILL_TOOL_NAME } from '../../tools/builtins/skill.js'
14
+ import { sandboxShellSpawn, withoutBashStartup } from '../../tools/command-shell.js'
15
+ import { isAlwaysDestructive } from '../../tools/defineTool.js'
14
16
  import { createFileReadTracker } from '../../tools/file-read-tracker.js'
15
17
  import { pathOutsideRoots, toolRoots } from '../../tools/paths.js'
16
18
  import type { ToolResultGuardrailSpec } from '../../types/guardrail/index.js'
@@ -33,9 +35,11 @@ import type {
33
35
  FileReadTracker,
34
36
  PreparedToolExecution,
35
37
  RequestToolPause,
38
+ ShellDialect,
36
39
  SkillRegistryRef,
37
40
  ToolContext,
38
41
  ToolDispatchOptions,
42
+ ToolHandoff,
39
43
  ToolRegistryContract,
40
44
  ToolResult,
41
45
  } from '../../types/tool/index.js'
@@ -449,6 +453,12 @@ export interface ToolExecutorConfig {
449
453
  authorizationGate?: AuthorizationGate
450
454
  /** Durable refusal sink paired with {@link authorizationGate}. */
451
455
  recordAudit?: (input: AuditEventInput) => Promise<unknown>
456
+ /**
457
+ * Where the `skill` tool's `allowed-tools` pre-approvals are recorded for
458
+ * this turn. The review phase reads the same set. Absent: a loaded skill
459
+ * grants nothing, and its tool says so.
460
+ */
461
+ skillGrants?: SkillGrantSet
452
462
  }
453
463
 
454
464
  /**
@@ -480,6 +490,8 @@ export interface ToolCallOutcome {
480
490
  /** Rich form for the model, when the tool supplied one. */
481
491
  content?: ToolResultContent
482
492
  isError?: boolean
493
+ /** The tool asked for a person; see `ToolResult.handoff`. */
494
+ handoff?: ToolHandoff
483
495
  }
484
496
 
485
497
  export interface ToolExecutionBatch {
@@ -692,58 +704,83 @@ export class ToolExecutor {
692
704
  private batchMode?: PermissionMode
693
705
 
694
706
  /**
695
- * The tool scope a loaded skill declared, and the batch it applies from.
696
- *
697
- * `allowed-tools` was parsed, stored and rendered into the prompt, and
698
- * read by nothing — advice phrased as a declaration. This is what makes
699
- * it a restriction, on the same line that already enforces the step's
700
- * list, because a narrowing the model can decline is not one.
701
- *
702
- * Two fields rather than one, and the second is the point: a skill
703
- * loaded MID-batch must not retroactively refuse the calls the model
704
- * issued alongside it. The model chose that batch under the old scope,
705
- * and refusing half of it teaches nothing except that tools fail at
706
- * random. `adoptedInBatch` is compared against the batch counter, so the
707
- * scope takes effect from the next one.
707
+ * The step's list where it has one; the turn's is the default.
708
708
  *
709
- * **`adoptedInBatch` is redundant TODAY and kept deliberately**, the same
710
- * bargain `batchMode` above documents. `buildToolContext()` runs once per
711
- * batch, so every call in a batch already shares one `allowedTools` array
712
- * computed before any of them could adopt anything — remove this
713
- * comparison and no test changes, because the guarantee currently comes
714
- * from where the context happens to be built rather than from here.
715
- * Moving the context into the per-call spread is a plausible refactor,
716
- * and it would silently produce a batch whose second half is refused for
717
- * a scope its first half installed. That is precisely the incoherent
718
- * batch this line exists to make impossible.
709
+ * A loaded skill no longer narrows this. Its `allowed-tools` used to be
710
+ * intersected in here from the next batch on, which read the field as a
711
+ * restriction when it is a pre-approval; see `grantSkillTools`.
719
712
  */
720
- private skillScope?: {
721
- skill: string
722
- allowedTools: readonly string[]
723
- adoptedInBatch: number
713
+ private effectiveAllowedTools(): readonly string[] | undefined {
714
+ return this.stepAllowedTools ?? this.config.allowedTools
724
715
  }
725
- private batchCounter = 0
726
716
 
727
717
  /**
728
- * The step's list, narrowed by any skill scope in force.
718
+ * Compile a skill's `allowed-tools` into pre-approvals for the rest of the
719
+ * turn, say what they would be, and record them only on `commit()`.
729
720
  *
730
- * An INTERSECTION, never a replacement: a skill cannot hand the model a
731
- * tool the step withheld. Widening has to be unexpressible rather than
732
- * discouraged — the same rule `CreateTaskOptions.toolScope` states for
733
- * delegation, and for the same reason: a skill file is content, and
734
- * content that can grant tools is a privilege-escalation surface wearing
735
- * the word "scope".
721
+ * Names resolve against THIS turn's registry, case-insensitively and
722
+ * through the Agent Skills aliases (`Read` is `read`, `WebFetch` is
723
+ * `web_fetch`), so a grant can only ever name a tool the turn already has.
724
+ * An entry that resolves to nothing, or to a tool every call of which is
725
+ * destructive (and therefore always reviewed), is reported and grants
726
+ * nothing.
736
727
  *
737
- * The `skill` tool itself always survives. A skill that narrowed the
738
- * model out of reaching for another skill would be a one-way door, and
739
- * the tool reads instructions and changes nothing.
728
+ * Two steps because the `skill` tool can still fail after it knows what
729
+ * to say — its instructions may not fit the output budget — and a skill
730
+ * the model never received must not have approved anything.
740
731
  */
741
- private effectiveAllowedTools(): readonly string[] | undefined {
742
- const base = this.stepAllowedTools ?? this.config.allowedTools
743
- const scope = this.skillScope
744
- if (!scope || scope.adoptedInBatch >= this.batchCounter) return base
745
- const narrowed = new Set([...scope.allowedTools, SKILL_TOOL_NAME])
746
- return base === undefined ? [...narrowed] : base.filter((name) => narrowed.has(name))
732
+ private grantSkillTools(grant: {
733
+ readonly skill: string
734
+ readonly allowedTools: readonly string[]
735
+ readonly skillDirectory?: string
736
+ }): {
737
+ readonly granted: readonly string[]
738
+ readonly ignored: readonly {
739
+ readonly entry: string
740
+ readonly reason: string
741
+ }[]
742
+ readonly commit: () => void
743
+ } {
744
+ const grants = this.config.skillGrants
745
+ if (!grants) return { granted: [], ignored: [], commit: () => {} }
746
+ const tools = this.config.tools
747
+ const byLowerName = new Map<string, string>()
748
+ for (const name of tools.listNames()) byLowerName.set(name.toLowerCase(), name)
749
+ // A grant can only ever name a tool this turn — or this step — can call.
750
+ // The registry holds more than that when `allowedTools` withholds some,
751
+ // and telling the model a withheld tool is pre-approved is a promise the
752
+ // executor will refuse to keep.
753
+ const allowed = this.effectiveAllowedTools()
754
+ const compiled = compileSkillGrant(grant.allowedTools, {
755
+ resolveTool: (name) => {
756
+ const registered = byLowerName.get(name.toLowerCase())
757
+ if (registered === undefined) return undefined
758
+ const definition = tools.get(registered)
759
+ const commandArgument = definition?.commandArgument
760
+ return {
761
+ name: registered,
762
+ ...(allowed !== undefined && !allowed.includes(registered) ? { unavailable: true } : {}),
763
+ ...(commandArgument === undefined ? {} : { commandArgument }),
764
+ ...(definition && isAlwaysDestructive(definition) ? { alwaysDestructive: true } : {}),
765
+ }
766
+ },
767
+ ...(grant.skillDirectory ? { skillDirectory: grant.skillDirectory } : {}),
768
+ })
769
+ return {
770
+ granted: compiled.entries.map((entry) =>
771
+ entry.pattern === undefined ? entry.tool : entry.declared,
772
+ ),
773
+ ignored: compiled.ignored,
774
+ commit: () => {
775
+ grants.grant(grant.skill, compiled)
776
+ if (compiled.ignored.length > 0) {
777
+ this.log.warn('Skill allowed-tools entries were ignored', {
778
+ 'namzu.skill.name': grant.skill,
779
+ 'namzu.skill.ignored': compiled.ignored.map((item) => item.entry),
780
+ })
781
+ }
782
+ },
783
+ }
747
784
  }
748
785
 
749
786
  private resolvePermissionMode(): PermissionMode {
@@ -757,9 +794,20 @@ export class ToolExecutor {
757
794
  toolName,
758
795
  toolInput: input,
759
796
  toolDef: this.config.tools.get(toolName),
797
+ commandDialect: this.commandDialect(toolName),
760
798
  })
761
799
  }
762
800
 
801
+ /**
802
+ * The shell a tool's command line will run in this turn, for the
803
+ * permission rules to read it in. A tool that does not say is `sh`, the
804
+ * reading that holds for any POSIX shell.
805
+ */
806
+ commandDialect(toolName: string): ShellDialect {
807
+ const tool = this.config.tools.get(toolName)
808
+ return tool?.commandDialect?.({ sandboxed: this.config.sandbox !== undefined }) ?? 'sh'
809
+ }
810
+
763
811
  /**
764
812
  * Resolve repairs and pre-tool hooks, then decode each call exactly once.
765
813
  * The returned projection is what policy and a human review; execution later
@@ -883,8 +931,6 @@ export class ToolExecutor {
883
931
  }
884
932
  assertUniqueToolCallIds(toolCalls)
885
933
 
886
- this.batchCounter += 1
887
-
888
934
  // Sampled here, once, and held for every call below. See the note on
889
935
  // `permissionMode` in the config type.
890
936
  this.batchMode = this.resolvePermissionMode()
@@ -1212,6 +1258,7 @@ export class ToolExecutor {
1212
1258
  toolName: name,
1213
1259
  toolInput: preparedInput,
1214
1260
  toolDef: this.config.tools.get(name),
1261
+ commandDialect: this.commandDialect(name),
1215
1262
  })
1216
1263
  if (gateResult && gateResult.decision !== 'allow') {
1217
1264
  const reason =
@@ -1402,9 +1449,16 @@ export class ToolExecutor {
1402
1449
  }
1403
1450
 
1404
1451
  private resultPresentation(name: string, input: unknown, result: ToolResult) {
1405
- if (!result.success) return {}
1406
1452
  try {
1407
1453
  const view = this.config.tools.get(name)?.presentResult?.(input, result)
1454
+ // A failed call carries one view only: the person's No on the tool's
1455
+ // own screen, which a host draws as cancelled rather than failed and
1456
+ // cannot tell apart from the result text alone.
1457
+ if (!result.success) {
1458
+ return view?.kind === 'generic' && view.outcome === 'cancelled'
1459
+ ? { presentation: { kind: 'generic', label: view.label, outcome: 'cancelled' } as const }
1460
+ : {}
1461
+ }
1408
1462
  if (view?.kind !== 'diff') return {}
1409
1463
  const serialized = JSON.stringify(view)
1410
1464
  if (serialized.length > (this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS))
@@ -1442,11 +1496,11 @@ export class ToolExecutor {
1442
1496
  // Same precedence the request already uses when it decides which
1443
1497
  // schemas to send, so the menu and the kitchen agree.
1444
1498
  allowedTools: this.effectiveAllowedTools(),
1445
- // Recorded, not applied here: a skill loaded during this batch
1446
- // narrows the NEXT one. See `skillScope`.
1447
- adoptSkillScope: (scope) => {
1448
- this.skillScope = { ...scope, adoptedInBatch: this.batchCounter }
1449
- },
1499
+ // A skill loaded during this batch pre-approves calls from the NEXT
1500
+ // review on; this batch was already reviewed. See `grantSkillTools`.
1501
+ ...(this.config.skillGrants
1502
+ ? { grantSkillTools: (grant) => this.grantSkillTools(grant) }
1503
+ : {}),
1450
1504
  maxToolOutputChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
1451
1505
  // The turn's screens, defaulted HERE rather than on the registry: a
1452
1506
  // host builds the registry and hands it over, so a registry-side
@@ -1498,11 +1552,11 @@ export class ToolExecutor {
1498
1552
  readonly env?: Record<string, string>
1499
1553
  }): JobProcess =>
1500
1554
  (this.config.sandbox as Sandbox).spawnDetached?.(
1501
- '/bin/sh',
1502
- ['-c', job.command],
1555
+ sandboxShellSpawn(job.command).file,
1556
+ sandboxShellSpawn(job.command).args,
1503
1557
  {
1504
1558
  cwd: job.workingDirectory,
1505
- ...(job.env ? { env: job.env } : {}),
1559
+ ...(job.env ? { env: withoutBashStartup(job.env) } : {}),
1506
1560
  },
1507
1561
  ) as JobProcess,
1508
1562
  }
@@ -1960,6 +2014,12 @@ export class ToolExecutor {
1960
2014
  // a result whose image is unaffected — and a hook that needs it gone
1961
2015
  // says so with `content`, which wins over both.
1962
2016
  ...(modelContent !== undefined ? { content: modelContent } : {}),
2017
+ // Carried whatever a post-tool hook did to the text: the request is
2018
+ // the tool's statement about the world, not about its output, and a
2019
+ // hook that redacts a sign-in page has not signed anyone in.
2020
+ ...(result.handoff !== undefined && !this.config.abortSignal.aborted
2021
+ ? { handoff: result.handoff }
2022
+ : {}),
1963
2023
  }
1964
2024
  }
1965
2025
 
@@ -6,6 +6,7 @@ import {
6
6
  assertBudgetEnforceable,
7
7
  } from '../../advisory/index.js'
8
8
  import { AuthorizationGate } from '../../authorization/gate.js'
9
+ import { SkillGrantSet } from '../../authorization/skill-grant.js'
9
10
  import { repairToolMessageHistory, toolHistoryRepairChanged } from '../../compaction/dangling.js'
10
11
  import { extractFromUserMessage } from '../../compaction/extractor.js'
11
12
  import { WorkingStateManager } from '../../compaction/manager.js'
@@ -1197,8 +1198,13 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1197
1198
  : undefined
1198
1199
  awaitedJobs?.attach()
1199
1200
 
1201
+ // Turn-scoped, like `toolGrants` below: what a skill loaded in this turn
1202
+ // pre-approves ends with the turn. Shared by the executor, where the
1203
+ // `skill` tool records a grant, and the review phase, which reads it.
1204
+ const skillGrants = new SkillGrantSet()
1200
1205
  const toolExecutor = ToolingBootstrap.init(
1201
1206
  {
1207
+ skillGrants,
1202
1208
  tools: params.tools,
1203
1209
  sessionId: ctx.sessionId,
1204
1210
  turnId: ctx.turnId,
@@ -1456,6 +1462,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1456
1462
  provider: resilientProvider,
1457
1463
  providerCapabilities: capabilities,
1458
1464
  strictCapabilities: params.strictCapabilities === true,
1465
+ ...(params.parentSessionId !== undefined ? { delegated: true } : {}),
1459
1466
  servingMember: () => serving.current,
1460
1467
  turnConfig,
1461
1468
  ...(params.stopWhen ? { stopWhen: params.stopWhen } : {}),
@@ -1496,6 +1503,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1496
1503
  // Turn-scoped. An approval is a statement about this turn's work;
1497
1504
  // carrying one into a later turn would be reuse nobody agreed to.
1498
1505
  toolGrants: new ToolGrantSet(),
1506
+ skillGrants,
1499
1507
  ...(params.reviewAllowedCalls ? { reviewAllowedCalls: params.reviewAllowedCalls } : {}),
1500
1508
  // Turn-scoped for the same reason. A repeat count carried into a later
1501
1509
  // run is a claim about work nobody repeated, and a module-level map
@@ -71,6 +71,7 @@ import {
71
71
  relieveOverflow,
72
72
  runCompactionCheck,
73
73
  } from './phases/compaction.js'
74
+ import { runHandoffPause } from './phases/handoff.js'
74
75
  import type { IterationContext } from './phases/index.js'
75
76
  import { runPlanGate } from './phases/plan.js'
76
77
  import { runToolReview } from './phases/tool-review.js'
@@ -1421,6 +1422,13 @@ export class IterationOrchestrator {
1421
1422
  return
1422
1423
  }
1423
1424
 
1425
+ // A tool asked for a person. The whole batch is committed; the
1426
+ // turn parks here rather than asking the model what to do about
1427
+ // something only the operator can do.
1428
+ if ((yield* runHandoffPause(this.ctx, iterationNum, reviewOutcome.results)) === 'stop') {
1429
+ return
1430
+ }
1431
+
1424
1432
  if (reviewOutcome.decision === 'rejected') {
1425
1433
  continue
1426
1434
  }
@@ -1826,6 +1834,11 @@ export class IterationOrchestrator {
1826
1834
  private rememberUserMessage(message: Message, arriving = true): void {
1827
1835
  if (!isOperatorUserMessage(message)) return
1828
1836
  this.latestUserMessage = message
1837
+ // A skill's `allowed-tools` pre-approves for the request that loaded
1838
+ // it. The operator speaking again — a queued message or steering
1839
+ // delivered into this same `query()` — is a new request, and it starts
1840
+ // with nothing pre-approved, exactly as a new turn would.
1841
+ if (arriving) this.ctx.skillGrants?.clear()
1829
1842
  // The hook field alone does not reach the model. Preserve arrivals in
1830
1843
  // the compaction state too, before their original message or attached
1831
1844
  // tool result can be shed. Initial history was already extracted at seed.
@@ -1,4 +1,5 @@
1
1
  import type { AdvisoryContext } from '../../../../advisory/context.js'
2
+ import type { SkillGrantSet } from '../../../../authorization/skill-grant.js'
2
3
  import type { AgentBus } from '../../../../bus/index.js'
3
4
  import type { WorkingStateManager } from '../../../../compaction/manager.js'
4
5
  import type { ContextReducer } from '../../../../compaction/reducer.js'
@@ -43,6 +44,12 @@ import type { ToolGrantSet } from '../../tool-grants.js'
43
44
 
44
45
  export interface IterationContext {
45
46
  readonly provider: LLMProvider
47
+ /**
48
+ * The turn runs in a delegated child session. A tool's request for a
49
+ * person then fails the turn instead of pausing it: nobody is watching
50
+ * a child to resume it, and its parent is waiting on a result.
51
+ */
52
+ readonly delegated?: boolean
46
53
  /** Driver-level request shapes negotiated for this turn. */
47
54
  readonly providerCapabilities?: ResolvedProviderCapabilities
48
55
  /** Refuse a capability mismatch instead of emitting a warning and degrading. */
@@ -166,6 +173,12 @@ export interface IterationContext {
166
173
  * asked about again. Absent on paths that do not review tools.
167
174
  */
168
175
  readonly toolGrants?: ToolGrantSet
176
+ /**
177
+ * What skills loaded in this turn pre-approve (`allowed-tools`). Read by
178
+ * the review phase to mark covered calls; the review policy decides
179
+ * whether a mark skips the prompt. Absent: nothing is marked.
180
+ */
181
+ readonly skillGrants?: SkillGrantSet
169
182
  /** See `QueryParams.reviewAllowedCalls`. Absent: allowed and granted batches skip review. */
170
183
  readonly reviewAllowedCalls?: () => boolean
171
184
  /**
@@ -0,0 +1,74 @@
1
+ import { GENAI, NAMZU } from '../../../../telemetry/attributes.js'
2
+ import { NamzuError } from '../../../../types/errors/index.js'
3
+ import type { SessionEvent } from '../../../../types/session/index.js'
4
+ import type { ToolCallOutcome } from '../../executor.js'
5
+ import type { IterationContext, PhaseSignal } from './context.js'
6
+
7
+ /**
8
+ * A tool asked for a person: stop before the next model call.
9
+ *
10
+ * Runs after the batch settled, so every result — the one that asked and
11
+ * its siblings — is already in the transcript and queued for the session
12
+ * log. The checkpoint written here is taken from that state, which is what
13
+ * lets a resume continue exactly as it does after a provider pause: the
14
+ * next step is a model call that sees the results.
15
+ *
16
+ * The first request in the batch speaks for it. Two tools that both need a
17
+ * person need the same thing from the operator — to come and look — and
18
+ * one pause answers both.
19
+ *
20
+ * In a delegated child the turn fails with the reason instead. Nobody
21
+ * resumes a child's turn: its parent is waiting on a result, and a failed
22
+ * child with the reason in it is a result the parent's model can act on.
23
+ */
24
+ export async function* runHandoffPause(
25
+ ctx: IterationContext,
26
+ iterationNum: number,
27
+ results: readonly ToolCallOutcome[],
28
+ ): AsyncGenerator<SessionEvent, PhaseSignal> {
29
+ const requested = results.find((result) => result.handoff !== undefined)
30
+ const handoff = requested?.handoff
31
+ if (!requested || !handoff) return 'continue'
32
+
33
+ if (ctx.delegated) {
34
+ ctx.log.info('A tool in a delegated turn needs a person; the turn fails', {
35
+ [NAMZU.TURN_ID]: ctx.recorder.turnId,
36
+ [NAMZU.ITERATION]: iterationNum,
37
+ [GENAI.TOOL_NAME]: requested.toolName,
38
+ })
39
+ throw new NamzuError({
40
+ code: 'tool_error',
41
+ message: `${requested.toolName} needs a person: ${handoff.reason}`,
42
+ details: { toolName: requested.toolName, handoff },
43
+ retryable: false,
44
+ })
45
+ }
46
+
47
+ const checkpoint = await ctx.checkpointMgr.create(ctx.recorder, iterationNum)
48
+ await ctx.emitEvent({
49
+ type: 'checkpoint_created',
50
+ turnId: ctx.recorder.turnId,
51
+ checkpointId: checkpoint.id,
52
+ iteration: iterationNum,
53
+ })
54
+ yield* ctx.drainPending()
55
+
56
+ await ctx.emitEvent({
57
+ type: 'turn_paused',
58
+ budget: ctx.recorder.budget?.summary(),
59
+ turnId: ctx.recorder.turnId,
60
+ checkpointId: checkpoint.id,
61
+ reason: handoff.reason,
62
+ handoff,
63
+ })
64
+ yield* ctx.drainPending()
65
+ ctx.recorder.setStopReason('paused')
66
+ ctx.log.info('Turn paused for a person', {
67
+ [NAMZU.TURN_ID]: ctx.recorder.turnId,
68
+ [NAMZU.ITERATION]: iterationNum,
69
+ [GENAI.TOOL_NAME]: requested.toolName,
70
+ 'namzu.checkpoint.id': checkpoint.id,
71
+ 'namzu.runtime.reason': handoff.reason,
72
+ })
73
+ return 'stop'
74
+ }
@@ -6,3 +6,4 @@ export type { ToolReviewOutcome } from './tool-review.js'
6
6
  export { runIterationCheckpoint } from './checkpoint.js'
7
7
  export { runCompactionCheck } from './compaction.js'
8
8
  export { runAdvisoryPhase } from './advisory.js'
9
+ export { runHandoffPause } from './handoff.js'
@@ -2,6 +2,8 @@ import type { AuthorizationGate } from '../../../../authorization/index.js'
2
2
  import type { ToolCallSummary } from '../../../../types/hitl/index.js'
3
3
  import type { ChatCompletionResponse } from '../../../../types/provider/index.js'
4
4
  import type { SessionEvent } from '../../../../types/session/index.js'
5
+ import type { ShellDialect } from '../../../../types/tool/index.js'
6
+ import { DECLINED_TOOL_CALL_FEEDBACK } from '../../declined.js'
5
7
  import type { PreparedToolBatch, ToolCallDenials } from '../../executor.js'
6
8
  import {
7
9
  awaitProjectInstructionCallback,
@@ -53,6 +55,17 @@ export async function* runToolReview(
53
55
  ): AsyncGenerator<SessionEvent, ToolReviewOutcome> {
54
56
  let executed: readonly import('../../executor.js').ToolCallOutcome[] = []
55
57
  let toolMs = 0
58
+ // The shell each call's command line will run in, for the rules and the
59
+ // skill grants to read it the same way. A test double without the method
60
+ // leaves it unset, which reads the line for any POSIX shell.
61
+ const dialectFor = (toolName: string): { commandDialect?: ShellDialect } => {
62
+ const executor = ctx.toolExecutor as {
63
+ commandDialect?: (name: string) => ShellDialect
64
+ }
65
+ return typeof executor.commandDialect === 'function'
66
+ ? { commandDialect: executor.commandDialect(toolName) }
67
+ : {}
68
+ }
56
69
 
57
70
  const finish = (decision: ToolReviewDecision): ToolReviewOutcome => ({
58
71
  decision,
@@ -255,6 +268,7 @@ export async function* runToolReview(
255
268
  toolName: tc.name,
256
269
  toolInput: tc.input,
257
270
  toolDef: ctx.tools.get(tc.name),
271
+ ...dialectFor(tc.name),
258
272
  }),
259
273
  }))
260
274
  for (const gr of gateResults) {
@@ -331,6 +345,24 @@ export async function* runToolReview(
331
345
  })
332
346
  }
333
347
 
348
+ // A skill's `allowed-tools` pre-approval, marked on the calls it covers
349
+ // and left for the review policy to honour. Marked, never decided here:
350
+ // only the policy knows the mode, and `plan` and `strict` must refuse a
351
+ // call a skill granted exactly as they refuse any other. Nothing stronger
352
+ // may stand in the way — an operator's deny or explicit ask, a
353
+ // destructive call, a path outside the roots or a sandbox escape all
354
+ // leave the call unmarked, so it is reviewed as though no skill had
355
+ // spoken.
356
+ if (ctx.skillGrants && ctx.skillGrants.size > 0) {
357
+ for (const tc of toolCallSummaries) {
358
+ if (gateDenied.has(tc.id)) continue
359
+ if (tc.authorization?.decision === 'deny' || tc.authorization?.explicitReview) continue
360
+ if (tc.isDestructive || tc.escalation !== undefined) continue
361
+ const skill = ctx.skillGrants.coveringSkill(tc, ctx.tools.get(tc.name), dialectFor(tc.name))
362
+ if (skill !== undefined) tc.skillGrant = { skill }
363
+ }
364
+ }
365
+
334
366
  // Already approved, at a scope the approver chose — and nothing the
335
367
  // operator's policy denied, because `gateDenied` is checked first.
336
368
  // Re-asking about a call somebody has already said yes to is how an
@@ -419,7 +451,11 @@ export async function* runToolReview(
419
451
  }
420
452
  for (const path of tc.escalation?.outsidePaths ?? []) {
421
453
  await ctx.recorder.recordAudit({
422
- what: { action: 'outside_root_access', tool: tc.name, resource: path },
454
+ what: {
455
+ action: 'outside_root_access',
456
+ tool: tc.name,
457
+ resource: path,
458
+ },
423
459
  outcome: 'approved',
424
460
  reason: "the turn's review approved this call",
425
461
  })
@@ -436,7 +472,7 @@ export async function* runToolReview(
436
472
  })
437
473
  yield* ctx.drainPending()
438
474
 
439
- const feedback = reviewDecision.feedback || 'The user rejected this tool call.'
475
+ const feedback = reviewDecision.feedback || DECLINED_TOOL_CALL_FEEDBACK
440
476
  const denials = new Map(denyAll(feedback))
441
477
  await settleEscalations(denials, undefined)
442
478
  await settle(denials)
@@ -466,7 +502,7 @@ export async function* runToolReview(
466
502
  }
467
503
  }
468
504
  if (mod.action === 'deny' && !denials.has(mod.toolCallId)) {
469
- denials.set(mod.toolCallId, 'The user denied this tool call.')
505
+ denials.set(mod.toolCallId, DECLINED_TOOL_CALL_FEEDBACK)
470
506
  }
471
507
  }
472
508
 
@@ -490,6 +526,7 @@ export async function* runToolReview(
490
526
  toolName: summary.name,
491
527
  toolInput: summary.input,
492
528
  toolDef: ctx.tools.get(summary.name),
529
+ ...dialectFor(summary.name),
493
530
  })
494
531
  if (gateResult.decision === 'allow') continue
495
532
  const reason =
@@ -550,6 +587,21 @@ export async function* runToolReview(
550
587
  if (reviewDecision.action === 'approve_tools' && reviewDecision.remember) {
551
588
  ctx.toolGrants?.grant(reviewDecision.remember)
552
589
  }
590
+ // A call nobody was asked about, approved because a skill said so,
591
+ // is on the record naming that skill. Only a call that carried the
592
+ // mark counts: a policy that lists an unmarked id has not been
593
+ // given a skill's word for it.
594
+ if (reviewDecision.action === 'approve_tools' && reviewDecision.skillGranted) {
595
+ const listed = new Set(reviewDecision.skillGranted)
596
+ for (const tc of toolCallSummaries) {
597
+ if (!listed.has(tc.id) || !tc.skillGrant || gateDenied.has(tc.id)) continue
598
+ await ctx.recorder.recordAudit({
599
+ what: { action: 'tool_call', tool: tc.name },
600
+ outcome: 'approved',
601
+ reason: `pre-approved by the allowed-tools of skill "${tc.skillGrant.skill}" for this turn; nobody was asked`,
602
+ })
603
+ }
604
+ }
553
605
 
554
606
  await ctx.emitEvent({
555
607
  type: 'tool_review_completed',