@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
@@ -1,23 +1,27 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { z } from 'zod'
3
3
 
4
+ import { parseAllowedTools } from '../../authorization/skill-grant.js'
4
5
  import { isInvocableBy, skillInvocation } from '../../types/skills/index.js'
5
6
  import { defineTool } from '../defineTool.js'
6
7
 
8
+ export { parseAllowedTools }
9
+
7
10
  /**
8
- * Load a skill's instructions, and adopt whatever it says it needs.
11
+ * Load a skill's instructions, and apply what its `allowed-tools` grants.
9
12
  *
10
13
  * The manifest in the system prompt told the model that a SKILL.md exists
11
14
  * and to "read the SKILL.md at its <location> before writing code" — which
12
15
  * is a filesystem instruction, so a turn with no filesystem tools could see
13
- * every skill it had and open none of them. The protocol text even admits
14
- * it: *"when the runtime exposes filesystem or skill-loading tools"*. There
15
- * was no skill-loading tool.
16
+ * every skill it had and open none of them. There was no skill-loading tool.
16
17
  *
17
- * `allowed-tools` had the same shape of problem from the other side. It was
18
- * parsed, carried into `SkillMetadata`, rendered into the prompt as
19
- * `<allowed_tools>…</allowed_tools>` — and read by nothing. It was advice
20
- * the model could take or ignore, phrased as a declaration.
18
+ * `allowed-tools` is a PRE-APPROVAL, as the Agent Skills format defines it:
19
+ * the listed tools skip the approval prompt for the rest of this turn, and
20
+ * every other tool stays callable under the turn's ordinary review. It was
21
+ * read here for a while as a restriction — the listed tools and nothing
22
+ * else, from the next batch — which inverted what skill authors mean by it
23
+ * and left a model that loaded `allowed-tools: Read Grep` without `bash`.
24
+ * See `authorization/skill-grant.ts` for what a grant can and cannot do.
21
25
  */
22
26
 
23
27
  const inputSchema = z.object({
@@ -47,6 +51,8 @@ interface SkillSnapshot {
47
51
  readonly name: string
48
52
  readonly body: string
49
53
  readonly allowedTools: readonly string[] | undefined
54
+ /** Bound into the cursor because `${CLAUDE_SKILL_DIR}` in a grant expands to it. */
55
+ readonly skillDirectory: string | undefined
50
56
  readonly invocation: ReturnType<typeof skillInvocation>
51
57
  }
52
58
 
@@ -76,6 +82,9 @@ function snapshotDigest(snapshot: SkillSnapshot): string {
76
82
  name: snapshot.name,
77
83
  body: snapshot.body,
78
84
  allowedTools: snapshot.allowedTools ?? null,
85
+ ...(snapshot.skillDirectory === undefined
86
+ ? {}
87
+ : { skillDirectory: snapshot.skillDirectory }),
79
88
  invocation: snapshot.invocation,
80
89
  }),
81
90
  )
@@ -231,27 +240,6 @@ function pageSkillBody(input: {
231
240
  return undefined
232
241
  }
233
242
 
234
- /**
235
- * `allowed-tools` as a list.
236
- *
237
- * Comma-separated in the frontmatter because that is what authors write and
238
- * what the field has always accepted. Split here rather than at parse so
239
- * the stored metadata keeps the author's own string — the same reasoning
240
- * `invocation` uses for not defaulting at parse.
241
- */
242
- export function parseAllowedTools(declared: string | undefined): readonly string[] | undefined {
243
- if (declared === undefined) return undefined
244
- const names = declared
245
- .split(',')
246
- .map((name) => name.trim())
247
- .filter((name) => name.length > 0)
248
- // An empty result from a non-empty declaration is a real answer and not
249
- // the same as "declared nothing": `allowed-tools: ""` is an author
250
- // saying this skill needs no tools, and collapsing it to `undefined`
251
- // would silently widen that to everything.
252
- return names
253
- }
254
-
255
243
  export const SKILL_TOOL_NAME = 'skill'
256
244
 
257
245
  export const SkillTool = defineTool({
@@ -261,13 +249,31 @@ export const SkillTool = defineTool({
261
249
  inputSchema,
262
250
  category: 'analysis',
263
251
  permissions: [],
264
- // Reads instructions and changes nothing. It is the one tool whose
265
- // availability a narrowed skill scope must never remove, or a model
266
- // inside one skill could not reach for another.
252
+ // Reads instructions and changes nothing on disk. What it does change is
253
+ // the turn's approvals, through `grantSkillTools`, and only ever towards
254
+ // fewer prompts for calls the operator's policy already leaves to review.
267
255
  readOnly: true,
268
256
  destructive: false,
269
257
  concurrencySafe: true,
270
258
 
259
+ // The body is instructions for the model, often a hundred lines; the
260
+ // person needs the row that says which skill was read, not the text.
261
+ presentCall(input: SkillInput) {
262
+ const name = typeof input?.name === 'string' ? input.name : undefined
263
+ return {
264
+ kind: 'generic',
265
+ presentation: 'activity',
266
+ label:
267
+ name === undefined
268
+ ? input?.cursor === undefined
269
+ ? 'List skills'
270
+ : 'List more skills'
271
+ : `Read skill ${name}${input.cursor === undefined ? '' : ' (continued)'}`,
272
+ }
273
+ },
274
+ presentResult: (_input: SkillInput, result) =>
275
+ result.success ? { kind: 'generic', label: 'read', visibility: 'hidden' } : undefined,
276
+
271
277
  async execute(input: SkillInput, context) {
272
278
  if (!context.skills) {
273
279
  return {
@@ -382,6 +388,7 @@ export const SkillTool = defineTool({
382
388
  name: input.name,
383
389
  body: skill.body ?? '(this skill has no body)',
384
390
  allowedTools: allowed,
391
+ skillDirectory: skill.dirPath,
385
392
  invocation,
386
393
  }
387
394
  const digest = snapshotDigest(snapshot)
@@ -403,10 +410,20 @@ export const SkillTool = defineTool({
403
410
  start = parsed.offset
404
411
  }
405
412
 
406
- const notice =
407
- allowed === undefined
408
- ? ''
409
- : `\n\n[While following this skill, restrict yourself to: ${allowed.length > 0 ? allowed.join(', ') : '(no tools)'}. This takes effect from your next turn.]`
413
+ // Compiled before paging so the notice can say what the grant is, and
414
+ // committed only after paging succeeded: a load that fails here gave
415
+ // the model no instructions, so it must not have approved anything.
416
+ // Idempotent: a continuation call grants the same entries again, and
417
+ // the turn's set keeps one copy.
418
+ let grant: ReturnType<NonNullable<typeof context.grantSkillTools>> | undefined
419
+ if (allowed !== undefined && allowed.length > 0 && context.grantSkillTools) {
420
+ grant = context.grantSkillTools({
421
+ skill: skill.metadata.name,
422
+ allowedTools: allowed,
423
+ ...(snapshot.skillDirectory ? { skillDirectory: snapshot.skillDirectory } : {}),
424
+ })
425
+ }
426
+ const notice = grantNotice(allowed, grant, context.grantSkillTools !== undefined)
410
427
  const page = pageSkillBody({
411
428
  snapshot,
412
429
  digest,
@@ -421,23 +438,7 @@ export const SkillTool = defineTool({
421
438
  error: `The model-visible tool-output budget is too small to read "${input.name}" safely. Increase maxToolOutputChars and retry.`,
422
439
  }
423
440
  }
424
-
425
- // Cursor and policy validation must finish before this mutation. A
426
- // continuation is bound to the body AND its effective authorization
427
- // metadata, so an edit to allowed-tools or invocation cannot widen the
428
- // next batch under an old cursor.
429
- if (allowed !== undefined) {
430
- // Adopted, not merely announced. The notice below tells the model
431
- // what happened; this is what makes it true whether or not the
432
- // model reads it — the difference between the field as it was and
433
- // the field as a declaration.
434
- //
435
- // Absent `adoptSkillScope`, the notice still goes out and is all
436
- // there is: a host driving this tool outside a turn has no executor
437
- // to enforce anything, and saying nothing would be worse than
438
- // advice.
439
- context.adoptSkillScope?.({ skill: skill.metadata.name, allowedTools: allowed })
440
- }
441
+ grant?.commit()
441
442
 
442
443
  return {
443
444
  success: true,
@@ -445,8 +446,46 @@ export const SkillTool = defineTool({
445
446
  data: {
446
447
  skill: skill.metadata.name,
447
448
  ...(allowed === undefined ? {} : { allowedTools: allowed }),
449
+ ...(grant ? { granted: grant.granted, ignored: grant.ignored } : {}),
448
450
  ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
449
451
  },
450
452
  }
451
453
  },
452
454
  })
455
+
456
+ /**
457
+ * What the model is told about `allowed-tools`.
458
+ *
459
+ * The sentence that matters most is the second one. The old notice said
460
+ * "restrict yourself to", and a model told that does exactly what the owner
461
+ * reported: it stops using `bash` and tries to do the work through the skill.
462
+ * So the notice says, every time, that nothing was taken away.
463
+ */
464
+ function grantNotice(
465
+ allowed: readonly string[] | undefined,
466
+ grant:
467
+ | {
468
+ granted: readonly string[]
469
+ ignored: readonly { entry: string; reason: string }[]
470
+ }
471
+ | undefined,
472
+ canGrant: boolean,
473
+ ): string {
474
+ if (allowed === undefined || allowed.length === 0) return ''
475
+ const unchanged =
476
+ 'Every other tool remains available and is reviewed as usual; this skill does not limit which tools you may use.'
477
+ if (!canGrant || !grant) {
478
+ return `\n\n[This skill lists allowed-tools (${allowed.join(', ')}), but this host applies no pre-approval, so those calls are reviewed as usual. ${unchanged}]`
479
+ }
480
+ const lines: string[] = []
481
+ lines.push(
482
+ grant.granted.length > 0
483
+ ? `Pre-approved for the rest of this turn: ${grant.granted.join(', ')}. Deny and ask rules, plan and strict mode, and review of destructive calls still apply.`
484
+ : 'Nothing in allowed-tools could be pre-approved.',
485
+ )
486
+ for (const { entry, reason } of grant.ignored) {
487
+ lines.push(`Ignored allowed-tools entry "${entry}": ${reason}.`)
488
+ }
489
+ lines.push(unchanged)
490
+ return `\n\n[${lines.join(' ')}]`
491
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Which shell runs a `bash` tool command, and in what dialect it must be read.
3
+ *
4
+ * ## Why this is one decision
5
+ *
6
+ * The permission rules read a command line before it runs
7
+ * (`authorization/shell-lexer.ts`), and a reading is only as good as its
8
+ * match with the shell that runs the line afterwards. The tool is called
9
+ * `bash` and its description says bash, but it used to spawn `/bin/sh -c`:
10
+ * bash on some hosts, `dash` on Debian and Ubuntu, `busybox sh` in small
11
+ * images. `$'\x3b'`, `|&`, `&>` and `<<<` mean different things in those,
12
+ * so a line the rules read as bash could run as something else.
13
+ *
14
+ * So the host path now runs bash wherever bash exists, and the reading
15
+ * follows what was actually chosen:
16
+ *
17
+ * - bash found (or named by `NAMZU_BASH_SHELL`) → `bash -c`, read in the
18
+ * `bash` dialect;
19
+ * - no bash → `/bin/sh -c`, read in the conservative `sh` dialect, in which
20
+ * every construct whose meaning differs between bash and a POSIX shell
21
+ * makes a line opaque;
22
+ * - inside a sandbox the guest image decides, and the rules cannot see it,
23
+ * so a small launcher runs bash when the guest has it and `/bin/sh`
24
+ * otherwise, and the line is always read in the `sh` dialect, which is
25
+ * right for either.
26
+ *
27
+ * ## Equivalent to what ran before
28
+ *
29
+ * `/bin/sh -c` read no startup file. `bash -c` reads none either, except
30
+ * the file `BASH_ENV` names, and it imports shell functions (`BASH_FUNC_*`)
31
+ * and parser options (`SHELLOPTS`, `BASHOPTS`) from its environment. Any of
32
+ * those would change what a command line means after the rules read it, so
33
+ * they are removed from the environment of the spawned bash.
34
+ * `NAMZU_BASH_SHELL=/bin/sh` restores the old shell exactly.
35
+ */
36
+
37
+ import { constants, accessSync } from 'node:fs'
38
+ import { delimiter, join } from 'node:path'
39
+
40
+ import type { ShellDialect } from '../types/tool/index.js'
41
+
42
+ /** The shell a host-side command runs in. */
43
+ export interface CommandShell {
44
+ /** The executable, run as `<path> -c <command>`. Undefined: Node's platform shell (Windows). */
45
+ readonly path: string | undefined
46
+ /** How the permission rules read a line this shell runs. */
47
+ readonly dialect: ShellDialect
48
+ /** Where the choice came from, for diagnostics. */
49
+ readonly source: 'override' | 'bash' | 'sh' | 'platform'
50
+ }
51
+
52
+ /** What resolution looks at. Injected by tests to simulate a host without bash. */
53
+ export interface CommandShellProbe {
54
+ readonly env: NodeJS.ProcessEnv
55
+ readonly platform: NodeJS.Platform
56
+ readonly isExecutable: (path: string) => boolean
57
+ }
58
+
59
+ const WELL_KNOWN_BASH = ['/bin/bash', '/usr/bin/bash']
60
+
61
+ /** Environment variables that change what a bash command line means. */
62
+ const BASH_STARTUP_VARIABLES = new Set(['BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS'])
63
+
64
+ export function findCommandShell(probe: CommandShellProbe): CommandShell {
65
+ const override = probe.env.NAMZU_BASH_SHELL
66
+ if (override !== undefined && override !== '') {
67
+ // Read as bash only when it is bash; anything else gets the reading
68
+ // that holds for every POSIX shell.
69
+ const name = override.slice(override.lastIndexOf('/') + 1)
70
+ return { path: override, dialect: name === 'bash' ? 'bash' : 'sh', source: 'override' }
71
+ }
72
+ // Windows keeps Node's platform shell. Looking `bash` up on its PATH can
73
+ // find WSL's launcher, which runs the command in another system.
74
+ if (probe.platform === 'win32') return { path: undefined, dialect: 'sh', source: 'platform' }
75
+ for (const directory of (probe.env.PATH ?? '').split(delimiter)) {
76
+ if (directory === '' || !directory.startsWith('/')) continue
77
+ const candidate = join(directory, 'bash')
78
+ if (probe.isExecutable(candidate)) return { path: candidate, dialect: 'bash', source: 'bash' }
79
+ }
80
+ for (const candidate of WELL_KNOWN_BASH) {
81
+ if (probe.isExecutable(candidate)) return { path: candidate, dialect: 'bash', source: 'bash' }
82
+ }
83
+ return { path: '/bin/sh', dialect: 'sh', source: 'sh' }
84
+ }
85
+
86
+ function isExecutable(path: string): boolean {
87
+ try {
88
+ accessSync(path, constants.X_OK)
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ let resolved: CommandShell | undefined
96
+
97
+ /**
98
+ * The host's command shell, resolved once per process. The same value serves
99
+ * the permission rules and the spawn, so the two cannot disagree.
100
+ */
101
+ export function hostCommandShell(): CommandShell {
102
+ if (resolved === undefined) {
103
+ resolved = findCommandShell({ env: process.env, platform: process.platform, isExecutable })
104
+ }
105
+ return resolved
106
+ }
107
+
108
+ /** Replace the resolved host shell; `undefined` resolves again on next use. For tests. */
109
+ export function setHostCommandShellForTesting(shell: CommandShell | undefined): void {
110
+ resolved = shell
111
+ }
112
+
113
+ /**
114
+ * The spawn for one command on the host: executable, arguments, environment.
115
+ * For bash, the variables that would change the line's meaning are dropped.
116
+ */
117
+ export function hostShellSpawn(
118
+ command: string,
119
+ env: NodeJS.ProcessEnv,
120
+ shell: CommandShell = hostCommandShell(),
121
+ ): {
122
+ readonly file: string | undefined
123
+ readonly args: readonly string[]
124
+ readonly env: NodeJS.ProcessEnv
125
+ } {
126
+ if (shell.path === undefined) return { file: undefined, args: [command], env }
127
+ if (shell.dialect !== 'bash') return { file: shell.path, args: ['-c', command], env }
128
+ return { file: shell.path, args: ['-c', command], env: withoutBashStartup(env) }
129
+ }
130
+
131
+ export function withoutBashStartup<T extends Readonly<Record<string, string | undefined>>>(
132
+ env: T,
133
+ ): T {
134
+ const out: Record<string, string | undefined> = {}
135
+ for (const [name, value] of Object.entries(env)) {
136
+ if (BASH_STARTUP_VARIABLES.has(name) || name.startsWith('BASH_FUNC_')) continue
137
+ out[name] = value
138
+ }
139
+ return out as T
140
+ }
141
+
142
+ /**
143
+ * The command a sandbox runs for one command line: bash when the guest has
144
+ * it, `/bin/sh` otherwise. The rules read a sandboxed line in the `sh`
145
+ * dialect, which holds for both. The command is passed as an argument, never
146
+ * spliced into the launcher's text.
147
+ *
148
+ * The launcher does not `unset` the startup variables: where `/bin/sh` is
149
+ * bash, `SHELLOPTS` arriving in the environment is readonly and `unset`
150
+ * fails. The guest's environment is an allowlist plus the host's `env`, so
151
+ * callers drop them from that `env` with {@link withoutBashStartup}.
152
+ */
153
+ export const SANDBOX_SHELL_LAUNCHER =
154
+ 'if command -v bash >/dev/null 2>&1; then exec bash -c "$1"; fi; exec /bin/sh -c "$1"'
155
+
156
+ export function sandboxShellSpawn(command: string): {
157
+ readonly file: string
158
+ readonly args: string[]
159
+ } {
160
+ return { file: '/bin/sh', args: ['-c', SANDBOX_SHELL_LAUNCHER, 'sh', command] }
161
+ }
162
+
163
+ /** The dialect a `bash` tool command is read in. */
164
+ export function bashToolDialect(context: { readonly sandboxed: boolean }): ShellDialect {
165
+ return context.sandboxed ? 'sh' : hostCommandShell().dialect
166
+ }
@@ -61,13 +61,46 @@ export interface DefineToolOptions<S extends z.ZodType> {
61
61
  * hand-written definitions can use.
62
62
  */
63
63
  commandArgument?: string
64
+ /** The shell the command argument runs in; see {@link ToolDefinition.commandDialect}. */
65
+ commandDialect?: ToolDefinition['commandDialect']
64
66
  /** The argument holding a filesystem path; see {@link ToolDefinition.pathArgument}. */
65
67
  pathArgument?: string
68
+ /** The argument holding a canonical URL; see {@link ToolDefinition.urlArgument}. */
69
+ urlArgument?: string
66
70
  /** The argument asking to leave the sandbox; see {@link ToolDefinition.sandboxEscapeArgument}. */
67
71
  sandboxEscapeArgument?: string
68
72
  execute(input: z.infer<S>, context: ToolContext): Promise<ToolResult>
69
73
  }
70
74
 
75
+ /**
76
+ * The `isDestructive` functions built from a literal `destructive: true`.
77
+ *
78
+ * Such a tool is destructive for EVERY input, so no call of it can ever be
79
+ * approved without review — and a grant that names it (a skill's
80
+ * `allowed-tools: Write`) would be a promise the review phase never keeps.
81
+ * Kept here rather than as a field on the definition so the public
82
+ * `ToolDefinition` shape does not change; see {@link isAlwaysDestructive}.
83
+ */
84
+ const ALWAYS_DESTRUCTIVE = new WeakSet<object>()
85
+
86
+ /**
87
+ * Whether a tool declares every call destructive, whatever the input.
88
+ *
89
+ * Known only for a tool built by {@link defineTool} with `destructive: true`.
90
+ * A hand-written definition, or one whose flag depends on the input, answers
91
+ * `false`: its calls are still judged one by one, so nothing is lost but an
92
+ * early warning.
93
+ */
94
+ export function isAlwaysDestructive(tool: Pick<ToolDefinition, 'isDestructive'>): boolean {
95
+ return tool.isDestructive !== undefined && ALWAYS_DESTRUCTIVE.has(tool.isDestructive)
96
+ }
97
+
98
+ function constantDestructive(value: boolean): () => boolean {
99
+ const fn = () => value
100
+ if (value) ALWAYS_DESTRUCTIVE.add(fn)
101
+ return fn
102
+ }
103
+
71
104
  export function defineTool<S extends z.ZodType>(
72
105
  options: DefineToolOptions<S>,
73
106
  ): ToolDefinition<z.infer<S>> {
@@ -84,7 +117,9 @@ export function defineTool<S extends z.ZodType>(
84
117
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
85
118
  ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
86
119
  ...(options.commandArgument !== undefined ? { commandArgument: options.commandArgument } : {}),
120
+ ...(options.commandDialect !== undefined ? { commandDialect: options.commandDialect } : {}),
87
121
  ...(options.pathArgument !== undefined ? { pathArgument: options.pathArgument } : {}),
122
+ ...(options.urlArgument !== undefined ? { urlArgument: options.urlArgument } : {}),
88
123
  ...(options.sandboxEscapeArgument !== undefined
89
124
  ? { sandboxEscapeArgument: options.sandboxEscapeArgument }
90
125
  : {}),
@@ -102,7 +137,7 @@ export function defineTool<S extends z.ZodType>(
102
137
  isDestructive:
103
138
  typeof options.destructive === 'function'
104
139
  ? options.destructive
105
- : () => options.destructive as boolean,
140
+ : constantDestructive(options.destructive as boolean),
106
141
  isConcurrencySafe: () => options.concurrencySafe,
107
142
 
108
143
  async execute(input: TInput, context: ToolContext): Promise<ToolResult> {
@@ -0,0 +1,4 @@
1
+ export { buildScheduleTools, SCHEDULE_TOOL_NAME } from './schedule-tool.js'
2
+ export { buildSessionLoopTools, SESSION_LOOP_TOOL_NAME } from './loop-tool.js'
3
+ export { revealHiddenCharacters, scanSchedulePrompt } from './prompt-scan.js'
4
+ export type * from './types.js'
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod'
2
+ import type { ToolDefinition } from '../../types/tool/index.js'
3
+ import { defineTool } from '../defineTool.js'
4
+ import { presentLoopCall, presentLoopResult } from './present.js'
5
+ import type { SessionLoopHost } from './types.js'
6
+
7
+ export const SESSION_LOOP_TOOL_NAME = 'session_loop'
8
+
9
+ const inputSchema = z.object({
10
+ action: z.enum(['create', 'list', 'delete']),
11
+ interval: z
12
+ .string()
13
+ .optional()
14
+ .describe('create: how often, e.g. "5m", "1h", or a five-field cron expression'),
15
+ prompt: z.string().min(1).max(4_000).optional().describe('create: the message to send each time'),
16
+ id: z.string().optional().describe('delete: the loop id, or "all"'),
17
+ })
18
+
19
+ /**
20
+ * The `session_loop` tool: re-send a prompt to THIS conversation on an
21
+ * interval, between turns, while the session is open.
22
+ *
23
+ * `create` is an ordinary reviewed call — not exempt from review — so in
24
+ * `prompt` mode the operator sees it before a loop exists; a loop a model
25
+ * created is labelled as such wherever it is shown. `list` and `delete` only
26
+ * read or remove loops.
27
+ */
28
+ export function buildSessionLoopTools(host: SessionLoopHost): ToolDefinition[] {
29
+ return [
30
+ defineTool({
31
+ name: SESSION_LOOP_TOOL_NAME,
32
+ description:
33
+ 'Re-send a prompt to this conversation on an interval while the session stays open (between turns only; stops when the session closes; expires after 7 days). Use only when the user asks for something to repeat. Minimum interval one minute.',
34
+ inputSchema,
35
+ category: 'custom',
36
+ permissions: [],
37
+ readOnly: (input) => input.action !== 'create',
38
+ destructive: false,
39
+ concurrencySafe: false,
40
+ presentCall: presentLoopCall,
41
+ presentResult: presentLoopResult,
42
+ async execute(input) {
43
+ if (input.action === 'list') {
44
+ const loops = host.list()
45
+ return {
46
+ success: true,
47
+ output:
48
+ loops.length === 0
49
+ ? 'No loops in this session.'
50
+ : loops.map((l) => `${l.id} · ${l.schedule} · ${l.prompt.slice(0, 80)}`).join('\n'),
51
+ data: { loops },
52
+ }
53
+ }
54
+ if (input.action === 'delete') {
55
+ if (!input.id) return { success: false, output: '', error: 'delete needs id (or "all").' }
56
+ const stopped = await host.delete(input.id)
57
+ return stopped === 0
58
+ ? { success: false, output: '', error: `No loop has id "${input.id}".` }
59
+ : { success: true, output: `Stopped ${stopped} loop${stopped === 1 ? '' : 's'}.` }
60
+ }
61
+ if (!input.interval || !input.prompt) {
62
+ return { success: false, output: '', error: 'create needs interval and prompt.' }
63
+ }
64
+ try {
65
+ const loop = await host.create({
66
+ interval: input.interval,
67
+ prompt: input.prompt,
68
+ createdBy: 'model',
69
+ })
70
+ return {
71
+ success: true,
72
+ output: `Loop ${loop.id} created: ${loop.schedule}. The operator can stop it with /loop stop ${loop.id}.`,
73
+ data: { id: loop.id },
74
+ }
75
+ } catch (error) {
76
+ return {
77
+ success: false,
78
+ output: '',
79
+ error: error instanceof Error ? error.message : String(error),
80
+ }
81
+ }
82
+ },
83
+ }),
84
+ ]
85
+ }
@@ -0,0 +1,86 @@
1
+ import type { ToolResult } from '../../types/tool/index.js'
2
+ import type { ToolCallView, ToolResultView } from '../../types/tool/presentation.js'
3
+
4
+ /**
5
+ * How the schedule tools read to a person: in words, never ids or JSON.
6
+ */
7
+
8
+ const MAX = 80
9
+
10
+ function oneLine(value: unknown): string {
11
+ if (typeof value !== 'string') return ''
12
+ const flat = value.replace(/\s+/g, ' ').trim()
13
+ return flat.length > MAX ? `${flat.slice(0, MAX - 1)}…` : flat
14
+ }
15
+
16
+ function activity(label: string): ToolCallView {
17
+ return { kind: 'generic', presentation: 'activity', label }
18
+ }
19
+
20
+ const HIDDEN: ToolResultView = { kind: 'generic', label: '', visibility: 'hidden' }
21
+
22
+ export function presentScheduleCall(input: {
23
+ readonly action?: unknown
24
+ readonly name?: unknown
25
+ readonly when?: unknown
26
+ readonly job?: unknown
27
+ }): ToolCallView {
28
+ switch (input.action) {
29
+ case 'create': {
30
+ const name = oneLine(input.name)
31
+ const when = oneLine(input.when)
32
+ return activity(`Propose scheduled job${name ? ` · ${name}` : ''}${when ? ` · ${when}` : ''}`)
33
+ }
34
+ case 'list':
35
+ return activity('List scheduled jobs')
36
+ case 'pause':
37
+ return activity(`Pause scheduled job · ${oneLine(input.job)}`)
38
+ case 'resume':
39
+ return activity(`Resume scheduled job · ${oneLine(input.job)}`)
40
+ case 'delete':
41
+ return activity(`Delete scheduled job · ${oneLine(input.job)}`)
42
+ default:
43
+ return activity('Scheduled jobs')
44
+ }
45
+ }
46
+
47
+ /** The operator answered no on the tool's own screen: not a failure. */
48
+ function operatorCancelled(result: ToolResult): boolean {
49
+ const data = result.data as { cancelled?: unknown } | undefined
50
+ return data?.cancelled === true
51
+ }
52
+
53
+ export function presentScheduleResult(input: unknown, result: ToolResult): ToolResultView {
54
+ if (!result.success && operatorCancelled(result)) {
55
+ const action = (input as { action?: unknown } | null)?.action
56
+ return {
57
+ kind: 'generic',
58
+ outcome: 'cancelled',
59
+ label: action === 'create' ? 'Cancelled — no job was created' : 'Cancelled — nothing changed',
60
+ }
61
+ }
62
+ if (!result.success) return { kind: 'generic', label: oneLine(result.error) || 'Not done' }
63
+ return HIDDEN
64
+ }
65
+
66
+ export function presentLoopCall(input: {
67
+ readonly action?: unknown
68
+ readonly interval?: unknown
69
+ readonly prompt?: unknown
70
+ }): ToolCallView {
71
+ switch (input.action) {
72
+ case 'create':
73
+ return activity(`Loop ${oneLine(input.interval)} · ${oneLine(input.prompt)}`)
74
+ case 'list':
75
+ return activity('List loops')
76
+ case 'delete':
77
+ return activity('Stop loop')
78
+ default:
79
+ return activity('Loops')
80
+ }
81
+ }
82
+
83
+ export function presentLoopResult(_input: unknown, result: ToolResult): ToolResultView {
84
+ if (!result.success) return { kind: 'generic', label: oneLine(result.error) || 'Not done' }
85
+ return HIDDEN
86
+ }