@gaunt-sloth/core 2.0.0-alpha.9 → 2.0.0-beta.1

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 (275) hide show
  1. package/.gsloth.review.md +2 -0
  2. package/README.md +71 -20
  3. package/dist/config/colour.d.ts +38 -0
  4. package/dist/config/colour.js +36 -0
  5. package/dist/config/colour.js.map +1 -0
  6. package/dist/config/configDiscovery.d.ts +79 -0
  7. package/dist/config/configDiscovery.js +80 -0
  8. package/dist/config/configDiscovery.js.map +1 -0
  9. package/dist/config/defaults.d.ts +20 -20
  10. package/dist/config/defaults.js +10 -8
  11. package/dist/config/defaults.js.map +1 -1
  12. package/dist/config/filesystem-tools.d.ts +41 -0
  13. package/dist/config/filesystem-tools.js +56 -0
  14. package/dist/config/filesystem-tools.js.map +1 -0
  15. package/dist/config/loader.d.ts +171 -19
  16. package/dist/config/loader.js +1054 -144
  17. package/dist/config/loader.js.map +1 -1
  18. package/dist/config/mouse.d.ts +50 -0
  19. package/dist/config/mouse.js +44 -0
  20. package/dist/config/mouse.js.map +1 -0
  21. package/dist/config/profiles.d.ts +68 -0
  22. package/dist/config/profiles.js +93 -0
  23. package/dist/config/profiles.js.map +1 -0
  24. package/dist/config/providerKeys.d.ts +69 -0
  25. package/dist/config/providerKeys.js +69 -0
  26. package/dist/config/providerKeys.js.map +1 -0
  27. package/dist/config/schema.d.ts +2671 -138
  28. package/dist/config/schema.js +1361 -85
  29. package/dist/config/schema.js.map +1 -1
  30. package/dist/config/shell-policy.d.ts +899 -111
  31. package/dist/config/shell-policy.js +800 -70
  32. package/dist/config/shell-policy.js.map +1 -1
  33. package/dist/config/tool-descriptions.d.ts +211 -0
  34. package/dist/config/tool-descriptions.js +272 -0
  35. package/dist/config/tool-descriptions.js.map +1 -0
  36. package/dist/config/types.d.ts +352 -41
  37. package/dist/config/types.js +1 -0
  38. package/dist/config/types.js.map +1 -1
  39. package/dist/config.d.ts +35 -1
  40. package/dist/config.js +16 -1
  41. package/dist/config.js.map +1 -1
  42. package/dist/constants.d.ts +28 -1
  43. package/dist/constants.js +28 -1
  44. package/dist/constants.js.map +1 -1
  45. package/dist/core/GthAbstractAgent.d.ts +166 -11
  46. package/dist/core/GthAbstractAgent.js +484 -45
  47. package/dist/core/GthAbstractAgent.js.map +1 -1
  48. package/dist/core/GthAgentRunner.d.ts +543 -57
  49. package/dist/core/GthAgentRunner.js +1494 -140
  50. package/dist/core/GthAgentRunner.js.map +1 -1
  51. package/dist/core/GthLangChainAgent.d.ts +117 -2
  52. package/dist/core/GthLangChainAgent.js +602 -29
  53. package/dist/core/GthLangChainAgent.js.map +1 -1
  54. package/dist/core/approvals/annotations.d.ts +122 -0
  55. package/dist/core/approvals/annotations.js +137 -0
  56. package/dist/core/approvals/annotations.js.map +1 -0
  57. package/dist/core/approvals/grants.d.ts +216 -0
  58. package/dist/core/approvals/grants.js +469 -0
  59. package/dist/core/approvals/grants.js.map +1 -0
  60. package/dist/core/approvals/matcher.d.ts +202 -0
  61. package/dist/core/approvals/matcher.js +267 -0
  62. package/dist/core/approvals/matcher.js.map +1 -0
  63. package/dist/core/approvals/mcpSubjects.d.ts +40 -0
  64. package/dist/core/approvals/mcpSubjects.js +99 -0
  65. package/dist/core/approvals/mcpSubjects.js.map +1 -0
  66. package/dist/core/approvals/promptHeader.d.ts +28 -0
  67. package/dist/core/approvals/promptHeader.js +62 -0
  68. package/dist/core/approvals/promptHeader.js.map +1 -0
  69. package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
  70. package/dist/core/approvals/toolAnnotationSources.js +277 -0
  71. package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
  72. package/dist/core/approvals/toolHost.d.ts +46 -0
  73. package/dist/core/approvals/toolHost.js +108 -0
  74. package/dist/core/approvals/toolHost.js.map +1 -0
  75. package/dist/core/debugCapture.d.ts +74 -0
  76. package/dist/core/debugCapture.js +100 -0
  77. package/dist/core/debugCapture.js.map +1 -0
  78. package/dist/core/gthLeanAgentFactory.d.ts +4 -4
  79. package/dist/core/gthLeanAgentFactory.js +4 -4
  80. package/dist/core/launchBanner.d.ts +127 -0
  81. package/dist/core/launchBanner.js +414 -0
  82. package/dist/core/launchBanner.js.map +1 -0
  83. package/dist/core/modelLabel.d.ts +19 -0
  84. package/dist/core/modelLabel.js +26 -0
  85. package/dist/core/modelLabel.js.map +1 -0
  86. package/dist/core/plainToolIndication.d.ts +15 -0
  87. package/dist/core/plainToolIndication.js +174 -0
  88. package/dist/core/plainToolIndication.js.map +1 -0
  89. package/dist/core/reasoningBlocks.d.ts +65 -0
  90. package/dist/core/reasoningBlocks.js +103 -0
  91. package/dist/core/reasoningBlocks.js.map +1 -0
  92. package/dist/core/refusal.d.ts +53 -0
  93. package/dist/core/refusal.js +133 -0
  94. package/dist/core/refusal.js.map +1 -0
  95. package/dist/core/runHeader.d.ts +38 -0
  96. package/dist/core/runHeader.js +42 -0
  97. package/dist/core/runHeader.js.map +1 -0
  98. package/dist/core/runStats.d.ts +14 -3
  99. package/dist/core/runStats.js +48 -3
  100. package/dist/core/runStats.js.map +1 -1
  101. package/dist/core/shell/ShellCommandFailedError.d.ts +3 -4
  102. package/dist/core/shell/ShellCommandFailedError.js +3 -4
  103. package/dist/core/shell/ShellCommandFailedError.js.map +1 -1
  104. package/dist/core/shell/abstention.d.ts +88 -0
  105. package/dist/core/shell/abstention.js +184 -0
  106. package/dist/core/shell/abstention.js.map +1 -0
  107. package/dist/core/shell/approvalCapture.d.ts +271 -0
  108. package/dist/core/shell/approvalCapture.js +108 -0
  109. package/dist/core/shell/approvalCapture.js.map +1 -0
  110. package/dist/core/shell/approvalStop.d.ts +123 -0
  111. package/dist/core/shell/approvalStop.js +269 -0
  112. package/dist/core/shell/approvalStop.js.map +1 -0
  113. package/dist/core/shell/arity.d.ts +6 -0
  114. package/dist/core/shell/arity.js +20 -6
  115. package/dist/core/shell/arity.js.map +1 -1
  116. package/dist/core/shell/denylist.d.ts +11 -0
  117. package/dist/core/shell/denylist.js +37 -0
  118. package/dist/core/shell/denylist.js.map +1 -0
  119. package/dist/core/shell/escalationSeverity.d.ts +141 -0
  120. package/dist/core/shell/escalationSeverity.js +89 -0
  121. package/dist/core/shell/escalationSeverity.js.map +1 -0
  122. package/dist/core/shell/framing.d.ts +190 -0
  123. package/dist/core/shell/framing.js +633 -0
  124. package/dist/core/shell/framing.js.map +1 -0
  125. package/dist/core/shell/hardline.d.ts +103 -0
  126. package/dist/core/shell/hardline.js +780 -0
  127. package/dist/core/shell/hardline.js.map +1 -0
  128. package/dist/core/shell/negotiation.d.ts +328 -0
  129. package/dist/core/shell/negotiation.js +488 -0
  130. package/dist/core/shell/negotiation.js.map +1 -0
  131. package/dist/core/shell/normalize.d.ts +44 -4
  132. package/dist/core/shell/normalize.js +61 -7
  133. package/dist/core/shell/normalize.js.map +1 -1
  134. package/dist/core/shell/openWorld.d.ts +263 -0
  135. package/dist/core/shell/openWorld.js +1188 -0
  136. package/dist/core/shell/openWorld.js.map +1 -0
  137. package/dist/core/shell/rater.d.ts +873 -0
  138. package/dist/core/shell/rater.js +1454 -0
  139. package/dist/core/shell/rater.js.map +1 -0
  140. package/dist/core/shell/raterModel.d.ts +41 -0
  141. package/dist/core/shell/raterModel.js +51 -0
  142. package/dist/core/shell/raterModel.js.map +1 -0
  143. package/dist/core/shell/raterVocabulary.d.ts +121 -0
  144. package/dist/core/shell/raterVocabulary.js +116 -0
  145. package/dist/core/shell/raterVocabulary.js.map +1 -0
  146. package/dist/core/shell/rejection.d.ts +69 -0
  147. package/dist/core/shell/rejection.js +38 -0
  148. package/dist/core/shell/rejection.js.map +1 -0
  149. package/dist/core/toolCallRepair/grammar.d.ts +41 -0
  150. package/dist/core/toolCallRepair/grammar.js +116 -0
  151. package/dist/core/toolCallRepair/grammar.js.map +1 -0
  152. package/dist/core/toolCallRepair/index.d.ts +2 -0
  153. package/dist/core/toolCallRepair/index.js +7 -0
  154. package/dist/core/toolCallRepair/index.js.map +1 -0
  155. package/dist/core/toolCallRepair/payload.d.ts +36 -0
  156. package/dist/core/toolCallRepair/payload.js +341 -0
  157. package/dist/core/toolCallRepair/payload.js.map +1 -0
  158. package/dist/core/toolCallRepair/promote.d.ts +45 -0
  159. package/dist/core/toolCallRepair/promote.js +90 -0
  160. package/dist/core/toolCallRepair/promote.js.map +1 -0
  161. package/dist/core/toolDisplay.d.ts +123 -0
  162. package/dist/core/toolDisplay.js +451 -0
  163. package/dist/core/toolDisplay.js.map +1 -0
  164. package/dist/core/toolOutputChannel.d.ts +95 -0
  165. package/dist/core/toolOutputChannel.js +165 -0
  166. package/dist/core/toolOutputChannel.js.map +1 -0
  167. package/dist/core/types.d.ts +349 -16
  168. package/dist/core/types.js.map +1 -1
  169. package/dist/history/historyFormat.d.ts +12 -3
  170. package/dist/history/historyFormat.js +50 -8
  171. package/dist/history/historyFormat.js.map +1 -1
  172. package/dist/history/historyStore.d.ts +77 -0
  173. package/dist/history/historyStore.js +173 -6
  174. package/dist/history/historyStore.js.map +1 -1
  175. package/dist/history/recordSession.d.ts +10 -1
  176. package/dist/history/recordSession.js +27 -0
  177. package/dist/history/recordSession.js.map +1 -1
  178. package/dist/providers/anthropic.js +12 -0
  179. package/dist/providers/anthropic.js.map +1 -1
  180. package/dist/providers/configurationPassthrough.d.ts +107 -0
  181. package/dist/providers/configurationPassthrough.js +148 -0
  182. package/dist/providers/configurationPassthrough.js.map +1 -0
  183. package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
  184. package/dist/providers/geminiSchemaSanitizer.js +347 -0
  185. package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
  186. package/dist/providers/geminiThinking.d.ts +60 -0
  187. package/dist/providers/geminiThinking.js +92 -0
  188. package/dist/providers/geminiThinking.js.map +1 -0
  189. package/dist/providers/google-genai.js +18 -1
  190. package/dist/providers/google-genai.js.map +1 -1
  191. package/dist/providers/groq.js +12 -0
  192. package/dist/providers/groq.js.map +1 -1
  193. package/dist/providers/huggingface.d.ts +25 -0
  194. package/dist/providers/huggingface.js +69 -0
  195. package/dist/providers/huggingface.js.map +1 -0
  196. package/dist/providers/modelCatalog.d.ts +109 -0
  197. package/dist/providers/modelCatalog.js +245 -0
  198. package/dist/providers/modelCatalog.js.map +1 -0
  199. package/dist/providers/modelDiscovery.d.ts +99 -5
  200. package/dist/providers/modelDiscovery.js +191 -35
  201. package/dist/providers/modelDiscovery.js.map +1 -1
  202. package/dist/providers/ollama.d.ts +18 -4
  203. package/dist/providers/ollama.js +67 -37
  204. package/dist/providers/ollama.js.map +1 -1
  205. package/dist/providers/openai.js +34 -0
  206. package/dist/providers/openai.js.map +1 -1
  207. package/dist/providers/openrouter.d.ts +26 -4
  208. package/dist/providers/openrouter.js +83 -26
  209. package/dist/providers/openrouter.js.map +1 -1
  210. package/dist/providers/vertexai.js +19 -1
  211. package/dist/providers/vertexai.js.map +1 -1
  212. package/dist/providers/xai.js +20 -0
  213. package/dist/providers/xai.js.map +1 -1
  214. package/dist/runtime/askStructured.d.ts +105 -0
  215. package/dist/runtime/askStructured.js +120 -0
  216. package/dist/runtime/askStructured.js.map +1 -0
  217. package/dist/runtime/conversation.d.ts +64 -0
  218. package/dist/runtime/conversation.js +171 -0
  219. package/dist/runtime/conversation.js.map +1 -0
  220. package/dist/runtime/singleShot.d.ts +37 -6
  221. package/dist/runtime/singleShot.js +113 -67
  222. package/dist/runtime/singleShot.js.map +1 -1
  223. package/dist/runtime/structuredOutput.d.ts +104 -0
  224. package/dist/runtime/structuredOutput.js +393 -0
  225. package/dist/runtime/structuredOutput.js.map +1 -0
  226. package/dist/utils/ProgressIndicator.d.ts +21 -0
  227. package/dist/utils/ProgressIndicator.js +30 -3
  228. package/dist/utils/ProgressIndicator.js.map +1 -1
  229. package/dist/utils/aiignoreUtils.js.map +1 -1
  230. package/dist/utils/binaryOutputUtils.js.map +1 -1
  231. package/dist/utils/consoleUtils.d.ts +95 -0
  232. package/dist/utils/consoleUtils.js +112 -2
  233. package/dist/utils/consoleUtils.js.map +1 -1
  234. package/dist/utils/crashHandler.d.ts +87 -0
  235. package/dist/utils/crashHandler.js +128 -0
  236. package/dist/utils/crashHandler.js.map +1 -0
  237. package/dist/utils/debugDump.d.ts +134 -0
  238. package/dist/utils/debugDump.js +381 -0
  239. package/dist/utils/debugDump.js.map +1 -0
  240. package/dist/utils/debugUtils.d.ts +13 -4
  241. package/dist/utils/debugUtils.js +36 -13
  242. package/dist/utils/debugUtils.js.map +1 -1
  243. package/dist/utils/displayWidth.d.ts +53 -0
  244. package/dist/utils/displayWidth.js +195 -0
  245. package/dist/utils/displayWidth.js.map +1 -0
  246. package/dist/utils/fileUtils.js.map +1 -1
  247. package/dist/utils/globalConfigUtils.d.ts +14 -2
  248. package/dist/utils/globalConfigUtils.js +22 -4
  249. package/dist/utils/globalConfigUtils.js.map +1 -1
  250. package/dist/utils/llmUtils.d.ts +39 -8
  251. package/dist/utils/llmUtils.js +76 -8
  252. package/dist/utils/llmUtils.js.map +1 -1
  253. package/dist/utils/redactSecrets.d.ts +63 -0
  254. package/dist/utils/redactSecrets.js +286 -0
  255. package/dist/utils/redactSecrets.js.map +1 -0
  256. package/dist/utils/systemPromptNotes.d.ts +222 -0
  257. package/dist/utils/systemPromptNotes.js +338 -0
  258. package/dist/utils/systemPromptNotes.js.map +1 -0
  259. package/dist/utils/systemUtils.d.ts +18 -1
  260. package/dist/utils/systemUtils.js +38 -3
  261. package/dist/utils/systemUtils.js.map +1 -1
  262. package/dist/utils/toolMatching.d.ts +30 -0
  263. package/dist/utils/toolMatching.js +44 -0
  264. package/dist/utils/toolMatching.js.map +1 -0
  265. package/dist/utils/untrustedText.d.ts +86 -0
  266. package/dist/utils/untrustedText.js +101 -0
  267. package/dist/utils/untrustedText.js.map +1 -0
  268. package/package.json +21 -7
  269. package/schema/gsloth-config.schema.json +1921 -326
  270. package/dist/core/shell/allowlist.d.ts +0 -75
  271. package/dist/core/shell/allowlist.js +0 -187
  272. package/dist/core/shell/allowlist.js.map +0 -1
  273. package/dist/core/shell/judge.d.ts +0 -161
  274. package/dist/core/shell/judge.js +0 -261
  275. package/dist/core/shell/judge.js.map +0 -1
@@ -1,3 +1,147 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * Shell / dev-tools policy: the {@link GthDevToolsConfig} type plus all the resolvers
4
+ * that interpret it (shell enablement, timeouts, output budget, per-command dev-tools
5
+ * selection), and — since CFG-27 — the {@link ApprovalsConfig} value and its resolver
6
+ * {@link resolveApprovals} (the five-rung ladder, the rater's identity profile, and the declared
7
+ * allow/deny lists).
8
+ *
9
+ * It also hosts the GitHub review-tool registry resolvers — {@link isGhReadFileToolEnabled} and
10
+ * {@link getGhReadFileMaxBytes} — which read the same {@link GthConfig.builtInTools} registry
11
+ * through the same per-command-then-root pick as the shell accessors. They live here, in core,
12
+ * rather than beside the tool in `@gaunt-sloth/review` (their only caller), so that one place owns
13
+ * how a `builtInTools` entry is normalised and resolved; a copy sitting next to the tool would be
14
+ * free to drift from the precedence every other tool in the registry obeys.
15
+ *
16
+ * CFG-18 — the dev/shell tools are now configured through the unified {@link GthConfig.builtInTools}
17
+ * registry (`string[] | Record<string, boolean | BuiltInToolConfig>`), NOT the removed per-command
18
+ * `commands.<mode>.devTools` key. {@link GthDevToolsConfig} is therefore no longer an on-disk shape:
19
+ * it is the internal, resolved view that {@link getEffectiveDevToolsConfig} builds from the effective
20
+ * `builtInTools` registry, and that {@link GthDevToolkit} + the shell accessors below consume. This
21
+ * keeps the toolkit/accessor surface stable while the single config surface is `builtInTools`.
22
+ */
23
+ import { StatusLevel } from '#src/core/types.js';
24
+ import { isAccessClassGrantedAtRung } from '#src/config/tool-descriptions.js';
25
+ /**
26
+ * The fixed dev-command tools: each maps a `command` string (from its {@link BuiltInToolConfig})
27
+ * to a run_* tool emitted by {@link GthDevToolkit}.
28
+ */
29
+ export const DEV_COMMAND_TOOL_NAMES = [
30
+ 'run_tests',
31
+ 'run_lint',
32
+ 'run_build',
33
+ 'run_single_test',
34
+ ];
35
+ /** The opt-in general-purpose shell tool name. */
36
+ export const SHELL_TOOL_NAME = 'run_shell_command';
37
+ /**
38
+ * All dev/shell tool names carried in the {@link GthConfig.builtInTools} registry. These are emitted
39
+ * by {@link GthDevToolkit} via the dev-tools bucket, NOT loaded as plain built-in tools — so
40
+ * `getBuiltInTools` skips them (a `run_shell_command` entry in `builtInTools` is legitimate, not an
41
+ * "unknown built-in tool").
42
+ */
43
+ export const DEV_TOOL_NAMES = [...DEV_COMMAND_TOOL_NAMES, SHELL_TOOL_NAME];
44
+ /**
45
+ * CFG-52 — the `gh api` file-read tool the review agent gets on a GitHub PR. The tool itself is
46
+ * built in `@gaunt-sloth/review` and stays there: it binds to the PR under review, which the
47
+ * `AVAILABLE_BUILT_IN_TOOLS` factory contract (`tool.get(config)`) cannot supply. Only its NAME
48
+ * lives here, because two packages need it and neither may depend on the other — `review` to decide
49
+ * whether to inject it, and `agent`'s `getBuiltInTools` to SKIP it (see
50
+ * {@link EXTERNALLY_EMITTED_BUILT_IN_TOOL_NAMES}).
51
+ */
52
+ export const GH_READ_FILE_TOOL_NAME = 'gth_gh_read_file';
53
+ /**
54
+ * Built-in tool names that are legitimate {@link GthConfig.builtInTools} entries but are NOT loaded
55
+ * by `getBuiltInTools`, because the tool is constructed elsewhere: the dev/shell tools come from
56
+ * {@link GthDevToolkit}, and {@link GH_READ_FILE_TOOL_NAME} is built by the review module with the
57
+ * PR context bound in. Without the skip, configuring one of these prints
58
+ * `Unknown built-in tool: <name>` on EVERY command's run — including the ones that never load it.
59
+ */
60
+ export const EXTERNALLY_EMITTED_BUILT_IN_TOOL_NAMES = [
61
+ ...DEV_TOOL_NAMES,
62
+ GH_READ_FILE_TOOL_NAME,
63
+ ];
64
+ /**
65
+ * CFG-52 — default ceiling on the DECODED file text {@link GH_READ_FILE_TOOL_NAME} returns:
66
+ * 600 KiB, roughly 10K lines of code. Deliberately generous, because the tool exists precisely for
67
+ * the case where the diff truncated and the reviewer needs the whole file; a cap that bites in
68
+ * normal use would defeat it. Override per entry with
69
+ * `{ "builtInTools": { "gth_gh_read_file": { "maxBytes": 200000 } } }`.
70
+ */
71
+ export const GH_READ_FILE_DEFAULT_MAX_BYTES = 614400;
72
+ /**
73
+ * The `builtInTools` registry that applies to a review/pr run: the per-command registry if the
74
+ * command sets one, else the root one — the SAME precedence {@link getEffectiveDevToolsConfig}
75
+ * uses, and picked WHOLESALE rather than per key. A per-command object replaces the root set
76
+ * entirely (the documented CFG-18 merge semantic), so a root entry for a tool the per-command
77
+ * registry does not name is not inherited.
78
+ */
79
+ function effectiveBuiltInToolsRegistry(config, command) {
80
+ const cmdConfig = config?.commands?.[command];
81
+ return normalizeBuiltInTools(cmdConfig?.builtInTools ?? config?.builtInTools);
82
+ }
83
+ /**
84
+ * CFG-52 — whether the review agent gets {@link GH_READ_FILE_TOOL_NAME} on this run.
85
+ *
86
+ * **Opt-OUT: absence means enabled.** The tool is bound to the PR's own head repo and ref, reads
87
+ * nothing local, and no-ops gracefully when `gh` is missing — the content it can reach is the
88
+ * content the review is already about — so making it opt-in would turn it off for every existing
89
+ * `gth pr` user in exchange for a flag most would never find. That is why absence is resolved here
90
+ * rather than through {@link isBuiltInToolEntryEnabled}, which reads an absent entry as OFF.
91
+ *
92
+ * `{ "builtInTools": { "gth_gh_read_file": false } }` turns it off; an object entry configures it
93
+ * (and, like any other tool, `{ "enabled": false }` also disables).
94
+ */
95
+ export function isGhReadFileToolEnabled(config, command) {
96
+ const registry = effectiveBuiltInToolsRegistry(config, command);
97
+ if (!Object.prototype.hasOwnProperty.call(registry, GH_READ_FILE_TOOL_NAME)) {
98
+ return true;
99
+ }
100
+ return isBuiltInToolEntryEnabled(registry[GH_READ_FILE_TOOL_NAME]);
101
+ }
102
+ /**
103
+ * CFG-52 — the decoded-text byte ceiling for {@link GH_READ_FILE_TOOL_NAME}, falling back to
104
+ * {@link GH_READ_FILE_DEFAULT_MAX_BYTES}. Only the object form can override it, and an
105
+ * out-of-range / non-numeric value falls back to the default — the same guard
106
+ * {@link getShellMaxOutputBytes} applies.
107
+ */
108
+ export function getGhReadFileMaxBytes(config, command) {
109
+ const entry = effectiveBuiltInToolsRegistry(config, command)[GH_READ_FILE_TOOL_NAME];
110
+ if (entry && typeof entry === 'object' && typeof entry.maxBytes === 'number') {
111
+ if (Number.isFinite(entry.maxBytes) && entry.maxBytes > 0) {
112
+ return entry.maxBytes;
113
+ }
114
+ }
115
+ return GH_READ_FILE_DEFAULT_MAX_BYTES;
116
+ }
117
+ /**
118
+ * Normalize the widened {@link BuiltInToolsSetting} to a plain lookup keyed by tool name. The array
119
+ * form maps each name to `true`; the object form passes through unchanged; absent → `{}`.
120
+ */
121
+ export function normalizeBuiltInTools(builtInTools) {
122
+ if (!builtInTools)
123
+ return {};
124
+ if (Array.isArray(builtInTools)) {
125
+ const out = {};
126
+ for (const name of builtInTools)
127
+ out[name] = true;
128
+ return out;
129
+ }
130
+ return builtInTools;
131
+ }
132
+ /**
133
+ * Whether a plain built-in tool's registry entry is enabled: a bare `true`, or an object entry that
134
+ * is not `{ enabled: false }` (configuring a tool enables it). A bare `false` force-disables it.
135
+ * Dev/shell tools ({@link DEV_TOOL_NAMES}) are NOT resolved through this — they go through
136
+ * {@link getEffectiveDevToolsConfig} / {@link isShellToolEnabled}.
137
+ */
138
+ export function isBuiltInToolEntryEnabled(value) {
139
+ if (value === undefined)
140
+ return false;
141
+ if (typeof value === 'boolean')
142
+ return value;
143
+ return value.enabled !== false;
144
+ }
1
145
  /**
2
146
  * Default per-command shell timeout (ms) when {@link GthDevToolsConfig.shell}
3
147
  * does not specify one. ~120s suits typical build/test/git steps without
@@ -12,17 +156,19 @@ export const SHELL_DEFAULT_TIMEOUT_MS = 120_000;
12
156
  export const SHELL_DEFAULT_MAX_OUTPUT_BYTES = 100_000;
13
157
  /**
14
158
  * Normalize the {@link GthDevToolsConfig.shell} opt-in (bare boolean or
15
- * `{ enabled }`) to a plain boolean. Centralized so the toolkit (tool emission)
16
- * and the deep agent (interrupt wiring) agree on what "shell enabled" means.
17
- *
18
- * EXT-12 — default-resolution: an EXPLICIT value always wins (a bare boolean, or the
19
- * object form's `enabled`), so `shell: false` / `{ enabled: false }` remains a hard
20
- * escape hatch that fully disables the tool. Only when `shell` is ABSENT/undefined does
21
- * the per-mode default apply: in `code` mode the shell tool is ON by default (still
22
- * gated the per-command approval interrupt is wired separately and is NOT bypassed by
23
- * this), and OFF everywhere else (`exec`, `ask --write`, …) to preserve prior behaviour.
24
- * The default is `code`-mode only because `code` is the interactive agentic-coding surface
25
- * where a TTY can answer the approval prompt; the absent-config default never implies yolo.
159
+ * `{ enabled }`) to a plain boolean. Centralized so tool emission and interrupt
160
+ * wiring agree on what "shell enabled" means.
161
+ *
162
+ * EXT-12 / CFG-18 — default-resolution is `enabled ?? default`. An EXPLICIT `enabled` always wins
163
+ * (a bare boolean, or the object form's `enabled`), so `shell: false` / `{ enabled: false }` remains
164
+ * a hard escape hatch that fully disables the tool. When `enabled` is ABSENT — whether `shell` is
165
+ * undefined OR an object that omits `enabled` (e.g. `{ timeout: 300000 }`, i.e. a
166
+ * `{ "run_shell_command": { "timeout": 300000 } }` registry entry) the per-mode default applies:
167
+ * ON in `code` mode (still gated the per-command approval interrupt is wired separately and is NOT
168
+ * bypassed by this), OFF everywhere else (`exec`, `ask --write`, …). This is the CFG-18 change from
169
+ * the old `enabled === true` object semantics: configuring the shell no longer silently turns it off.
170
+ * The default is `code`-mode only because `code` is the interactive agentic-coding surface where a
171
+ * TTY can answer the approval prompt; the absent-config default never implies yolo.
26
172
  *
27
173
  * @param command The active command, so the absent-config default can be scoped to `code`.
28
174
  * Omit (or pass a non-`code` command) to keep the historical OFF-by-default behaviour.
@@ -31,8 +177,9 @@ export function isShellToolEnabled(devTools, command) {
31
177
  const shell = devTools?.shell;
32
178
  if (typeof shell === 'boolean')
33
179
  return shell;
180
+ // Object form: `enabled ?? default` — an object without `enabled` still defaults ON in `code`.
34
181
  if (shell && typeof shell === 'object')
35
- return shell.enabled === true;
182
+ return shell.enabled ?? command === 'code';
36
183
  // Absent/undefined shell: ON by default for `code` mode (gated), OFF elsewhere.
37
184
  return command === 'code';
38
185
  }
@@ -64,79 +211,662 @@ export function getShellMaxOutputBytes(devTools) {
64
211
  return SHELL_DEFAULT_MAX_OUTPUT_BYTES;
65
212
  }
66
213
  /**
67
- * Whether the EXT-9 Tier-2 scoped allow-list is active. Default `true`; only the object
68
- * form's `allowlist: false` disables it (a bare `shell: true` keeps it on). When off, the
69
- * runner prompts for every `run_shell_command` regardless of prior approvals.
214
+ * Build the internal, resolved {@link GthDevToolsConfig} from a normalized `builtInTools` registry:
215
+ * the fixed dev-command tools read their `command` string, and `run_shell_command` maps to the
216
+ * `shell` view the accessors below consume (CFG-26: the approval knobs are no longer here — they
217
+ * live in the top-level `approvals` block, resolved by {@link resolveApprovals}). Returns `undefined` when the registry
218
+ * carries no dev/shell entry at all, so callers treat it exactly like an unset `devTools` (the
219
+ * `code`-mode shell default still applies downstream via {@link isShellToolEnabled}).
70
220
  */
71
- export function isShellAllowlistEnabled(devTools) {
72
- const shell = devTools?.shell;
73
- if (shell && typeof shell === 'object' && shell.allowlist === false)
74
- return false;
75
- return true;
221
+ function devToolsConfigFromRegistry(registry) {
222
+ const resolved = {};
223
+ let hasAny = false;
224
+ for (const name of DEV_COMMAND_TOOL_NAMES) {
225
+ const entry = registry[name];
226
+ const cmd = entry && typeof entry === 'object' ? entry.command : undefined;
227
+ if (typeof cmd === 'string' && cmd.length > 0) {
228
+ resolved[name] = cmd;
229
+ hasAny = true;
230
+ }
231
+ }
232
+ if (Object.prototype.hasOwnProperty.call(registry, SHELL_TOOL_NAME)) {
233
+ const entry = registry[SHELL_TOOL_NAME];
234
+ if (typeof entry === 'boolean') {
235
+ resolved.shell = entry;
236
+ }
237
+ else if (entry && typeof entry === 'object') {
238
+ resolved.shell = {
239
+ enabled: entry.enabled,
240
+ timeout: entry.timeout,
241
+ maxOutputBytes: entry.maxOutputBytes,
242
+ };
243
+ }
244
+ hasAny = true;
245
+ }
246
+ return hasAny ? resolved : undefined;
76
247
  }
77
248
  /**
78
- * Whether `always`-scoped approvals are persisted to the project allow-list file. Default
79
- * `true`; only the object form's `persistAllowlist: false` disables persistence (an
80
- * `always` decision then behaves as `session`).
249
+ * Resolve the {@link GthDevToolsConfig} that applies to the active command from the unified
250
+ * {@link GthConfig.builtInTools} registry (CFG-18 replaces the removed per-command `devTools`).
251
+ * Mirrors the per-command selection used by `builtInToolsConfig.getDefaultTools`: `exec` →
252
+ * `commands.exec`, `ask --write` → `commands.ask`, `code` → `commands.code`; `undefined` elsewhere
253
+ * (the dev/shell tools are inert there). The effective registry for the scope is the per-command
254
+ * `builtInTools` if set, else the root `builtInTools` — matching `getEffectiveConfig`'s replace
255
+ * merge. Shared in core so the runner's allow-list/judge gates stay in lockstep with where the
256
+ * shell tool is actually emitted.
81
257
  */
82
- export function isShellAllowlistPersisted(devTools) {
83
- const shell = devTools?.shell;
84
- if (shell && typeof shell === 'object' && shell.persistAllowlist === false)
85
- return false;
86
- return true;
258
+ export function getEffectiveDevToolsConfig(config, command) {
259
+ if (!config)
260
+ return undefined;
261
+ const askWrite = command === 'ask' && config.askWriteMode === true;
262
+ const cmdConfig = command === 'exec'
263
+ ? config.commands?.exec
264
+ : askWrite
265
+ ? config.commands?.ask
266
+ : command === 'code'
267
+ ? config.commands?.code
268
+ : undefined;
269
+ // Only the do-the-job commands (code/exec/ask --write) carry dev/shell tools.
270
+ if (command !== 'exec' && command !== 'code' && !askWrite)
271
+ return undefined;
272
+ const effective = cmdConfig?.builtInTools ?? config.builtInTools;
273
+ return devToolsConfigFromRegistry(normalizeBuiltInTools(effective));
87
274
  }
275
+ /* -------------------------------------------------------------------------------------------- *
276
+ * CFG-27 — the `approvals` ladder: one ordered set of five rungs, plus the declared lists.
277
+ * -------------------------------------------------------------------------------------------- */
278
+ /**
279
+ * CFG-27 (spec §1, §2) — **the ladder**. There is ONE approvals setting and it is a single ordered
280
+ * ladder; each rung fully determines behaviour. There are no severity thresholds, no strictness
281
+ * levels and no independent rater on/off switch.
282
+ *
283
+ * | # | Rung | Rater | LLM cost |
284
+ * |---|---|---|---|
285
+ * | 1 | `manual` | no | none |
286
+ * | 2 | `write` | no | none |
287
+ * | 3 | `assisted` | yes | 1 call per gated call |
288
+ * | 4 | `auto` | yes | 1–2 calls per gated call |
289
+ * | 5 | `bypass` | no | none |
290
+ *
291
+ * Rungs 1, 2 and 5 are fully deterministic: no model is consulted, so behaviour is reproducible
292
+ * and costs nothing.
293
+ *
294
+ * **CFG-39 — four postures plus one modifier, not five peers.** `write` is not a rung on a trust
295
+ * ladder: it is the same posture as `manual` with a different auto-granted set. The four postures
296
+ * a user chooses between are {@link APPROVAL_POSTURES} (`manual` → `assisted` → `auto`, plus
297
+ * `bypass`), and that ordering is legible in the names themselves. `write` remains fully settable
298
+ * — via `/approvals write` and via config — and simply does not occupy a row in quick access.
299
+ * This constant keeps ALL FIVE members: it is the type's domain, the set
300
+ * {@link resolveInterruptToolNames} unions over, and what makes `write` settable at all.
301
+ *
302
+ * **`bypass` is NOT a higher-autonomy rung than `auto`** (§2.5). Both let the agent act
303
+ * without asking; `bypass` is the same autonomy with the checks removed. The ordering below is the
304
+ * order the rungs are *offered* in, and must never be presented as though `auto` were an
305
+ * incomplete `bypass`.
306
+ *
307
+ * Identifiers are lower-case single words (§9.1) because the same token must work as a config
308
+ * value, a slash-command argument and a CLI flag — a space breaks the last two. Display names are
309
+ * capitalised; see {@link APPROVAL_RUNG_LABELS}.
310
+ */
311
+ export const APPROVAL_RUNGS = ['manual', 'write', 'assisted', 'auto', 'bypass'];
88
312
  /**
89
- * Whether the EXT-10 LLM-as-judge safety gate is enabled for the given dev-tools config.
90
- * Default OFF (only the object form's `judge` truthy enables it), mirroring
91
- * {@link isShellToolEnabled}. A bare `shell: true` keeps the judge OFF — it costs an LLM call
92
- * per command and must be opted into explicitly.
313
+ * §9.1 rule / §10 rule 4 the display spelling of each rung, with spaces. An identifier and a
314
+ * label do not have to match and only one of them has to survive a shell, so user-facing prose
315
+ * uses these and never the kebab-case identifiers.
93
316
  */
94
- export function isShellJudgeEnabled(devTools) {
95
- const shell = devTools?.shell;
96
- if (!shell || typeof shell !== 'object')
317
+ export const APPROVAL_RUNG_LABELS = {
318
+ manual: 'Manual',
319
+ write: 'Write',
320
+ assisted: 'Assisted',
321
+ auto: 'Auto',
322
+ bypass: 'Bypass',
323
+ };
324
+ /**
325
+ * CFG-39 — **the four postures**, in the order they are offered: the rows `/approvals` presents
326
+ * when it asks the user to choose one.
327
+ *
328
+ * `write` is deliberately absent, and its absence is the whole point of the reframe. It is not a
329
+ * rung on a trust ladder — it is `manual`'s posture with a different auto-granted set, so listing
330
+ * it as a fifth peer is what made a four-point ladder read as five indistinguishable ones. It stays
331
+ * fully settable via `/approvals write` and via config; it simply leaves quick access.
332
+ *
333
+ * **This is a presentation list, never a policy one.** Every predicate, every `Record<ApprovalRung,
334
+ * …>` and the interrupt set are built from {@link APPROVAL_RUNGS}, which keeps all five. Using this
335
+ * constant to decide behaviour would silently un-settle `write`.
336
+ */
337
+ export const APPROVAL_POSTURES = [
338
+ 'manual',
339
+ 'assisted',
340
+ 'auto',
341
+ 'bypass',
342
+ ];
343
+ /**
344
+ * CFG-39 — the one-line note that `write` exists, shown as picker CHROME beside the `manual` row.
345
+ *
346
+ * It lives here rather than appended to {@link APPROVAL_RUNG_DESCRIPTIONS}`.manual` on purpose: the
347
+ * descriptions are one surface's copy shared by six, so a sentence about quick-access mechanics
348
+ * would follow the mode into the status display and the tool-description layer, where it means
349
+ * nothing.
350
+ */
351
+ export const APPROVAL_WRITE_MODIFIER_HINT = 'Manual also has a Write variant that may edit files in your working folder without asking: ' +
352
+ 'set it with /approvals write.';
353
+ /**
354
+ * §10 — what each mode is **for**, in at most two sentences, shown wherever a mode is chosen or
355
+ * displayed.
356
+ *
357
+ * **The first sentence is load-bearing and must stand alone.** The `/approvals` picker, the text
358
+ * fallback and the usage hint all render one line per mode through `firstSentence`, so sentence
359
+ * one has to answer "what is this mode for" on its own and sentence two carries the qualification.
360
+ * Keep it short enough to read as a menu row.
361
+ *
362
+ * **Say what the mode is for, not only what it permits.** A description that lists permissions
363
+ * invites the category error these modes actually suffer: Manual reads as "the safe one", so it
364
+ * gets picked for a long unattended run — where the deciding is done by a human, and a human is the
365
+ * fastest-degrading decider in the system. Manual and Write are bounded-volume tools and their copy
366
+ * says so.
367
+ *
368
+ * The wording is constrained by four normative rules (state what the mode PERMITS, state the
369
+ * allow-list carve-out, never claim safety this system cannot deliver, use the display spelling)
370
+ * plus §8.1 — the hardline floor is real but is NEVER advertised, so descriptions cite only
371
+ * protections the user can inspect and extend, i.e. the deny list. Six further constraints bind
372
+ * every edit here:
373
+ *
374
+ * 1. **No description may imply containment.** The gate protects against accidents, not intent, and
375
+ * a working-folder claim collapses the moment the agent has a shell — `write_file` refuses a
376
+ * path that `touch` then writes. The narrow true form (the built-in file *tools* are confined)
377
+ * is stated once, on `write`, alongside the fact that the shell is not confined that way.
378
+ * 2. **Claims are scoped to the session the user is in — ratified by Andrew on 2026-08-13** as the
379
+ * standing rule for every in-product approval promise ([[CFG-40]]), so it is a decision and not
380
+ * a habit inherited from whichever string happened to be written last. What makes it true
381
+ * rather than merely convenient: these strings render on terminal surfaces only, and every
382
+ * surface that renders them is a session a person is sitting in, so a claim scoped to *this
383
+ * session* describes the only place the sentence appears rather than hedging around a
384
+ * falsehood. Unscoped it would not: a sentence whose subject is "Gaunt Sloth" and whose claim
385
+ * is that it always asks is false over the AG-UI server, which drives the agent itself and
386
+ * drains no approval interrupt ([[EXT-54]] — worth fixing on its own merits, never as a gate on
387
+ * this copy).
388
+ *
389
+ * **The ACP server does ask, and that does not re-open this rule.** It drives
390
+ * `GthAgentRunner`, registers a per-turn tool-approval callback and raises
391
+ * `session/request_permission`, so a human there really is asked. What it puts in front of them
392
+ * is assembled from the rater's verdict, the matched `approvals.escalate` entry and the grant
393
+ * preview — never from `APPROVAL_RUNG_DESCRIPTIONS` or any other posture copy — so no string
394
+ * governed here renders on a server surface, and the ruling's conclusion is untouched.
395
+ *
396
+ * **That is also the boundary: the moment an approvals string is rendered by a server surface
397
+ * this ruling stops covering it**, and the scoped sentence has to be re-earned there rather
398
+ * than inherited from here.
399
+ * 3. **`assisted` MUST keep the sentence saying files are still rewritten and deleted without
400
+ * asking** — it sounds safer than it is, and that clause is the correction.
401
+ * 4. **A qualification may not live in the second sentence alone.** The picker, the text fallback
402
+ * and the usage hint all render `firstSentence` and nothing else, so the opener is the whole
403
+ * message on the three surfaces a user reads while *choosing* a mode. An opener that sells a
404
+ * behavioural difference the product does not have is not rescued by a sentence two those
405
+ * surfaces never print — check a wording by rendering it, not by reading the constant.
406
+ * 5. **`auto` is the mode a reader most wants to hear is quiet, and it is not.** It settles some
407
+ * risky commands with the rater instead of interrupting — that difference is real and may be
408
+ * stated — but the copy MUST also say that a bounded exchange ends at the user, and MUST NOT
409
+ * promise the user watches it happen. The rounds reach a person at the escalation, all of them
410
+ * at once, and nothing renders them before that ([[TUI-C26]]), so a sentence implying a live
411
+ * commentary would be describing a screen that does not exist.
412
+ * 6. **Every rated rung states a LIMIT ON USE in its opener, and `recoverable` is the word that
413
+ * carries it** ([[CFG-48]]). A rated rung settles some risky commands without asking anyone, so
414
+ * the sentence offering it has to say what kind of work that is acceptable for — and a
415
+ * *terminus* is not a limit on use. "then asks you" and "anything riskier comes to you" say
416
+ * where the exchange ends; they do not say when the mode is a bad idea, which is what the three
417
+ * UNRATED rungs each say ("not a mode to leave running", "a bounded stretch", "a throwaway
418
+ * environment you would not mind losing"). Constraint 4 is why it must be the opener: the three
419
+ * surfaces a user chooses from print `firstSentence` and nothing else.
420
+ *
421
+ * **"Unrated", not "deterministic", and the distinction is this file's own.** The complement of
422
+ * {@link isRatedRung} is `{manual, write, bypass}` — the rungs that consult no rater. That is NOT
423
+ * the set {@link isDeterministicRung} names, which is `{manual, write}`: `bypass` gates nothing at
424
+ * all, so it is not a rung that decides deterministically, it is the absence of a decision. Prose
425
+ * elsewhere in the repo calls the three "deterministic"; against the predicate exported a hundred
426
+ * lines below, that reading is wrong, so this constraint says "unrated" and means the complement.
427
+ *
428
+ * **A lexical test watches the word, and its failure is not a false positive.** "Carries a
429
+ * limit-on-use clause" has no mechanical predicate, so the rule names its own carrier token
430
+ * instead: `packages/app/spec/tui/slashCommands.spec.ts` — *"every rated rung's opener says what
431
+ * kind of work it is for, and only the rated ones do"* — takes the rated set from
432
+ * {@link isRatedRung} rather than a literal pair, so a sixth rated rung is required to carry a
433
+ * clause on the day it is added, and asserts the three unrated rungs do NOT carry the token, so
434
+ * that spreading `recoverable` across all five cannot satisfy it. Rewriting this copy is fine;
435
+ * dropping the word is the thing that must not happen quietly, because that is exactly how
436
+ * Auto's cautionary clause was lost once already — in an ordinary rewrite that broke no test.
437
+ *
438
+ * Everything these two sentences cannot hold lives at {@link APPROVAL_PROTECTION_DOCS_URL}, which
439
+ * the surfaces print beside the copy rather than each description repeating it.
440
+ */
441
+ export const APPROVAL_RUNG_DESCRIPTIONS = {
442
+ manual: 'For a handful of commands you want to read yourself — not a mode to leave running. In this ' +
443
+ 'session Gaunt Sloth reads and lists files in your working folder on its own; everything ' +
444
+ 'else — shell, file changes, MCP and custom tools — comes to you, until you tell it to always ' +
445
+ 'allow a command.',
446
+ write: 'Manual, for work that is mostly editing, and like Manual a bounded stretch: the built-in ' +
447
+ 'file tools run free inside your working folder. The shell is not confined that way, so shell ' +
448
+ 'commands, MCP calls and custom tools still come to you, until you tell it to always allow a ' +
449
+ 'command.',
450
+ assisted: 'For everyday, recoverable work: safe commands run, anything riskier comes to you — usually ' +
451
+ 'with a line explaining what it does. Gaunt Sloth can still rewrite and delete files in your ' +
452
+ 'working folder without asking — "safe" means each action is checked for reaching outside ' +
453
+ 'that folder or harming your system, not that nothing changes.',
454
+ auto: 'For recoverable work you want to keep moving: Auto sends a risky command back to the agent ' +
455
+ 'to fix or justify a few times, then asks you. It is not safe — Gaunt Sloth will change and ' +
456
+ 'delete things, your deny list still applies, and when it does ask, you are shown the whole ' +
457
+ 'argument that led there.',
458
+ bypass: 'No gate, for a throwaway environment you would not mind losing. Whatever Gaunt Sloth decides ' +
459
+ 'to run, runs — nothing is rated and nothing is asked; only the refusals in your config’s ' +
460
+ 'deny list still apply.',
461
+ };
462
+ /**
463
+ * The page that carries what these modes do and do NOT protect you from — the reasoning the
464
+ * two-sentence descriptions deliberately do not hold.
465
+ *
466
+ * **A GitHub blob URL, matching the one other user-facing runtime doc link in this package** (the
467
+ * 2.0 migration pointer in `config/schema.ts`). The docs site publishes the same page at
468
+ * `https://gauntsloth.app/docs/guides/what-approvals-protect-you-from/`; move this constant there
469
+ * once that path serves the page, and nothing else changes.
470
+ */
471
+ export const APPROVAL_PROTECTION_DOCS_URL = 'https://github.com/pukeko-robotics/gaunt-sloth/blob/main/docs/guides/what-approvals-protect-you-from.md';
472
+ /**
473
+ * The docs pointer as the surfaces print it: **label and URL as two separate lines**, never one
474
+ * joined string. Every notice surface takes `lines: string[]` and renders one line each, so a bare
475
+ * URL on its own line is the only form that survives a narrow pane without the break landing
476
+ * mid-path — and it is what lets a terminal that linkifies URLs pick the whole thing up.
477
+ *
478
+ * It lives beside the descriptions rather than inside them for {@link APPROVAL_WRITE_MODIFIER_HINT}'s
479
+ * reason: one line about where to read more, repeated into all five descriptions, would follow each
480
+ * mode into the tool-description layer and the status display, where it is noise.
481
+ */
482
+ export const APPROVAL_PROTECTION_DOCS_LINES = [
483
+ 'What these modes do and do not protect you from:',
484
+ APPROVAL_PROTECTION_DOCS_URL,
485
+ ];
486
+ /** Narrowing type guard for a raw string that may name a rung. */
487
+ export function isApprovalRung(value) {
488
+ return typeof value === 'string' && APPROVAL_RUNGS.includes(value);
489
+ }
490
+ /** The rungs at which every gated call is rated by the model (§2.3, §2.4). */
491
+ export function isRatedRung(rung) {
492
+ return rung === 'assisted' || rung === 'auto';
493
+ }
494
+ /**
495
+ * [[EXT-29]] (§5) — the rung at which a `destructive` rating opens a **negotiation** with the rater
496
+ * instead of going to the human: the agent may revise the command or justify it, the rater re-rates
497
+ * with the exchange in view, and only a spent bound reaches a person.
498
+ *
499
+ * **This is the one predicate that separates `auto` from `assisted`, and it is deliberately ONE.**
500
+ * Three places have to agree about it — the decision mapping
501
+ * ({@link import('../core/shell/rater.js').mapVerdictToAction}, which returns `reject` here and
502
+ * `escalate` at `assisted`), the rating prompt (§5.2's wording rules are addressed to the agent, so
503
+ * they are turned on by this and not by whether a transcript happens to exist yet), and the runner
504
+ * that counts the rounds. Two of them agreeing and the third not is exactly how the two rated rungs
505
+ * would drift back into being the same posture with different names.
506
+ *
507
+ * Written as a `=== 'auto'` test rather than as "rated but not assisted" so that a sixth rung has to
508
+ * be classified deliberately rather than inheriting a negotiation by omission.
509
+ */
510
+ export function isNegotiatingRung(rung) {
511
+ return rung === 'auto';
512
+ }
513
+ /**
514
+ * The rungs that decide a gated call **without a model** — `manual` and `write` (§2.1, §2.2).
515
+ * Everything they do not auto-grant goes to the human, so these are the two rungs a user picks in
516
+ * order to read and approve every tool call themselves.
517
+ *
518
+ * The complement of {@link isRatedRung} plus `bypass`; written out rather than negated so that a
519
+ * sixth rung would have to be classified deliberately instead of defaulting into this set.
520
+ */
521
+ export function isDeterministicRung(rung) {
522
+ return rung === 'manual' || rung === 'write';
523
+ }
524
+ /**
525
+ * **The one rule: does `rung` gate this tool — i.e. must this call be decided rather than simply
526
+ * run?** Everything else in this area is a projection of this predicate over a set of names.
527
+ *
528
+ * - The shell is gated whenever the shell gate is on, at EVERY rung (`bypass` included, so §2.5's
529
+ * deny list can still fire — see the `bypass` arm of `GthAgentRunner.decideToolApproval`).
530
+ * - At the two **deterministic** rungs, a tool is gated when the rung's own grant does not cover its
531
+ * access class ({@link isAccessClassGrantedAtRung}). At `manual` that leaves only the built-in
532
+ * READ tools free; at `write`, the built-in read and write tools. The write built-ins, the shell,
533
+ * MCP tools and custom/agent-authored tools all escalate to the human.
534
+ * - At `assisted`, `auto` and `bypass` nothing but the shell is gated. **That split is
535
+ * deliberate and load-bearing, not tidiness.** At a rated rung a gated non-shell call reaches the
536
+ * `subject.kind !== 'shell'` arm of `GthAgentRunner.decideToolApproval`, which floors it at
537
+ * `destructive` and sends it to the human *with no rating call*, because §4.3 keeps the rater on
538
+ * the shell until [[EXT-30]]. Gating there would silently turn every MCP call at `assisted` into
539
+ * a human prompt — a UX change belonging to EXT-30, not to the two rungs whose published
540
+ * descriptions this predicate makes true.
541
+ *
542
+ * `gateShell` only ever WIDENS the result. At a deterministic rung the shell is gated by its own
543
+ * (absent) access class if it is bound at all, so `gateShell: false` does not exempt it — an
544
+ * exemption keyed to one tool NAME is the defect class this predicate exists to remove. In practice
545
+ * a disabled shell tool is never bound, so the two agree.
546
+ *
547
+ * **This takes no bound toolset**, which is what lets `GthAgentRunner` ask it about a single
548
+ * arriving call: the runner sees only the names the graph registered, and a graph builder that
549
+ * registers tools of its own leaves them off that list. A decision that consulted a bound list
550
+ * would grant such a tool at `manual` purely because the runner could not see it.
551
+ */
552
+ export function isToolGatedAtRung(options) {
553
+ const { toolName, rung, gateShell } = options;
554
+ if (gateShell && toolName === SHELL_TOOL_NAME)
555
+ return true;
556
+ if (!isDeterministicRung(rung))
97
557
  return false;
98
- const judge = shell.judge;
99
- if (typeof judge === 'boolean')
100
- return judge;
101
- if (judge && typeof judge === 'object')
102
- return judge.enabled === true;
103
- return false;
558
+ return !isAccessClassGrantedAtRung(toolName, rung);
104
559
  }
105
560
  /**
106
- * Resolve the EXT-10 judge gate settings from a dev-tools config, applying safe defaults
107
- * (auto-approve low, do NOT block high). `enabled` reflects {@link isShellJudgeEnabled}.
561
+ * **The LIVE gated set: which bound tools the rung in force actually gates.** What a decision is
562
+ * measured against the tool descriptions the model reads (§4.5) and the rater's granted-tools
563
+ * summary (§4.4) are both built from this, so neither can tell the model a tool is free while the
564
+ * gate escalates it.
565
+ *
566
+ * **It is NOT what the backends wire into the interrupt.** That is
567
+ * {@link resolveInterruptToolNames}, and the two are different sets on purpose: the interrupt is
568
+ * installed once, at agent init, while `/approvals <rung>` moves the rung underneath it for the rest
569
+ * of the session. A set that carried the rung would be frozen at the rung the session started on —
570
+ * and since the default is `assisted`, typing `/approvals manual` would leave exactly the write
571
+ * tools this design escalates ungated. So the interrupt is wired rung-independently and
572
+ * `GthAgentRunner.decideToolApproval` consults {@link isToolGatedAtRung} against the LIVE rung.
573
+ *
574
+ * **Derived from the bound toolset, never a hand-written list.** A static list of built-ins would
575
+ * leave MCP, custom and agent-authored tools out — the exact tools with no access class and so the
576
+ * exact tools the deterministic rungs must escalate. `boundToolNames` must therefore be the FINAL
577
+ * toolset the graph is handed, including any tool the graph builder registers itself, which by
578
+ * definition never appears in the array gsloth passes it.
579
+ *
580
+ * Order is stable: the shell first, then bound order. Duplicates are collapsed, so a caller may pass
581
+ * overlapping name sources without deduplicating first.
108
582
  */
109
- export function getShellJudgeSettings(devTools) {
110
- const enabled = isShellJudgeEnabled(devTools);
111
- const shell = devTools?.shell;
112
- const judge = shell && typeof shell === 'object' && shell.judge && typeof shell.judge === 'object'
113
- ? shell.judge
114
- : undefined;
115
- return {
116
- enabled,
117
- autoApproveLow: judge?.autoApproveLow ?? true,
118
- blockHigh: judge?.blockHigh ?? false,
119
- model: judge?.model,
583
+ export function resolveGatedToolNames(options) {
584
+ const { rung, gateShell, boundToolNames } = options;
585
+ return collectToolNames(gateShell, boundToolNames, (name) => isToolGatedAtRung({ toolName: name, rung, gateShell }));
586
+ }
587
+ /**
588
+ * **Which commands have something that ANSWERS an approval interrupt.**
589
+ *
590
+ * `GthAgentRunner` is the one component that drains a suspended graph
591
+ * (`resolveToolInterrupts` → `decideToolApproval`) and the one that holds the session rung, so every
592
+ * command it drives answers approvals and can move the rung under a running session.
593
+ *
594
+ * **The AG-UI server (`api`) does not.** It drives the agent directly — `agent.init` +
595
+ * `streamWithEvents` — and its resume path serves its own frontend-tool `interrupt()` stubs, not
596
+ * approvals. An approval interrupt raised there suspends the graph with nobody to resume it: the
597
+ * tool never runs, the client is never asked, and the turn simply ends with that tool call
598
+ * unanswered. Measured, not inferred.
599
+ *
600
+ * So a command that answers nothing must be handed **no approval interrupt beyond what the shell
601
+ * gate itself requires** — see the `interruptTools` wiring in both backends. In particular it must
602
+ * NOT be handed the LIVE set for its configured rung: that set is non-empty at `manual` and
603
+ * `write`, so it carries exactly the same trap, and `commands.api.approvals` (plus a root-level
604
+ * `approvals`, which applies to every command) puts an ordinary config on those rungs.
605
+ *
606
+ * **Total over {@link GthCommand} on purpose.** An eighth command has to be classified here before
607
+ * it compiles, rather than defaulting silently into "answers approvals". The property is really
608
+ * about the DRIVER and not about the command — a second server built under an existing command that
609
+ * drives the agent without `GthAgentRunner` would inherit the trap with this record fully populated
610
+ * — so the shape that cannot rot at all is for the constructing surface to declare it at its own
611
+ * call site.
612
+ *
613
+ * Approvals for the AG-UI surface are [[EXT-30]]'s to build; until then this keeps a rung the server
614
+ * cannot serve from silently swallowing the tool calls it was asked to make.
615
+ */
616
+ const COMMAND_ANSWERS_APPROVALS = {
617
+ ask: true,
618
+ chat: true,
619
+ code: true,
620
+ exec: true,
621
+ pr: true,
622
+ review: true,
623
+ /** The AG-UI server: it drives the agent itself and drains nothing. */
624
+ api: false,
625
+ };
626
+ /**
627
+ * {@link COMMAND_ANSWERS_APPROVALS} as a predicate. An unset command is a session driven by
628
+ * `GthAgentRunner` (nothing else leaves it unset), so it answers approvals.
629
+ *
630
+ * **`?? true` is the fail-safe default, not defensive noise.** The lookup yields `undefined` for a
631
+ * value outside {@link GthCommand}, and `undefined` is falsy — which would tell the caller to
632
+ * install NO approval interrupt, the one direction this predicate must never fail in. TypeScript
633
+ * makes that unreachable from inside this repo, but the function is re-exported from the public
634
+ * `@gaunt-sloth/core/config.js` barrel, so an untyped consumer can reach it. The coalesce restores
635
+ * runtime totality without weakening the compile-time totality the record already gives.
636
+ */
637
+ export function commandAnswersApprovals(command) {
638
+ return command === undefined ? true : (COMMAND_ANSWERS_APPROVALS[command] ?? true);
639
+ }
640
+ /**
641
+ * **The interrupt set: which tool names the backends wire into the approval interrupt.** Every bound
642
+ * tool that ANY rung could gate — the union of {@link resolveGatedToolNames} over
643
+ * {@link APPROVAL_RUNGS}, which in practice is the shell plus every bound tool that is not a
644
+ * built-in READ tool.
645
+ *
646
+ * One derivation for every backend, for the same reason {@link resolveShellApprovalGate} is one: a
647
+ * set computed twice is a set that drifts.
648
+ *
649
+ * **Deliberately rung-independent.** The interrupt is installed once, when the agent is built, and
650
+ * `/approvals <rung>` then moves the rung for the rest of the session without rebuilding it. Only a
651
+ * set that covers every rung can survive that: the interrupt fires and
652
+ * `GthAgentRunner.decideToolApproval` decides on the rung in force, which is where the rung has
653
+ * always been read. **Wiring wider does not gate wider** — a call the live rung does not gate is
654
+ * approved there with no rating call and no prompt, so `assisted`, `auto` and `bypass` behave
655
+ * exactly as they do when the interrupt holds the shell alone.
656
+ *
657
+ * **Only for a command that answers approvals** ({@link commandAnswersApprovals}). A surface that
658
+ * drains nothing gets the shell-gate set instead, because a rung-independent set there parks tool
659
+ * calls nobody can reach.
660
+ */
661
+ export function resolveInterruptToolNames(options) {
662
+ const { gateShell, boundToolNames } = options;
663
+ return collectToolNames(gateShell, boundToolNames, (name) => APPROVAL_RUNGS.some((rung) => isToolGatedAtRung({ toolName: name, rung, gateShell })));
664
+ }
665
+ /**
666
+ * Shared body of the two resolvers above: the shell first (when gated), then the bound names the
667
+ * caller's predicate selects, in bound order, deduplicated, with nameless entries dropped.
668
+ */
669
+ function collectToolNames(gateShell, boundToolNames, include) {
670
+ const names = [];
671
+ const seen = new Set();
672
+ const add = (name) => {
673
+ if (typeof name !== 'string' || name.length === 0 || seen.has(name))
674
+ return;
675
+ seen.add(name);
676
+ names.push(name);
120
677
  };
678
+ if (gateShell)
679
+ add(SHELL_TOOL_NAME);
680
+ for (const name of boundToolNames) {
681
+ if (typeof name === 'string' && name.length > 0 && include(name))
682
+ add(name);
683
+ }
684
+ return names;
121
685
  }
122
686
  /**
123
- * Resolve the {@link GthDevToolsConfig} that applies to the active command, mirroring the
124
- * per-command selection in `builtInToolsConfig.getDefaultTools` (which is what actually emits
125
- * the dev tools) and `GthDeepAgent.getEffectiveDevToolsConfig`: `exec` `commands.exec`,
126
- * `ask --write` → `commands.ask`, `code` → `commands.code`; `undefined` elsewhere (the
127
- * toolkit is inert there). Shared in core so the runner's allow-list gate stays in lockstep
128
- * with where the shell tool is actually emitted.
687
+ * §4.7 the four MCP `ToolAnnotations` hint names, and the whole vocabulary. It is the same list
688
+ * on both sides of the design: what a `hint` pattern may name ({@link ApprovalHintPattern}) and what
689
+ * a user may believe from a server ({@link McpServerApprovalsConfig.trustAnnotations}).
690
+ *
691
+ * **The schema twin `HINT_ANNOTATION_KEYS` in `config/schema.ts` is a deliberate duplicate, and the
692
+ * reason is layering, not oversight.** Neither file may import the other. `schema.ts` states in its
693
+ * own header that it must stay pure and cwd/fs-independent because it feeds `z.toJSONSchema()`, and
694
+ * importing this module would pull `core/types.js` and the whole runtime policy surface into it;
695
+ * importing `schema.ts` here would in turn pull zod into every module that only wanted a policy
696
+ * type. So the vocabulary is written once per layer on purpose — do not "simplify" it by making one
697
+ * import the other.
698
+ *
699
+ * What keeps the two honest instead is the equality assertion in `mcpApprovalsBlock.spec.ts`, which
700
+ * fails the moment they drift. Drift matters in one direction especially: a name the config accepts
701
+ * but the derivation never reads fails silently, and it fails toward trusting. Change one list,
702
+ * change the other.
703
+ */
704
+ export const TOOL_ANNOTATION_HINTS = [
705
+ 'readOnlyHint',
706
+ 'destructiveHint',
707
+ 'idempotentHint',
708
+ 'openWorldHint',
709
+ ];
710
+ /**
711
+ * §1.1 — **the default rung is `assisted`, everywhere.** It is the default in every interactive
712
+ * context, it does NOT vary with the configured model, and there is no separate non-interactive
713
+ * default. What changes without a human is what an escalation *does* (§6.2: an immediate non-zero
714
+ * exit, never an approval), not which rung the session starts on. A context-dependent default
715
+ * would reintroduce exactly the hidden branching this ladder exists to remove.
129
716
  */
130
- export function getEffectiveDevToolsConfig(config, command) {
131
- if (!config)
717
+ export const DEFAULT_APPROVAL_RUNG = 'assisted';
718
+ /** Normalize the scalar/object union to the object form. The scalar is sugar for `{ mode }`. */
719
+ function toApprovalsObject(raw) {
720
+ if (raw === undefined)
132
721
  return undefined;
133
- const askWrite = command === 'ask' && config.askWriteMode === true;
134
- if (command === 'exec')
135
- return config.commands?.exec?.devTools;
136
- if (askWrite)
137
- return config.commands?.ask?.devTools;
138
- if (command === 'code')
139
- return config.commands?.code?.devTools;
140
- return undefined;
722
+ if (typeof raw === 'string')
723
+ return { mode: raw };
724
+ return raw;
725
+ }
726
+ /**
727
+ * CFG-27 — resolve the effective {@link ResolvedApprovals} for the active command.
728
+ *
729
+ * There is no defaults *matrix*: §1.1 makes `assisted` the default in every context, so this
730
+ * resolver neither detects nor accepts a "context". Precedence is the only thing it decides, and
731
+ * §9.1 splits it in two:
732
+ *
733
+ * - **The scalars — `mode`, `rater`, `raterTimeoutMs` — are replaced** when the per-command value
734
+ * states them and **inherited from the root when it does not**. So the scalar sugar
735
+ * `"code": { "approvals": "bypass" }` is exactly `{ mode: 'bypass' }` merged over the root: it
736
+ * sets the rung and nothing else.
737
+ * - **`deny` and `escalate` never replace: they CONCATENATE across every scope.** A
738
+ * command-specific `deny` *adds to* the root's. Removing an inherited prohibition for one command
739
+ * is deliberately not expressible.
740
+ * - **`allow` is REPLACED when the per-command value states its own, and inherited when it does
741
+ * not.** A per-command scope may therefore narrow what runs unprompted, and may never widen what
742
+ * is prohibited.
743
+ * - **`mcp` (EXT-70 §4.7) follows `allow`, not the restrictive lists**: replaced when the
744
+ * per-command value states it, inherited when it does not. Believing a hint is a PERMISSIVE act
745
+ * in both directions — it can make an `allow` hint entry fire and can make a `deny` hint entry
746
+ * stop firing — so it merges the way the permissive list does, and a per-command scope can
747
+ * narrow the session's trust (`"mcp": {}` believes nothing) but never inherits half of it by
748
+ * accident. Deep-merging the two scopes' `servers` maps was rejected for the same reason: it
749
+ * would leave a deliberately distrustful per-command block silently carrying the root's trust.
750
+ *
751
+ * **The two halves differ because the costs differ (§3.1), not for tidiness.** A missed allow entry
752
+ * escalates and a missed deny entry falls through to the rater — neither is an execution — while a
753
+ * too-broad allow entry *runs, unrated and unprompted*. Concatenating the restrictive lists fails
754
+ * toward a prompt; concatenating the permissive one fails toward an execution, and would leave a
755
+ * deliberately restrictive per-command rung with no way to shed the root's standing grants. Do not
756
+ * "regularize" these three into one policy: the direction each list fails in is the whole design.
757
+ *
758
+ * On the restrictive side the pressure runs the other way (§11.1f). Were the per-command value to
759
+ * replace the root wholesale, the friendliest spelling of "stop asking me about `code`" would also
760
+ * delete every `deny` entry — at the one rung where the deny list and the §8 floor are the only
761
+ * checks left. A prohibition a nested config key can quietly delete is not a hardline.
762
+ *
763
+ * Concatenation order cannot change any outcome (`resolveApprovalRules` consults every deny entry
764
+ * before any escalate entry and every escalate entry before any allow entry), so root-first is a
765
+ * convention for readability — matching `GthAgentRunner.approvalRuleLists`, where the declared
766
+ * entries precede the runtime grants — and never a precedence.
767
+ *
768
+ * Defaults are applied HERE, at the read site, rather than in `DEFAULT_CONFIG` — so the
769
+ * effective-config snapshot the `/config` panel renders never churns (à la GS2-34
770
+ * `injectModelContext` / GS2-63 `output.header`).
771
+ *
772
+ * This is the per-command half. The cross-LAYER half (a project config's lists adding to a global
773
+ * config's rather than replacing them) is the additive-array policy in `config/loader.ts`; both are
774
+ * needed, since either alone still loses a list silently.
775
+ *
776
+ * @param command The active command; selects the per-command block.
777
+ */
778
+ export function resolveApprovals(config, command) {
779
+ const root = toApprovalsObject(config?.approvals);
780
+ const perCommand = toApprovalsObject(command
781
+ ? config?.commands?.[command]
782
+ ?.approvals
783
+ : undefined);
784
+ return {
785
+ rung: perCommand?.mode ?? root?.mode ?? DEFAULT_APPROVAL_RUNG,
786
+ rater: perCommand?.rater ?? root?.rater,
787
+ // `??`, so an EXPLICIT empty list is honoured: `allow: []` on a command states "nothing is
788
+ // pre-trusted here" and must not read as "said nothing, inherit the root's".
789
+ allow: perCommand?.allow ?? root?.allow ?? [],
790
+ deny: [...(root?.deny ?? []), ...(perCommand?.deny ?? [])],
791
+ escalate: [...(root?.escalate ?? []), ...(perCommand?.escalate ?? [])],
792
+ raterTimeoutMs: perCommand?.raterTimeoutMs ?? root?.raterTimeoutMs,
793
+ // `??`, so an EXPLICIT empty block is honoured exactly as an explicit empty `allow` is: it
794
+ // states "believe nothing external here" and must not read as "said nothing, inherit the root".
795
+ mcp: perCommand?.mcp ?? root?.mcp,
796
+ };
797
+ }
798
+ /**
799
+ * EXT-52 — the ONE shell approval-gate policy every agent backend resolves
800
+ * (`GthLangChainAgent` = lean/default). It decides whether the opt-in `run_shell_command` tool is
801
+ * gated behind the per-command approval interrupt, and which status notice (if any) the backend
802
+ * should surface. A backend contributes only HOW it installs the interrupt; the policy and its
803
+ * user-facing copy live here so no two can drift (and so a later rename of this config surface has
804
+ * one place to change).
805
+ *
806
+ * CFG-27 — **the tool is gated whenever it is enabled, at every rung including `bypass`.** CFG-26
807
+ * used to leave it UNGATED under `bypass` outside interactive `code`, which the ladder cannot
808
+ * afford: §2.5 makes the declared **deny list the one check `bypass` keeps**, and a deny entry can
809
+ * only fire if the call reaches `GthAgentRunner.decideToolApproval` — an ungated call never does.
810
+ * Gating unconditionally also keeps the rung switchable mid-session (`/approvals <rung>`), since a
811
+ * tool wired without the interrupt cannot be re-gated without rebuilding the agent.
812
+ *
813
+ * What each rung then does is decided in `decideToolApproval`, not here:
814
+ * • `bypass` — deny list, then approve without prompting or rating.
815
+ * • `manual`/`write` — deny list, allow-list, else escalate to the human.
816
+ * • `assisted`/`auto` — deny list, allow-list, then the rater.
817
+ *
818
+ * **This decides the SHELL's gating only, and it is not the whole gated set.** With the shell tool
819
+ * disabled — or on a non-dev-tools command (chat/api/…) — nothing about the shell is gated and
820
+ * nothing is announced, but at `manual` and `write` {@link resolveGatedToolNames} still gates
821
+ * every bound tool the rung does not auto-grant, so an MCP call in a plain `chat` session is
822
+ * escalated there. Read `gateShell` as "does the shell need the interrupt", never as "is the
823
+ * interrupt needed at all".
824
+ *
825
+ * Shell enablement itself is resolved through {@link getEffectiveDevToolsConfig} +
826
+ * {@link isShellToolEnabled}, so the gate stays in lockstep with where `GthDevToolkit` actually
827
+ * emits the tool; the posture comes from {@link resolveApprovals}, so this and the runner can
828
+ * never disagree about which rung is in force.
829
+ */
830
+ export function resolveShellApprovalGate(config, command) {
831
+ const devTools = getEffectiveDevToolsConfig(config, command);
832
+ const gateShell = isShellToolEnabled(devTools, command);
833
+ if (!gateShell)
834
+ return { gateShell };
835
+ const { rung } = resolveApprovals(config, command);
836
+ if (rung === 'bypass') {
837
+ return {
838
+ gateShell,
839
+ notice: {
840
+ level: StatusLevel.WARNING,
841
+ message: 'Shell tool (run_shell_command): commands run without asking and without rating ' +
842
+ '(approvals: bypass). Only your deny list still applies — type /approvals assisted to ' +
843
+ 'rate commands again.',
844
+ },
845
+ };
846
+ }
847
+ if (isRatedRung(rung)) {
848
+ // **Both rated modes get the SAME tail, because it is true of both and it is all this line
849
+ // promises**: nothing the rater does not clear simply runs. They reach it differently — at
850
+ // `assisted` an unsafe-looking command goes to the human, at `auto` ([[EXT-29]] §5) it is
851
+ // refused back to the agent first and reaches the human when a bound is spent — and at `auto`
852
+ // both halves happen within one session, so a per-mode tail could only pick one of them. This
853
+ // is the startup notice a user meets while working out what their config does; the mode's own
854
+ // description ({@link APPROVAL_RUNG_DESCRIPTIONS}) is where the difference is stated.
855
+ return {
856
+ gateShell,
857
+ notice: {
858
+ level: StatusLevel.INFO,
859
+ message: `Shell tool (run_shell_command) rated by the auto-rater (approvals: ${rung}); ` +
860
+ 'anything it does not rate safe is still refused or escalated to you.',
861
+ },
862
+ };
863
+ }
864
+ return {
865
+ gateShell,
866
+ notice: {
867
+ level: StatusLevel.INFO,
868
+ message: `Shell tool (run_shell_command) enabled with per-command approval (approvals: ${rung}).`,
869
+ },
870
+ };
141
871
  }
142
872
  //# sourceMappingURL=shell-policy.js.map