@namzu/sdk 44.2.0 → 45.0.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 (124) hide show
  1. package/CHANGELOG.md +49 -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 +1 -1
  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 +21 -5
  13. package/dist/authorization/rules.js.map +1 -1
  14. package/dist/authorization/shell-lexer.d.ts +138 -0
  15. package/dist/authorization/shell-lexer.d.ts.map +1 -0
  16. package/dist/authorization/shell-lexer.js +2143 -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/persona/assembler.d.ts.map +1 -1
  23. package/dist/persona/assembler.js +5 -2
  24. package/dist/persona/assembler.js.map +1 -1
  25. package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
  26. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  27. package/dist/prompt/coding-agent-doctrine.js +1 -1
  28. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  29. package/dist/public-runtime.d.ts +1 -0
  30. package/dist/public-runtime.d.ts.map +1 -1
  31. package/dist/public-runtime.js +4 -0
  32. package/dist/public-runtime.js.map +1 -1
  33. package/dist/public-tools.d.ts.map +1 -1
  34. package/dist/public-tools.js +2 -1
  35. package/dist/public-tools.js.map +1 -1
  36. package/dist/public-types.d.ts +3 -1
  37. package/dist/public-types.d.ts.map +1 -1
  38. package/dist/runtime/jobs/registry.d.ts +2 -2
  39. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  40. package/dist/runtime/jobs/registry.js +6 -2
  41. package/dist/runtime/jobs/registry.js.map +1 -1
  42. package/dist/runtime/query/executor.d.ts +34 -40
  43. package/dist/runtime/query/executor.d.ts.map +1 -1
  44. package/dist/runtime/query/executor.js +81 -51
  45. package/dist/runtime/query/executor.js.map +1 -1
  46. package/dist/runtime/query/index.d.ts.map +1 -1
  47. package/dist/runtime/query/index.js +7 -0
  48. package/dist/runtime/query/index.js.map +1 -1
  49. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  50. package/dist/runtime/query/iteration/index.js +6 -0
  51. package/dist/runtime/query/iteration/index.js.map +1 -1
  52. package/dist/runtime/query/iteration/phases/context.d.ts +7 -0
  53. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  54. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  55. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  56. package/dist/runtime/query/iteration/phases/tool-review.js +48 -0
  57. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  58. package/dist/runtime/query/review-policy.d.ts +11 -0
  59. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  60. package/dist/runtime/query/review-policy.js +32 -0
  61. package/dist/runtime/query/review-policy.js.map +1 -1
  62. package/dist/runtime/query/tooling.d.ts +3 -0
  63. package/dist/runtime/query/tooling.d.ts.map +1 -1
  64. package/dist/runtime/query/tooling.js +1 -0
  65. package/dist/runtime/query/tooling.js.map +1 -1
  66. package/dist/skills/loader.d.ts +8 -0
  67. package/dist/skills/loader.d.ts.map +1 -1
  68. package/dist/skills/loader.js +7 -1
  69. package/dist/skills/loader.js.map +1 -1
  70. package/dist/tools/builtins/bash.d.ts.map +1 -1
  71. package/dist/tools/builtins/bash.js +18 -6
  72. package/dist/tools/builtins/bash.js.map +1 -1
  73. package/dist/tools/builtins/skill.d.ts +2 -9
  74. package/dist/tools/builtins/skill.d.ts.map +1 -1
  75. package/dist/tools/builtins/skill.js +59 -51
  76. package/dist/tools/builtins/skill.js.map +1 -1
  77. package/dist/tools/command-shell.d.ts +90 -0
  78. package/dist/tools/command-shell.d.ts.map +1 -0
  79. package/dist/tools/command-shell.js +129 -0
  80. package/dist/tools/command-shell.js.map +1 -0
  81. package/dist/tools/defineTool.d.ts +11 -0
  82. package/dist/tools/defineTool.d.ts.map +1 -1
  83. package/dist/tools/defineTool.js +29 -1
  84. package/dist/tools/defineTool.js.map +1 -1
  85. package/dist/types/hitl/index.d.ts +23 -0
  86. package/dist/types/hitl/index.d.ts.map +1 -1
  87. package/dist/types/hitl/index.js.map +1 -1
  88. package/dist/types/provider/stream.d.ts +10 -0
  89. package/dist/types/provider/stream.d.ts.map +1 -1
  90. package/dist/types/tool/index.d.ts +67 -5
  91. package/dist/types/tool/index.d.ts.map +1 -1
  92. package/dist/types/tool/index.js.map +1 -1
  93. package/dist/utils/frontmatter.d.ts +17 -1
  94. package/dist/utils/frontmatter.d.ts.map +1 -1
  95. package/dist/utils/frontmatter.js +32 -2
  96. package/dist/utils/frontmatter.js.map +1 -1
  97. package/package.json +1 -1
  98. package/src/authorization/command-line.ts +148 -293
  99. package/src/authorization/gate.ts +8 -0
  100. package/src/authorization/rules.ts +33 -4
  101. package/src/authorization/shell-lexer.ts +2319 -0
  102. package/src/authorization/skill-grant.ts +400 -0
  103. package/src/persona/assembler.ts +5 -2
  104. package/src/prompt/coding-agent-doctrine.ts +1 -1
  105. package/src/public-runtime.ts +9 -0
  106. package/src/public-tools.ts +2 -1
  107. package/src/public-types.ts +7 -0
  108. package/src/runtime/jobs/registry.ts +19 -11
  109. package/src/runtime/query/executor.ts +99 -55
  110. package/src/runtime/query/index.ts +7 -0
  111. package/src/runtime/query/iteration/index.ts +5 -0
  112. package/src/runtime/query/iteration/phases/context.ts +7 -0
  113. package/src/runtime/query/iteration/phases/tool-review.ts +45 -0
  114. package/src/runtime/query/review-policy.ts +53 -0
  115. package/src/runtime/query/tooling.ts +4 -0
  116. package/src/skills/loader.ts +8 -1
  117. package/src/tools/builtins/bash.ts +24 -6
  118. package/src/tools/builtins/skill.ts +74 -53
  119. package/src/tools/command-shell.ts +166 -0
  120. package/src/tools/defineTool.ts +33 -1
  121. package/src/types/hitl/index.ts +21 -0
  122. package/src/types/provider/stream.ts +10 -0
  123. package/src/types/tool/index.ts +67 -5
  124. package/src/utils/frontmatter.ts +52 -2
@@ -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,9 +249,9 @@ 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,
@@ -382,6 +370,7 @@ export const SkillTool = defineTool({
382
370
  name: input.name,
383
371
  body: skill.body ?? '(this skill has no body)',
384
372
  allowedTools: allowed,
373
+ skillDirectory: skill.dirPath,
385
374
  invocation,
386
375
  }
387
376
  const digest = snapshotDigest(snapshot)
@@ -403,10 +392,20 @@ export const SkillTool = defineTool({
403
392
  start = parsed.offset
404
393
  }
405
394
 
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.]`
395
+ // Compiled before paging so the notice can say what the grant is, and
396
+ // committed only after paging succeeded: a load that fails here gave
397
+ // the model no instructions, so it must not have approved anything.
398
+ // Idempotent: a continuation call grants the same entries again, and
399
+ // the turn's set keeps one copy.
400
+ let grant: ReturnType<NonNullable<typeof context.grantSkillTools>> | undefined
401
+ if (allowed !== undefined && allowed.length > 0 && context.grantSkillTools) {
402
+ grant = context.grantSkillTools({
403
+ skill: skill.metadata.name,
404
+ allowedTools: allowed,
405
+ ...(snapshot.skillDirectory ? { skillDirectory: snapshot.skillDirectory } : {}),
406
+ })
407
+ }
408
+ const notice = grantNotice(allowed, grant, context.grantSkillTools !== undefined)
410
409
  const page = pageSkillBody({
411
410
  snapshot,
412
411
  digest,
@@ -421,23 +420,7 @@ export const SkillTool = defineTool({
421
420
  error: `The model-visible tool-output budget is too small to read "${input.name}" safely. Increase maxToolOutputChars and retry.`,
422
421
  }
423
422
  }
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
- }
423
+ grant?.commit()
441
424
 
442
425
  return {
443
426
  success: true,
@@ -445,8 +428,46 @@ export const SkillTool = defineTool({
445
428
  data: {
446
429
  skill: skill.metadata.name,
447
430
  ...(allowed === undefined ? {} : { allowedTools: allowed }),
431
+ ...(grant ? { granted: grant.granted, ignored: grant.ignored } : {}),
448
432
  ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
449
433
  },
450
434
  }
451
435
  },
452
436
  })
437
+
438
+ /**
439
+ * What the model is told about `allowed-tools`.
440
+ *
441
+ * The sentence that matters most is the second one. The old notice said
442
+ * "restrict yourself to", and a model told that does exactly what the owner
443
+ * reported: it stops using `bash` and tries to do the work through the skill.
444
+ * So the notice says, every time, that nothing was taken away.
445
+ */
446
+ function grantNotice(
447
+ allowed: readonly string[] | undefined,
448
+ grant:
449
+ | {
450
+ granted: readonly string[]
451
+ ignored: readonly { entry: string; reason: string }[]
452
+ }
453
+ | undefined,
454
+ canGrant: boolean,
455
+ ): string {
456
+ if (allowed === undefined || allowed.length === 0) return ''
457
+ const unchanged =
458
+ 'Every other tool remains available and is reviewed as usual; this skill does not limit which tools you may use.'
459
+ if (!canGrant || !grant) {
460
+ 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}]`
461
+ }
462
+ const lines: string[] = []
463
+ lines.push(
464
+ grant.granted.length > 0
465
+ ? `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.`
466
+ : 'Nothing in allowed-tools could be pre-approved.',
467
+ )
468
+ for (const { entry, reason } of grant.ignored) {
469
+ lines.push(`Ignored allowed-tools entry "${entry}": ${reason}.`)
470
+ }
471
+ lines.push(unchanged)
472
+ return `\n\n[${lines.join(' ')}]`
473
+ }
@@ -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,6 +61,8 @@ 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
66
68
  /** The argument asking to leave the sandbox; see {@link ToolDefinition.sandboxEscapeArgument}. */
@@ -68,6 +70,35 @@ export interface DefineToolOptions<S extends z.ZodType> {
68
70
  execute(input: z.infer<S>, context: ToolContext): Promise<ToolResult>
69
71
  }
70
72
 
73
+ /**
74
+ * The `isDestructive` functions built from a literal `destructive: true`.
75
+ *
76
+ * Such a tool is destructive for EVERY input, so no call of it can ever be
77
+ * approved without review — and a grant that names it (a skill's
78
+ * `allowed-tools: Write`) would be a promise the review phase never keeps.
79
+ * Kept here rather than as a field on the definition so the public
80
+ * `ToolDefinition` shape does not change; see {@link isAlwaysDestructive}.
81
+ */
82
+ const ALWAYS_DESTRUCTIVE = new WeakSet<object>()
83
+
84
+ /**
85
+ * Whether a tool declares every call destructive, whatever the input.
86
+ *
87
+ * Known only for a tool built by {@link defineTool} with `destructive: true`.
88
+ * A hand-written definition, or one whose flag depends on the input, answers
89
+ * `false`: its calls are still judged one by one, so nothing is lost but an
90
+ * early warning.
91
+ */
92
+ export function isAlwaysDestructive(tool: Pick<ToolDefinition, 'isDestructive'>): boolean {
93
+ return tool.isDestructive !== undefined && ALWAYS_DESTRUCTIVE.has(tool.isDestructive)
94
+ }
95
+
96
+ function constantDestructive(value: boolean): () => boolean {
97
+ const fn = () => value
98
+ if (value) ALWAYS_DESTRUCTIVE.add(fn)
99
+ return fn
100
+ }
101
+
71
102
  export function defineTool<S extends z.ZodType>(
72
103
  options: DefineToolOptions<S>,
73
104
  ): ToolDefinition<z.infer<S>> {
@@ -84,6 +115,7 @@ export function defineTool<S extends z.ZodType>(
84
115
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
85
116
  ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
86
117
  ...(options.commandArgument !== undefined ? { commandArgument: options.commandArgument } : {}),
118
+ ...(options.commandDialect !== undefined ? { commandDialect: options.commandDialect } : {}),
87
119
  ...(options.pathArgument !== undefined ? { pathArgument: options.pathArgument } : {}),
88
120
  ...(options.sandboxEscapeArgument !== undefined
89
121
  ? { sandboxEscapeArgument: options.sandboxEscapeArgument }
@@ -102,7 +134,7 @@ export function defineTool<S extends z.ZodType>(
102
134
  isDestructive:
103
135
  typeof options.destructive === 'function'
104
136
  ? options.destructive
105
- : () => options.destructive as boolean,
137
+ : constantDestructive(options.destructive as boolean),
106
138
  isConcurrencySafe: () => options.concurrencySafe,
107
139
 
108
140
  async execute(input: TInput, context: ToolContext): Promise<ToolResult> {
@@ -40,6 +40,15 @@ export type HITLResumeDecision =
40
40
  * person said yes to a batch that showed the escape.
41
41
  */
42
42
  confirmedEscalations?: readonly string[]
43
+ /**
44
+ * Ids of calls approved on the strength of a skill's `allowed-tools`
45
+ * grant ({@link ToolCallSummary.skillGrant}), with nobody asked.
46
+ *
47
+ * The kernel writes each one to the session's audit trail naming the
48
+ * skill, and only for a call that actually carried the grant — an id
49
+ * listed here for an unmarked call is ignored rather than trusted.
50
+ */
51
+ skillGranted?: readonly string[]
43
52
  }
44
53
  | {
45
54
  action: 'modify_tools'
@@ -126,6 +135,18 @@ export interface ToolCallSummary {
126
135
  * approve it on their own. A `deny` rule still refuses it outright.
127
136
  */
128
137
  escalation?: ToolCallEscalation
138
+ /**
139
+ * Present when a skill loaded earlier in this turn pre-approved this call
140
+ * through its `allowed-tools`, and nothing stronger stands in the way.
141
+ *
142
+ * Marked only on a call the operator's policy left to review (no `deny`,
143
+ * no explicit `ask` rule), that is not destructive and that carries no
144
+ * {@link escalation}. The review policy decides what the mark is worth:
145
+ * `createReviewHandler` approves a batch without asking when every call
146
+ * it would have asked about carries one, and still refuses under `plan`
147
+ * and `strict`. A host's own handler may ignore it.
148
+ */
149
+ skillGrant?: { readonly skill: string }
129
150
  }
130
151
 
131
152
  /** See {@link ToolCallSummary.escalation}. */
@@ -16,6 +16,16 @@ export interface StreamChunk {
16
16
  id: string
17
17
  name: 'web_search'
18
18
  status: 'running' | 'completed' | 'failed'
19
+ /**
20
+ * What the provider searched for, when it says. Often absent on
21
+ * `running` and present on the terminal chunk: a provider may decide
22
+ * the query while the call is already under way.
23
+ */
24
+ query?: string
25
+ /** The page the provider opened or searched within, for a page action rather than a query. */
26
+ url?: string
27
+ /** How many sources the provider reported for this call. Absent means unknown, not zero. */
28
+ results?: number
19
29
  }
20
30
 
21
31
  content?: string
@@ -77,6 +77,8 @@ export interface SkillRegistryRef {
77
77
  invocation?: 'model' | 'operator' | 'both'
78
78
  }
79
79
  body?: string
80
+ /** The skill's directory, which `${CLAUDE_SKILL_DIR}` in `allowed-tools` names. */
81
+ dirPath?: string
80
82
  }
81
83
  }
82
84
  | undefined
@@ -473,18 +475,56 @@ export interface ToolContext {
473
475
  }
474
476
 
475
477
  /**
476
- * Adopt the tool scope a skill declared.
478
+ * Formerly: narrow the turn's tools to what a skill's `allowed-tools`
479
+ * named. That reading was backwards — the field pre-approves, it never
480
+ * restricts — and the kernel no longer supplies this member, so a tool
481
+ * that calls it through `?.` does nothing.
477
482
  *
478
- * Called by the `skill` tool when a loaded skill names `allowed-tools`.
479
- * The scope INTERSECTS what the turn already allows and takes effect from
480
- * the next batch — a skill loaded alongside other calls must not
481
- * retroactively refuse them.
483
+ * @deprecated Never supplied by the kernel since `allowed-tools` became a
484
+ * pre-approval. Use {@link ToolContext.grantSkillTools}. Removed in the
485
+ * next major.
482
486
  */
483
487
  adoptSkillScope?: (scope: {
484
488
  skill: string
485
489
  allowedTools: readonly string[]
486
490
  }) => void
487
491
 
492
+ /**
493
+ * Pre-approve what a loaded skill's `allowed-tools` names, for the rest of
494
+ * this turn.
495
+ *
496
+ * Called by the `skill` tool. It never narrows anything: every tool the
497
+ * turn had stays callable, and a call the grant does not cover is reviewed
498
+ * exactly as before. A covered call skips the approval prompt, but not an
499
+ * operator `deny` or `ask` rule, plan mode, `strict` mode, a destructive
500
+ * call or one that reaches outside the turn's roots or sandbox. Each call
501
+ * approved this way is written to the session's audit trail naming the
502
+ * skill.
503
+ *
504
+ * Returns what would be granted and what was ignored (an unknown tool
505
+ * name, a pattern on a tool without a command line, a tool every call of
506
+ * which is destructive), so the tool can tell the model. Nothing is
507
+ * recorded until `commit()` is called: the caller commits only once the
508
+ * skill's instructions have actually been delivered, so a load that fails
509
+ * afterwards leaves no approval behind. Absent outside a turn, where
510
+ * there is nothing to grant into.
511
+ */
512
+ grantSkillTools?: (grant: {
513
+ readonly skill: string
514
+ /** The parsed entries, as `parseAllowedTools` returns them. */
515
+ readonly allowedTools: readonly string[]
516
+ /** The skill's directory, for `${CLAUDE_SKILL_DIR}` / `${NAMZU_SKILL_DIR}`. */
517
+ readonly skillDirectory?: string
518
+ }) => {
519
+ readonly granted: readonly string[]
520
+ readonly ignored: readonly {
521
+ readonly entry: string
522
+ readonly reason: string
523
+ }[]
524
+ /** Record the grant in the turn. Idempotent. */
525
+ readonly commit: () => void
526
+ }
527
+
488
528
  /**
489
529
  * Effective model-visible character cap for this tool result.
490
530
  *
@@ -677,6 +717,14 @@ export interface ToolResult {
677
717
  workingState?: readonly import('../../compaction/types.js').WorkingStatePin[]
678
718
  }
679
719
 
720
+ /**
721
+ * The shell a command line will run in, as the permission rules read it.
722
+ * `bash` reads it as bash does. `sh` reads it for a shell that may be bash or
723
+ * a POSIX shell such as `dash`: every construct the two read differently
724
+ * makes the line opaque, so a line that is not opaque means the same in both.
725
+ */
726
+ export type ShellDialect = 'bash' | 'sh'
727
+
680
728
  export interface ToolDefinition<TInput = unknown> extends ToolPresentation<TInput> {
681
729
  name: string
682
730
  description: string
@@ -737,6 +785,20 @@ export interface ToolDefinition<TInput = unknown> extends ToolPresentation<TInpu
737
785
  * chaining — has nothing to decompose and must not claim otherwise.
738
786
  */
739
787
  commandArgument?: string
788
+ /**
789
+ * The shell {@link commandArgument} runs in, as the dialect the permission
790
+ * rules must read it in.
791
+ *
792
+ * A rule reads a command line before it runs, and the reading is only
793
+ * right for the shell that runs it: `$'\x3b'`, `|&` and `&>` mean one
794
+ * thing to bash and another to `dash`. `bash` reads the line as bash
795
+ * does. `sh`, the default when this is absent, reads it for a shell that
796
+ * may be bash or a POSIX shell, and every construct the two read
797
+ * differently makes the line opaque, so no allow rule approves it. The
798
+ * shipped `bash` tool answers `bash` when it will spawn bash on the host
799
+ * and `sh` inside a sandbox, whose guest may not have bash.
800
+ */
801
+ commandDialect?: (context: { readonly sandboxed: boolean }) => ShellDialect
740
802
  /**
741
803
  * The argument that holds a filesystem path the tool resolves against the
742
804
  * turn's roots (the working directory and the added directories).