@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,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,6 +35,7 @@ import type {
33
35
  FileReadTracker,
34
36
  PreparedToolExecution,
35
37
  RequestToolPause,
38
+ ShellDialect,
36
39
  SkillRegistryRef,
37
40
  ToolContext,
38
41
  ToolDispatchOptions,
@@ -449,6 +452,12 @@ export interface ToolExecutorConfig {
449
452
  authorizationGate?: AuthorizationGate
450
453
  /** Durable refusal sink paired with {@link authorizationGate}. */
451
454
  recordAudit?: (input: AuditEventInput) => Promise<unknown>
455
+ /**
456
+ * Where the `skill` tool's `allowed-tools` pre-approvals are recorded for
457
+ * this turn. The review phase reads the same set. Absent: a loaded skill
458
+ * grants nothing, and its tool says so.
459
+ */
460
+ skillGrants?: SkillGrantSet
452
461
  }
453
462
 
454
463
  /**
@@ -692,58 +701,83 @@ export class ToolExecutor {
692
701
  private batchMode?: PermissionMode
693
702
 
694
703
  /**
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.
704
+ * The step's list where it has one; the turn's is the default.
701
705
  *
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.
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.
706
+ * A loaded skill no longer narrows this. Its `allowed-tools` used to be
707
+ * intersected in here from the next batch on, which read the field as a
708
+ * restriction when it is a pre-approval; see `grantSkillTools`.
719
709
  */
720
- private skillScope?: {
721
- skill: string
722
- allowedTools: readonly string[]
723
- adoptedInBatch: number
710
+ private effectiveAllowedTools(): readonly string[] | undefined {
711
+ return this.stepAllowedTools ?? this.config.allowedTools
724
712
  }
725
- private batchCounter = 0
726
713
 
727
714
  /**
728
- * The step's list, narrowed by any skill scope in force.
715
+ * Compile a skill's `allowed-tools` into pre-approvals for the rest of the
716
+ * turn, say what they would be, and record them only on `commit()`.
729
717
  *
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".
718
+ * Names resolve against THIS turn's registry, case-insensitively and
719
+ * through the Agent Skills aliases (`Read` is `read`, `WebFetch` is
720
+ * `web_fetch`), so a grant can only ever name a tool the turn already has.
721
+ * An entry that resolves to nothing, or to a tool every call of which is
722
+ * destructive (and therefore always reviewed), is reported and grants
723
+ * nothing.
736
724
  *
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.
725
+ * Two steps because the `skill` tool can still fail after it knows what
726
+ * to say — its instructions may not fit the output budget — and a skill
727
+ * the model never received must not have approved anything.
740
728
  */
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))
729
+ private grantSkillTools(grant: {
730
+ readonly skill: string
731
+ readonly allowedTools: readonly string[]
732
+ readonly skillDirectory?: string
733
+ }): {
734
+ readonly granted: readonly string[]
735
+ readonly ignored: readonly {
736
+ readonly entry: string
737
+ readonly reason: string
738
+ }[]
739
+ readonly commit: () => void
740
+ } {
741
+ const grants = this.config.skillGrants
742
+ if (!grants) return { granted: [], ignored: [], commit: () => {} }
743
+ const tools = this.config.tools
744
+ const byLowerName = new Map<string, string>()
745
+ for (const name of tools.listNames()) byLowerName.set(name.toLowerCase(), name)
746
+ // A grant can only ever name a tool this turn — or this step — can call.
747
+ // The registry holds more than that when `allowedTools` withholds some,
748
+ // and telling the model a withheld tool is pre-approved is a promise the
749
+ // executor will refuse to keep.
750
+ const allowed = this.effectiveAllowedTools()
751
+ const compiled = compileSkillGrant(grant.allowedTools, {
752
+ resolveTool: (name) => {
753
+ const registered = byLowerName.get(name.toLowerCase())
754
+ if (registered === undefined) return undefined
755
+ const definition = tools.get(registered)
756
+ const commandArgument = definition?.commandArgument
757
+ return {
758
+ name: registered,
759
+ ...(allowed !== undefined && !allowed.includes(registered) ? { unavailable: true } : {}),
760
+ ...(commandArgument === undefined ? {} : { commandArgument }),
761
+ ...(definition && isAlwaysDestructive(definition) ? { alwaysDestructive: true } : {}),
762
+ }
763
+ },
764
+ ...(grant.skillDirectory ? { skillDirectory: grant.skillDirectory } : {}),
765
+ })
766
+ return {
767
+ granted: compiled.entries.map((entry) =>
768
+ entry.pattern === undefined ? entry.tool : entry.declared,
769
+ ),
770
+ ignored: compiled.ignored,
771
+ commit: () => {
772
+ grants.grant(grant.skill, compiled)
773
+ if (compiled.ignored.length > 0) {
774
+ this.log.warn('Skill allowed-tools entries were ignored', {
775
+ 'namzu.skill.name': grant.skill,
776
+ 'namzu.skill.ignored': compiled.ignored.map((item) => item.entry),
777
+ })
778
+ }
779
+ },
780
+ }
747
781
  }
748
782
 
749
783
  private resolvePermissionMode(): PermissionMode {
@@ -757,9 +791,20 @@ export class ToolExecutor {
757
791
  toolName,
758
792
  toolInput: input,
759
793
  toolDef: this.config.tools.get(toolName),
794
+ commandDialect: this.commandDialect(toolName),
760
795
  })
761
796
  }
762
797
 
798
+ /**
799
+ * The shell a tool's command line will run in this turn, for the
800
+ * permission rules to read it in. A tool that does not say is `sh`, the
801
+ * reading that holds for any POSIX shell.
802
+ */
803
+ commandDialect(toolName: string): ShellDialect {
804
+ const tool = this.config.tools.get(toolName)
805
+ return tool?.commandDialect?.({ sandboxed: this.config.sandbox !== undefined }) ?? 'sh'
806
+ }
807
+
763
808
  /**
764
809
  * Resolve repairs and pre-tool hooks, then decode each call exactly once.
765
810
  * The returned projection is what policy and a human review; execution later
@@ -883,8 +928,6 @@ export class ToolExecutor {
883
928
  }
884
929
  assertUniqueToolCallIds(toolCalls)
885
930
 
886
- this.batchCounter += 1
887
-
888
931
  // Sampled here, once, and held for every call below. See the note on
889
932
  // `permissionMode` in the config type.
890
933
  this.batchMode = this.resolvePermissionMode()
@@ -1212,6 +1255,7 @@ export class ToolExecutor {
1212
1255
  toolName: name,
1213
1256
  toolInput: preparedInput,
1214
1257
  toolDef: this.config.tools.get(name),
1258
+ commandDialect: this.commandDialect(name),
1215
1259
  })
1216
1260
  if (gateResult && gateResult.decision !== 'allow') {
1217
1261
  const reason =
@@ -1442,11 +1486,11 @@ export class ToolExecutor {
1442
1486
  // Same precedence the request already uses when it decides which
1443
1487
  // schemas to send, so the menu and the kitchen agree.
1444
1488
  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
- },
1489
+ // A skill loaded during this batch pre-approves calls from the NEXT
1490
+ // review on; this batch was already reviewed. See `grantSkillTools`.
1491
+ ...(this.config.skillGrants
1492
+ ? { grantSkillTools: (grant) => this.grantSkillTools(grant) }
1493
+ : {}),
1450
1494
  maxToolOutputChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
1451
1495
  // The turn's screens, defaulted HERE rather than on the registry: a
1452
1496
  // host builds the registry and hands it over, so a registry-side
@@ -1498,11 +1542,11 @@ export class ToolExecutor {
1498
1542
  readonly env?: Record<string, string>
1499
1543
  }): JobProcess =>
1500
1544
  (this.config.sandbox as Sandbox).spawnDetached?.(
1501
- '/bin/sh',
1502
- ['-c', job.command],
1545
+ sandboxShellSpawn(job.command).file,
1546
+ sandboxShellSpawn(job.command).args,
1503
1547
  {
1504
1548
  cwd: job.workingDirectory,
1505
- ...(job.env ? { env: job.env } : {}),
1549
+ ...(job.env ? { env: withoutBashStartup(job.env) } : {}),
1506
1550
  },
1507
1551
  ) as JobProcess,
1508
1552
  }
@@ -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,
@@ -1496,6 +1502,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1496
1502
  // Turn-scoped. An approval is a statement about this turn's work;
1497
1503
  // carrying one into a later turn would be reuse nobody agreed to.
1498
1504
  toolGrants: new ToolGrantSet(),
1505
+ skillGrants,
1499
1506
  ...(params.reviewAllowedCalls ? { reviewAllowedCalls: params.reviewAllowedCalls } : {}),
1500
1507
  // Turn-scoped for the same reason. A repeat count carried into a later
1501
1508
  // run is a claim about work nobody repeated, and a module-level map
@@ -1826,6 +1826,11 @@ export class IterationOrchestrator {
1826
1826
  private rememberUserMessage(message: Message, arriving = true): void {
1827
1827
  if (!isOperatorUserMessage(message)) return
1828
1828
  this.latestUserMessage = message
1829
+ // A skill's `allowed-tools` pre-approves for the request that loaded
1830
+ // it. The operator speaking again — a queued message or steering
1831
+ // delivered into this same `query()` — is a new request, and it starts
1832
+ // with nothing pre-approved, exactly as a new turn would.
1833
+ if (arriving) this.ctx.skillGrants?.clear()
1829
1834
  // The hook field alone does not reach the model. Preserve arrivals in
1830
1835
  // the compaction state too, before their original message or attached
1831
1836
  // 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'
@@ -166,6 +167,12 @@ export interface IterationContext {
166
167
  * asked about again. Absent on paths that do not review tools.
167
168
  */
168
169
  readonly toolGrants?: ToolGrantSet
170
+ /**
171
+ * What skills loaded in this turn pre-approve (`allowed-tools`). Read by
172
+ * the review phase to mark covered calls; the review policy decides
173
+ * whether a mark skips the prompt. Absent: nothing is marked.
174
+ */
175
+ readonly skillGrants?: SkillGrantSet
169
176
  /** See `QueryParams.reviewAllowedCalls`. Absent: allowed and granted batches skip review. */
170
177
  readonly reviewAllowedCalls?: () => boolean
171
178
  /**
@@ -2,6 +2,7 @@ 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'
5
6
  import type { PreparedToolBatch, ToolCallDenials } from '../../executor.js'
6
7
  import {
7
8
  awaitProjectInstructionCallback,
@@ -53,6 +54,15 @@ export async function* runToolReview(
53
54
  ): AsyncGenerator<SessionEvent, ToolReviewOutcome> {
54
55
  let executed: readonly import('../../executor.js').ToolCallOutcome[] = []
55
56
  let toolMs = 0
57
+ // The shell each call's command line will run in, for the rules and the
58
+ // skill grants to read it the same way. A test double without the method
59
+ // leaves it unset, which reads the line for any POSIX shell.
60
+ const dialectFor = (toolName: string): { commandDialect?: ShellDialect } => {
61
+ const executor = ctx.toolExecutor as { commandDialect?: (name: string) => ShellDialect }
62
+ return typeof executor.commandDialect === 'function'
63
+ ? { commandDialect: executor.commandDialect(toolName) }
64
+ : {}
65
+ }
56
66
 
57
67
  const finish = (decision: ToolReviewDecision): ToolReviewOutcome => ({
58
68
  decision,
@@ -255,6 +265,7 @@ export async function* runToolReview(
255
265
  toolName: tc.name,
256
266
  toolInput: tc.input,
257
267
  toolDef: ctx.tools.get(tc.name),
268
+ ...dialectFor(tc.name),
258
269
  }),
259
270
  }))
260
271
  for (const gr of gateResults) {
@@ -331,6 +342,24 @@ export async function* runToolReview(
331
342
  })
332
343
  }
333
344
 
345
+ // A skill's `allowed-tools` pre-approval, marked on the calls it covers
346
+ // and left for the review policy to honour. Marked, never decided here:
347
+ // only the policy knows the mode, and `plan` and `strict` must refuse a
348
+ // call a skill granted exactly as they refuse any other. Nothing stronger
349
+ // may stand in the way — an operator's deny or explicit ask, a
350
+ // destructive call, a path outside the roots or a sandbox escape all
351
+ // leave the call unmarked, so it is reviewed as though no skill had
352
+ // spoken.
353
+ if (ctx.skillGrants && ctx.skillGrants.size > 0) {
354
+ for (const tc of toolCallSummaries) {
355
+ if (gateDenied.has(tc.id)) continue
356
+ if (tc.authorization?.decision === 'deny' || tc.authorization?.explicitReview) continue
357
+ if (tc.isDestructive || tc.escalation !== undefined) continue
358
+ const skill = ctx.skillGrants.coveringSkill(tc, ctx.tools.get(tc.name), dialectFor(tc.name))
359
+ if (skill !== undefined) tc.skillGrant = { skill }
360
+ }
361
+ }
362
+
334
363
  // Already approved, at a scope the approver chose — and nothing the
335
364
  // operator's policy denied, because `gateDenied` is checked first.
336
365
  // Re-asking about a call somebody has already said yes to is how an
@@ -490,6 +519,7 @@ export async function* runToolReview(
490
519
  toolName: summary.name,
491
520
  toolInput: summary.input,
492
521
  toolDef: ctx.tools.get(summary.name),
522
+ ...dialectFor(summary.name),
493
523
  })
494
524
  if (gateResult.decision === 'allow') continue
495
525
  const reason =
@@ -550,6 +580,21 @@ export async function* runToolReview(
550
580
  if (reviewDecision.action === 'approve_tools' && reviewDecision.remember) {
551
581
  ctx.toolGrants?.grant(reviewDecision.remember)
552
582
  }
583
+ // A call nobody was asked about, approved because a skill said so,
584
+ // is on the record naming that skill. Only a call that carried the
585
+ // mark counts: a policy that lists an unmarked id has not been
586
+ // given a skill's word for it.
587
+ if (reviewDecision.action === 'approve_tools' && reviewDecision.skillGranted) {
588
+ const listed = new Set(reviewDecision.skillGranted)
589
+ for (const tc of toolCallSummaries) {
590
+ if (!listed.has(tc.id) || !tc.skillGrant || gateDenied.has(tc.id)) continue
591
+ await ctx.recorder.recordAudit({
592
+ what: { action: 'tool_call', tool: tc.name },
593
+ outcome: 'approved',
594
+ reason: `pre-approved by the allowed-tools of skill "${tc.skillGrant.skill}" for this turn; nobody was asked`,
595
+ })
596
+ }
597
+ }
553
598
 
554
599
  await ctx.emitEvent({
555
600
  type: 'tool_review_completed',
@@ -238,6 +238,33 @@ export interface ReviewPolicyOptions {
238
238
  * in every mode that does not refuse the call outright.
239
239
  */
240
240
  readonly unattendedSandboxEscape?: 'refuse' | 'allow'
241
+ /**
242
+ * Whether a skill's `allowed-tools` pre-approval stands in for a person.
243
+ *
244
+ * `'honour'` (the default) approves a batch without asking when every call
245
+ * that would be asked about carries `ToolCallSummary.skillGrant`. `'ignore'`
246
+ * asks as though no skill had been loaded — the behaviour before
247
+ * `allowed-tools` was read as a pre-approval, for a host that does not want
248
+ * repository content to reduce its prompts. `plan` and `strict` refuse
249
+ * either way.
250
+ */
251
+ readonly skillGrants?: 'honour' | 'ignore'
252
+ }
253
+
254
+ /**
255
+ * Whether a skill's `allowed-tools` grant may stand in for a person on this
256
+ * call: the kernel marked it, and nothing that outranks a skill is present.
257
+ * The second half repeats what the kernel checked before marking, so a mark
258
+ * on a persisted or host-built summary cannot carry more than it should.
259
+ */
260
+ function isSkillGranted(tc: ToolCallSummary): boolean {
261
+ return (
262
+ tc.skillGrant !== undefined &&
263
+ !tc.authorization?.explicitReview &&
264
+ tc.authorization?.decision !== 'deny' &&
265
+ !tc.isDestructive &&
266
+ tc.escalation === undefined
267
+ )
241
268
  }
242
269
 
243
270
  /** The handler behind `createReviewPolicy`, for a host that wants only the function. */
@@ -338,6 +365,32 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
338
365
  if (mode === 'auto' || !prompt || remembered.all) {
339
366
  return { action: 'approve_tools' }
340
367
  }
368
+ // Every call a person would be asked about is one a skill loaded in
369
+ // this turn pre-approved (`allowed-tools`). Reached only in `prompt`
370
+ // and `accept-edits`: `plan` and `strict` refused above, so a skill's
371
+ // word never outranks either, and the kernel only marks a call no
372
+ // deny, explicit ask, destructive flag or escalation stands behind.
373
+ // One unmarked call and the whole batch is asked about, as always.
374
+ const needsPerson = request.toolCalls.filter(
375
+ (tc) =>
376
+ tc.authorization?.explicitReview ||
377
+ tc.isDestructive ||
378
+ tc.escalation !== undefined ||
379
+ !(
380
+ exempt(tc.name, tc.input) ||
381
+ (mode === 'accept-edits' && ACCEPT_EDITS_TOOLS.has(tc.name))
382
+ ),
383
+ )
384
+ if (
385
+ options.skillGrants !== 'ignore' &&
386
+ needsPerson.length > 0 &&
387
+ needsPerson.every(isSkillGranted)
388
+ ) {
389
+ return {
390
+ action: 'approve_tools',
391
+ skillGranted: needsPerson.map((tc) => tc.id),
392
+ }
393
+ }
341
394
  const answer = await prompt({
342
395
  sessionId: request.sessionId,
343
396
  turnId: request.turnId,
@@ -1,4 +1,5 @@
1
1
  import type { AuthorizationGate } from '../../authorization/gate.js'
2
+ import type { SkillGrantSet } from '../../authorization/skill-grant.js'
2
3
  import type { PluginLifecycleManager } from '../../plugin/lifecycle.js'
3
4
  import type { ActivityStore } from '../../store/activity/memory.js'
4
5
  import type { SessionId, TurnId } from '../../types/ids/index.js'
@@ -67,6 +68,8 @@ export interface ToolingBootstrapConfig {
67
68
  recordAudit?: (input: AuditEventInput) => Promise<unknown>
68
69
  /** Builds the durable-pause seam for one tool call; see ToolContext.requestPause. */
69
70
  toolPause?: (toolUseId: string) => RequestToolPause
71
+ /** The turn's skill pre-approvals; see the executor's own field. */
72
+ skillGrants?: SkillGrantSet
70
73
  }
71
74
 
72
75
  export class ToolingBootstrap {
@@ -130,6 +133,7 @@ export class ToolingBootstrap {
130
133
  : {}),
131
134
  ...(config.recordAudit !== undefined ? { recordAudit: config.recordAudit } : {}),
132
135
  ...(config.toolPause !== undefined ? { toolPause: config.toolPause } : {}),
136
+ ...(config.skillGrants !== undefined ? { skillGrants: config.skillGrants } : {}),
133
137
  },
134
138
  activityStore,
135
139
  emitEvent,
@@ -12,6 +12,13 @@ import { type Logger, resolveLogger } from '../utils/logger.js'
12
12
 
13
13
  export const SKILL_FILENAME = 'SKILL.md'
14
14
 
15
+ /**
16
+ * `allowed-tools` may be a YAML list. The Agent Skills format writes it
17
+ * space-separated, comma-separated or as a list, and all three must mean the
18
+ * same grant; the reader joins a list into the comma form.
19
+ */
20
+ export const SKILL_FRONTMATTER_OPTIONS = { lists: ['allowed-tools'] } as const
21
+
15
22
  /**
16
23
  * How this file's errors name themselves. Passed to the shared reader so a
17
24
  * frontmatter failure still reads as a `SKILL.md` failure — the reader is
@@ -152,7 +159,7 @@ export async function loadSkill(
152
159
  ): Promise<SkillLoadResult> {
153
160
  const skillMdPath = join(dirPath, SKILL_FILENAME)
154
161
  const raw = await readFile(skillMdPath, 'utf-8')
155
- const parsed = parseFrontmatter(raw, sourceLabel(dirPath))
162
+ const parsed = parseFrontmatter(raw, sourceLabel(dirPath), SKILL_FRONTMATTER_OPTIONS)
156
163
  const metadata = toSkillMetadata(parsed, dirPath)
157
164
 
158
165
  const skill: Skill = {
@@ -6,6 +6,12 @@ import { DANGEROUS_PATTERNS } from '../../constants/tools/index.js'
6
6
  import { killTree } from '../../process/kill-tree.js'
7
7
  import { subscribeToAbort } from '../../utils/abort.js'
8
8
  import { readPositiveIntEnv } from '../../utils/env.js'
9
+ import {
10
+ bashToolDialect,
11
+ hostShellSpawn,
12
+ sandboxShellSpawn,
13
+ withoutBashStartup,
14
+ } from '../command-shell.js'
9
15
  import { defineTool } from '../defineTool.js'
10
16
  import { scrubInheritedEnv } from '../env-scrub.js'
11
17
 
@@ -146,13 +152,20 @@ function execHostShell(
146
152
  ): Promise<{ stdout: string; stderr: string }> {
147
153
  options.signal?.throwIfAborted()
148
154
  return new Promise((resolve, reject) => {
149
- const child = spawn(command, {
155
+ // bash where the host has it, `/bin/sh` where it does not; the
156
+ // permission rules read the line in the matching dialect. See
157
+ // `../command-shell.ts`.
158
+ const shell = hostShellSpawn(command, options.env)
159
+ const spawnOptions = {
150
160
  cwd: options.cwd,
151
- env: options.env,
152
- shell: true,
161
+ env: shell.env,
153
162
  // killTree's negative PID must never target the caller's own group.
154
163
  detached: process.platform !== 'win32',
155
- })
164
+ }
165
+ const child =
166
+ shell.file === undefined
167
+ ? spawn(command, { ...spawnOptions, shell: true })
168
+ : spawn(shell.file, [...shell.args], spawnOptions)
156
169
  const captures = {
157
170
  stdout: {
158
171
  chunks: [] as Buffer[],
@@ -302,6 +315,8 @@ export const BashTool = defineTool({
302
315
  // so a permission rule about it is a rule about several commands more often
303
316
  // than not. Naming the argument is what lets the gate read it that way.
304
317
  commandArgument: 'command',
318
+ // The shell that runs it, so the rules read the line as that shell will.
319
+ commandDialect: bashToolDialect,
305
320
  // The kernel reviews a call that sets it under a sandbox every time and
306
321
  // confirms it only by id; see `ToolDefinition.sandboxEscapeArgument`.
307
322
  sandboxEscapeArgument: 'dangerously_disable_sandbox',
@@ -414,9 +429,12 @@ export const BashTool = defineTool({
414
429
  // builtin doesn't have that requirement today.
415
430
  const onOutput = shellProgress(context.report)
416
431
  if (context.sandbox && !leaveSandbox) {
417
- const result = await context.sandbox.exec('/bin/sh', ['-c', input.command], {
432
+ // bash if the guest has it, `/bin/sh` if not; read in the `sh`
433
+ // dialect, which holds for both.
434
+ const launch = sandboxShellSpawn(input.command)
435
+ const result = await context.sandbox.exec(launch.file, launch.args, {
418
436
  timeout: input.timeout,
419
- env: context.env,
437
+ ...(context.env ? { env: withoutBashStartup(context.env) } : {}),
420
438
  // Same reason as the host path below: a Stop must reach the
421
439
  // process, not just the promise waiting on it.
422
440
  signal: context.abortSignal,