@gaunt-sloth/core 2.0.0-alpha.3 → 2.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +52 -20
  2. package/dist/config/colour.d.ts +38 -0
  3. package/dist/config/colour.js +36 -0
  4. package/dist/config/colour.js.map +1 -0
  5. package/dist/config/defaults.d.ts +84 -0
  6. package/dist/config/defaults.js +99 -0
  7. package/dist/config/defaults.js.map +1 -0
  8. package/dist/config/filesystem-tools.d.ts +41 -0
  9. package/dist/config/filesystem-tools.js +56 -0
  10. package/dist/config/filesystem-tools.js.map +1 -0
  11. package/dist/config/jsonc.d.ts +12 -0
  12. package/dist/config/jsonc.js +41 -0
  13. package/dist/config/jsonc.js.map +1 -0
  14. package/dist/config/loader.d.ts +246 -0
  15. package/dist/config/loader.js +1321 -0
  16. package/dist/config/loader.js.map +1 -0
  17. package/dist/config/mouse.d.ts +50 -0
  18. package/dist/config/mouse.js +44 -0
  19. package/dist/config/mouse.js.map +1 -0
  20. package/dist/config/profiles.d.ts +68 -0
  21. package/dist/config/profiles.js +93 -0
  22. package/dist/config/profiles.js.map +1 -0
  23. package/dist/config/providerKeys.d.ts +69 -0
  24. package/dist/config/providerKeys.js +69 -0
  25. package/dist/config/providerKeys.js.map +1 -0
  26. package/dist/config/schema.d.ts +2999 -0
  27. package/dist/config/schema.js +1519 -0
  28. package/dist/config/schema.js.map +1 -0
  29. package/dist/config/shell-policy.d.ts +896 -0
  30. package/dist/config/shell-policy.js +750 -0
  31. package/dist/config/shell-policy.js.map +1 -0
  32. package/dist/config/tool-descriptions.d.ts +211 -0
  33. package/dist/config/tool-descriptions.js +272 -0
  34. package/dist/config/tool-descriptions.js.map +1 -0
  35. package/dist/config/types.d.ts +760 -0
  36. package/dist/config/types.js +13 -0
  37. package/dist/config/types.js.map +1 -0
  38. package/dist/config.d.ts +34 -827
  39. package/dist/config.js +25 -657
  40. package/dist/config.js.map +1 -1
  41. package/dist/constants.d.ts +46 -0
  42. package/dist/constants.js +46 -0
  43. package/dist/constants.js.map +1 -1
  44. package/dist/core/GthAbstractAgent.d.ts +135 -2
  45. package/dist/core/GthAbstractAgent.js +496 -30
  46. package/dist/core/GthAbstractAgent.js.map +1 -1
  47. package/dist/core/GthAgentRunner.d.ts +568 -54
  48. package/dist/core/GthAgentRunner.js +1537 -136
  49. package/dist/core/GthAgentRunner.js.map +1 -1
  50. package/dist/core/GthLangChainAgent.d.ts +115 -0
  51. package/dist/core/GthLangChainAgent.js +644 -17
  52. package/dist/core/GthLangChainAgent.js.map +1 -1
  53. package/dist/core/approvals/annotations.d.ts +122 -0
  54. package/dist/core/approvals/annotations.js +137 -0
  55. package/dist/core/approvals/annotations.js.map +1 -0
  56. package/dist/core/approvals/grants.d.ts +216 -0
  57. package/dist/core/approvals/grants.js +469 -0
  58. package/dist/core/approvals/grants.js.map +1 -0
  59. package/dist/core/approvals/matcher.d.ts +202 -0
  60. package/dist/core/approvals/matcher.js +267 -0
  61. package/dist/core/approvals/matcher.js.map +1 -0
  62. package/dist/core/approvals/mcpSubjects.d.ts +40 -0
  63. package/dist/core/approvals/mcpSubjects.js +99 -0
  64. package/dist/core/approvals/mcpSubjects.js.map +1 -0
  65. package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
  66. package/dist/core/approvals/toolAnnotationSources.js +277 -0
  67. package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
  68. package/dist/core/approvals/toolHost.d.ts +46 -0
  69. package/dist/core/approvals/toolHost.js +108 -0
  70. package/dist/core/approvals/toolHost.js.map +1 -0
  71. package/dist/core/debugCapture.d.ts +75 -0
  72. package/dist/core/debugCapture.js +101 -0
  73. package/dist/core/debugCapture.js.map +1 -0
  74. package/dist/core/gthLeanAgentFactory.d.ts +9 -0
  75. package/dist/core/gthLeanAgentFactory.js +10 -0
  76. package/dist/core/gthLeanAgentFactory.js.map +1 -0
  77. package/dist/core/launchBanner.d.ts +120 -0
  78. package/dist/core/launchBanner.js +418 -0
  79. package/dist/core/launchBanner.js.map +1 -0
  80. package/dist/core/plainToolIndication.d.ts +15 -0
  81. package/dist/core/plainToolIndication.js +174 -0
  82. package/dist/core/plainToolIndication.js.map +1 -0
  83. package/dist/core/reasoningBlocks.d.ts +60 -0
  84. package/dist/core/reasoningBlocks.js +98 -0
  85. package/dist/core/reasoningBlocks.js.map +1 -0
  86. package/dist/core/refusal.d.ts +53 -0
  87. package/dist/core/refusal.js +133 -0
  88. package/dist/core/refusal.js.map +1 -0
  89. package/dist/core/runStats.d.ts +52 -0
  90. package/dist/core/runStats.js +118 -0
  91. package/dist/core/runStats.js.map +1 -0
  92. package/dist/core/shell/ShellCommandFailedError.d.ts +54 -0
  93. package/dist/core/shell/ShellCommandFailedError.js +68 -0
  94. package/dist/core/shell/ShellCommandFailedError.js.map +1 -0
  95. package/dist/core/shell/abstention.d.ts +88 -0
  96. package/dist/core/shell/abstention.js +184 -0
  97. package/dist/core/shell/abstention.js.map +1 -0
  98. package/dist/core/shell/approvalCapture.d.ts +271 -0
  99. package/dist/core/shell/approvalCapture.js +108 -0
  100. package/dist/core/shell/approvalCapture.js.map +1 -0
  101. package/dist/core/shell/approvalStop.d.ts +84 -0
  102. package/dist/core/shell/approvalStop.js +119 -0
  103. package/dist/core/shell/approvalStop.js.map +1 -0
  104. package/dist/core/shell/arity.d.ts +6 -0
  105. package/dist/core/shell/arity.js +20 -6
  106. package/dist/core/shell/arity.js.map +1 -1
  107. package/dist/core/shell/denylist.d.ts +11 -0
  108. package/dist/core/shell/denylist.js +37 -0
  109. package/dist/core/shell/denylist.js.map +1 -0
  110. package/dist/core/shell/escalationSeverity.d.ts +141 -0
  111. package/dist/core/shell/escalationSeverity.js +89 -0
  112. package/dist/core/shell/escalationSeverity.js.map +1 -0
  113. package/dist/core/shell/framing.d.ts +190 -0
  114. package/dist/core/shell/framing.js +633 -0
  115. package/dist/core/shell/framing.js.map +1 -0
  116. package/dist/core/shell/hardline.d.ts +81 -0
  117. package/dist/core/shell/hardline.js +607 -0
  118. package/dist/core/shell/hardline.js.map +1 -0
  119. package/dist/core/shell/negotiation.d.ts +249 -0
  120. package/dist/core/shell/negotiation.js +355 -0
  121. package/dist/core/shell/negotiation.js.map +1 -0
  122. package/dist/core/shell/normalize.d.ts +44 -4
  123. package/dist/core/shell/normalize.js +61 -7
  124. package/dist/core/shell/normalize.js.map +1 -1
  125. package/dist/core/shell/openWorld.d.ts +263 -0
  126. package/dist/core/shell/openWorld.js +1188 -0
  127. package/dist/core/shell/openWorld.js.map +1 -0
  128. package/dist/core/shell/rater.d.ts +935 -0
  129. package/dist/core/shell/rater.js +1473 -0
  130. package/dist/core/shell/rater.js.map +1 -0
  131. package/dist/core/shell/raterModel.d.ts +41 -0
  132. package/dist/core/shell/raterModel.js +51 -0
  133. package/dist/core/shell/raterModel.js.map +1 -0
  134. package/dist/core/shell/rejection.d.ts +69 -0
  135. package/dist/core/shell/rejection.js +38 -0
  136. package/dist/core/shell/rejection.js.map +1 -0
  137. package/dist/core/toolCallRepair/grammar.d.ts +41 -0
  138. package/dist/core/toolCallRepair/grammar.js +116 -0
  139. package/dist/core/toolCallRepair/grammar.js.map +1 -0
  140. package/dist/core/toolCallRepair/index.d.ts +2 -0
  141. package/dist/core/toolCallRepair/index.js +7 -0
  142. package/dist/core/toolCallRepair/index.js.map +1 -0
  143. package/dist/core/toolCallRepair/payload.d.ts +36 -0
  144. package/dist/core/toolCallRepair/payload.js +341 -0
  145. package/dist/core/toolCallRepair/payload.js.map +1 -0
  146. package/dist/core/toolCallRepair/promote.d.ts +45 -0
  147. package/dist/core/toolCallRepair/promote.js +90 -0
  148. package/dist/core/toolCallRepair/promote.js.map +1 -0
  149. package/dist/core/toolDisplay.d.ts +123 -0
  150. package/dist/core/toolDisplay.js +451 -0
  151. package/dist/core/toolDisplay.js.map +1 -0
  152. package/dist/core/toolOutputChannel.d.ts +95 -0
  153. package/dist/core/toolOutputChannel.js +165 -0
  154. package/dist/core/toolOutputChannel.js.map +1 -0
  155. package/dist/core/types.d.ts +294 -12
  156. package/dist/core/types.js.map +1 -1
  157. package/dist/history/historyFormat.d.ts +28 -0
  158. package/dist/history/historyFormat.js +127 -0
  159. package/dist/history/historyFormat.js.map +1 -0
  160. package/dist/history/historyStore.d.ts +198 -0
  161. package/dist/history/historyStore.js +482 -0
  162. package/dist/history/historyStore.js.map +1 -0
  163. package/dist/history/recordSession.d.ts +37 -0
  164. package/dist/history/recordSession.js +56 -0
  165. package/dist/history/recordSession.js.map +1 -0
  166. package/dist/index.d.ts +4 -0
  167. package/dist/index.js +4 -0
  168. package/dist/index.js.map +1 -1
  169. package/dist/providers/anthropic.d.ts +1 -1
  170. package/dist/providers/anthropic.js +5 -10
  171. package/dist/providers/anthropic.js.map +1 -1
  172. package/dist/providers/deepseek.d.ts +1 -1
  173. package/dist/providers/deepseek.js +5 -10
  174. package/dist/providers/deepseek.js.map +1 -1
  175. package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
  176. package/dist/providers/geminiSchemaSanitizer.js +347 -0
  177. package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
  178. package/dist/providers/geminiThinking.d.ts +52 -0
  179. package/dist/providers/geminiThinking.js +72 -0
  180. package/dist/providers/geminiThinking.js.map +1 -0
  181. package/dist/providers/google-genai.d.ts +1 -1
  182. package/dist/providers/google-genai.js +12 -11
  183. package/dist/providers/google-genai.js.map +1 -1
  184. package/dist/providers/groq.d.ts +1 -1
  185. package/dist/providers/groq.js +5 -10
  186. package/dist/providers/groq.js.map +1 -1
  187. package/dist/providers/huggingface.d.ts +25 -0
  188. package/dist/providers/huggingface.js +69 -0
  189. package/dist/providers/huggingface.js.map +1 -0
  190. package/dist/providers/modelCatalog.d.ts +109 -0
  191. package/dist/providers/modelCatalog.js +245 -0
  192. package/dist/providers/modelCatalog.js.map +1 -0
  193. package/dist/providers/modelDiscovery.d.ts +153 -6
  194. package/dist/providers/modelDiscovery.js +295 -41
  195. package/dist/providers/modelDiscovery.js.map +1 -1
  196. package/dist/providers/ollama.d.ts +19 -5
  197. package/dist/providers/ollama.js +60 -52
  198. package/dist/providers/ollama.js.map +1 -1
  199. package/dist/providers/openai.d.ts +1 -1
  200. package/dist/providers/openai.js +39 -10
  201. package/dist/providers/openai.js.map +1 -1
  202. package/dist/providers/openrouter.d.ts +4 -5
  203. package/dist/providers/openrouter.js +20 -35
  204. package/dist/providers/openrouter.js.map +1 -1
  205. package/dist/providers/vertexai.d.ts +1 -1
  206. package/dist/providers/vertexai.js +12 -11
  207. package/dist/providers/vertexai.js.map +1 -1
  208. package/dist/providers/xai.d.ts +1 -1
  209. package/dist/providers/xai.js +5 -10
  210. package/dist/providers/xai.js.map +1 -1
  211. package/dist/runtime/askStructured.d.ts +105 -0
  212. package/dist/runtime/askStructured.js +120 -0
  213. package/dist/runtime/askStructured.js.map +1 -0
  214. package/dist/runtime/conversation.d.ts +60 -0
  215. package/dist/runtime/conversation.js +151 -0
  216. package/dist/runtime/conversation.js.map +1 -0
  217. package/dist/runtime/singleShot.d.ts +25 -5
  218. package/dist/runtime/singleShot.js +95 -36
  219. package/dist/runtime/singleShot.js.map +1 -1
  220. package/dist/runtime/structuredOutput.d.ts +104 -0
  221. package/dist/runtime/structuredOutput.js +393 -0
  222. package/dist/runtime/structuredOutput.js.map +1 -0
  223. package/dist/utils/ProgressIndicator.d.ts +21 -0
  224. package/dist/utils/ProgressIndicator.js +30 -3
  225. package/dist/utils/ProgressIndicator.js.map +1 -1
  226. package/dist/utils/aiignoreUtils.js.map +1 -1
  227. package/dist/utils/binaryOutputUtils.js.map +1 -1
  228. package/dist/utils/consoleUtils.d.ts +52 -0
  229. package/dist/utils/consoleUtils.js +72 -2
  230. package/dist/utils/consoleUtils.js.map +1 -1
  231. package/dist/utils/crashHandler.d.ts +87 -0
  232. package/dist/utils/crashHandler.js +128 -0
  233. package/dist/utils/crashHandler.js.map +1 -0
  234. package/dist/utils/debugDump.d.ts +134 -0
  235. package/dist/utils/debugDump.js +381 -0
  236. package/dist/utils/debugDump.js.map +1 -0
  237. package/dist/utils/debugUtils.d.ts +13 -4
  238. package/dist/utils/debugUtils.js +36 -13
  239. package/dist/utils/debugUtils.js.map +1 -1
  240. package/dist/utils/displayWidth.d.ts +53 -0
  241. package/dist/utils/displayWidth.js +195 -0
  242. package/dist/utils/displayWidth.js.map +1 -0
  243. package/dist/utils/fileUtils.d.ts +24 -2
  244. package/dist/utils/fileUtils.js +54 -12
  245. package/dist/utils/fileUtils.js.map +1 -1
  246. package/dist/utils/llmUtils.d.ts +39 -8
  247. package/dist/utils/llmUtils.js +76 -8
  248. package/dist/utils/llmUtils.js.map +1 -1
  249. package/dist/utils/redactSecrets.d.ts +63 -0
  250. package/dist/utils/redactSecrets.js +286 -0
  251. package/dist/utils/redactSecrets.js.map +1 -0
  252. package/dist/utils/systemPromptNotes.d.ts +225 -0
  253. package/dist/utils/systemPromptNotes.js +340 -0
  254. package/dist/utils/systemPromptNotes.js.map +1 -0
  255. package/dist/utils/systemUtils.d.ts +49 -1
  256. package/dist/utils/systemUtils.js +67 -3
  257. package/dist/utils/systemUtils.js.map +1 -1
  258. package/dist/utils/toolMatching.d.ts +30 -0
  259. package/dist/utils/toolMatching.js +44 -0
  260. package/dist/utils/toolMatching.js.map +1 -0
  261. package/dist/utils/untrustedText.d.ts +66 -0
  262. package/dist/utils/untrustedText.js +80 -0
  263. package/dist/utils/untrustedText.js.map +1 -0
  264. package/package.json +22 -6
  265. package/schema/gsloth-config.schema.json +3134 -0
  266. package/dist/core/shell/allowlist.d.ts +0 -75
  267. package/dist/core/shell/allowlist.js +0 -187
  268. package/dist/core/shell/allowlist.js.map +0 -1
  269. package/dist/core/shell/judge.d.ts +0 -161
  270. package/dist/core/shell/judge.js +0 -261
  271. package/dist/core/shell/judge.js.map +0 -1
@@ -1,15 +1,121 @@
1
- import { getEffectiveDevToolsConfig, getShellJudgeSettings, isShellAllowlistEnabled, isShellAllowlistPersisted, isShellJudgeEnabled, } from '#src/config.js';
1
+ import { DEFAULT_APPROVAL_RUNG, describeGrantedBuiltInTools, isNegotiatingRung, isRatedRung, isToolGatedAtRung, resolveApprovals, resolveGatedToolNames, resolveShellApprovalGate, SHELL_TOOL_NAME, TOOL_ANNOTATION_HINTS, } from '#src/config.js';
2
+ import { StatusLevel, } from '#src/core/types.js';
2
3
  import { GthLangChainAgent } from '#src/core/GthLangChainAgent.js';
3
- import { AllowlistStore, PersistedAllowlist, matchesApproval, } from '#src/core/shell/allowlist.js';
4
+ import { annotationWeakenings, ApprovalGrantStore, describeWeakenedGrant, PersistedApprovalGrants, shellGrantEntry, toolGrantEntry, trustWithdrawalWeakens, } from '#src/core/approvals/grants.js';
5
+ import { renderApprovalEntryObject } from '#src/config/schema.js';
4
6
  import { classifyCommand } from '#src/core/shell/arity.js';
7
+ import { describeAbstention } from '#src/core/shell/abstention.js';
5
8
  import { normalizeCommand } from '#src/core/shell/normalize.js';
6
- import { judgeShellCommand, mapVerdictToAction, } from '#src/core/shell/judge.js';
9
+ import { ApprovalStopError, AttackHaltError, NonInteractiveEscalationError, } from '#src/core/shell/approvalStop.js';
10
+ import { applyDestructiveFloor, isBelowDestructiveFloor, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, preflightFloorFinding, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
11
+ import { ApprovalCaptureLog, } from '#src/core/shell/approvalCapture.js';
12
+ import { buildHardlineRefusal, checkHardline } from '#src/core/shell/hardline.js';
13
+ import { renderNegotiationTranscript, ShellNegotiationState } from '#src/core/shell/negotiation.js';
14
+ import { buildRejectionMessage } from '#src/core/shell/rejection.js';
15
+ import { describeApprovalEntry, resolveApprovalRules, } from '#src/core/approvals/matcher.js';
16
+ import { createEffectiveToolAnnotationSource, trustedAnnotationHints, } from '#src/core/approvals/annotations.js';
17
+ import { approvalSubjectForToolName } from '#src/core/approvals/mcpSubjects.js';
18
+ import { toolCallHosts } from '#src/core/approvals/toolHost.js';
19
+ import { builtInToolAnnotations, mcpDeclaredAnnotationLookup, } from '#src/core/approvals/toolAnnotationSources.js';
20
+ import { resolveRaterModel } from '#src/core/shell/raterModel.js';
7
21
  import { env } from '#src/utils/systemUtils.js';
8
22
  import { getGslothConfigWritePath } from '#src/utils/fileUtils.js';
9
23
  import { SHELL_ALLOWLIST_FILE } from '#src/constants.js';
10
24
  import { enhanceVertexUnauthorizedMessage } from '#src/utils/vertexaiUtils.js';
11
25
  import { getNewRunnableConfig } from '#src/utils/llmUtils.js';
12
26
  import { initDebugLogging, debugLog, debugLogError, debugLogObject, } from '#src/utils/debugUtils.js';
27
+ import { updateCrashContext } from '#src/utils/crashHandler.js';
28
+ import { setToolDisplayConfig } from '#src/core/toolDisplay.js';
29
+ /**
30
+ * GS2-48 — how many trailing messages of the in-flight turn to hand the crash handler as the
31
+ * transcript tail. A crash file is triage, not the full session, so only the last few messages are
32
+ * kept; they are redacted (GS2-47) by the crash snapshot writer before anything reaches disk.
33
+ */
34
+ const CRASH_TRANSCRIPT_TAIL_MESSAGES = 8;
35
+ /**
36
+ * A private copy of a rule entry, for handing to a display. `pattern` is the one field that can be
37
+ * an object (a `hint` pattern, §3.1), so it is copied too — a shallow spread alone would leave the
38
+ * displayed entry sharing the very object the matcher compares against.
39
+ */
40
+ function copyApprovalEntry(entry) {
41
+ if (entry.type === 'shell' || typeof entry.pattern === 'string')
42
+ return { ...entry };
43
+ return { ...entry, pattern: { ...entry.pattern } };
44
+ }
45
+ /**
46
+ * [[EXT-29]] §5.1 — the `justification` argument of a `run_shell_command` call, when the model
47
+ * supplied a usable one.
48
+ *
49
+ * Read defensively for the same reason `command` is: these are model-authored arguments arriving
50
+ * through a schema the graph validated but that this method does not re-validate, so a non-string
51
+ * or a whitespace-only value is *absent* rather than a second spelling of empty.
52
+ *
53
+ * **What the trim buys is the RECORDED ROUND, not the prompt.** The rating prompt is already safe
54
+ * without it — `buildNegotiationContextBlock` drops a blank justification before it renders a fence,
55
+ * and `renderNegotiationTranscript` drops one before it renders a line. What only this can do is
56
+ * keep the round itself honest at the point it is written: a round carrying a whitespace-only
57
+ * justification asserts that the agent argued something it did not, to everything that later reads
58
+ * the transcript rather than a rendering of it. Both downstream guards then stay defence in depth
59
+ * instead of being the only thing between a blank string and that claim.
60
+ */
61
+ function shellJustification(args) {
62
+ const value = args?.justification;
63
+ if (typeof value !== 'string')
64
+ return undefined;
65
+ return value.trim().length === 0 ? undefined : value;
66
+ }
67
+ /**
68
+ * [[EXT-29]] §5.1 — the text of the human messages in a turn's input, for the rater's last-5 window.
69
+ *
70
+ * Structural and fail-soft, like `runStats`'s accumulator: the runner is handed `BaseMessage`s from
71
+ * several surfaces (readline, TUI, ACP, AG-UI) and a multimodal turn's `content` is an array of
72
+ * blocks rather than a string. Only the text is taken — §4.3 admits no file contents, no tool
73
+ * output and no fetched pages, and an image block is none of the three.
74
+ */
75
+ function humanMessageTexts(messages) {
76
+ const texts = [];
77
+ for (const message of messages) {
78
+ try {
79
+ const type = message?.getType?.();
80
+ if (type !== 'human')
81
+ continue;
82
+ const content = message.content;
83
+ if (typeof content === 'string') {
84
+ texts.push(content);
85
+ continue;
86
+ }
87
+ if (!Array.isArray(content))
88
+ continue;
89
+ const parts = content
90
+ .filter((block) => typeof block === 'object' &&
91
+ block !== null &&
92
+ block.type === 'text' &&
93
+ typeof block.text === 'string')
94
+ .map((block) => block.text);
95
+ if (parts.length > 0)
96
+ texts.push(parts.join('\n'));
97
+ }
98
+ catch {
99
+ /* fail-soft: an odd message shape just means that message contributes nothing */
100
+ }
101
+ }
102
+ return texts;
103
+ }
104
+ /**
105
+ * GS2-81 — the page carrying WHICH commands honor `agent.backend`, pointed at rather than
106
+ * enumerated in the notice itself.
107
+ *
108
+ * The list belongs in exactly one place. A copy of it inside a runtime string is a second source of
109
+ * truth with nothing pinning it: the first draft of this notice already disagreed with the docs
110
+ * table written in the same commit (it omitted `workflow` agent steps), and no test could tell,
111
+ * because a sentence is not a claim a test can check. The docs table is checkable, and
112
+ * `agentBackendScope.spec.ts` pins that this URL's anchor still resolves to a real heading in it.
113
+ *
114
+ * **A GitHub blob URL, matching the other user-facing runtime doc links in this repo** (the
115
+ * approvals-protection pointer in `config/shell-policy.ts` and the 2.0 migration pointer in
116
+ * `config/schema.ts`) — a running CLI's user has no checkout for a relative path to resolve in.
117
+ */
118
+ export const AGENT_BACKEND_SCOPE_DOCS_URL = 'https://github.com/pukeko-robotics/gaunt-sloth/blob/main/docs/configuration/profiles.md#which-commands-honour-it';
13
119
  /**
14
120
  * Agent simplifies interaction with LLM and reduces it to calling a few methods
15
121
  * {@link GthAgentRunner#init} and {@link GthAgentRunner#processMessages}.
@@ -28,40 +134,106 @@ export class GthAgentRunner {
28
134
  * for non-interactive entrypoints (a scripted `exec` run with no TTY to prompt on).
29
135
  */
30
136
  toolApprovalCallback = null;
137
+ /**
138
+ * [[TUI-C68]] §6.1 — consumer hook invoked when the rater rates a command an `attack`, so an
139
+ * interactive surface can show the red banner before the run ends. Set via
140
+ * {@link setAttackHaltCallback}; **when unset the runner halts immediately**, which is the
141
+ * behaviour every surface had before a banner existed. A surface that forgets to wire it
142
+ * therefore keeps the halt rather than losing it.
143
+ */
144
+ attackHaltCallback = null;
31
145
  /** The command the runner was initialized for; selects which `devTools` config applies. */
32
146
  command = undefined;
33
147
  /**
34
- * EXT-12runtime, session-scoped yolo flag toggled by the `/yolo` slash command. Distinct
35
- * from the static `devTools.shellYolo` config flag (which omits the tool from `interruptOn` at
36
- * agent-build time and cannot be changed mid-session). Because the tool stays gated (in
37
- * `interruptOn`), this flag is consulted at the TOP of {@link decideToolApproval}: when ON, a
38
- * gated `run_shell_command` is auto-approved WITHOUT prompting (yolo behaviour) for the rest of
39
- * this runner's life. Never persisted; defaults OFF. It does NOT disable the hardline floor —
40
- * catastrophic commands are still refused at exec time in `GthDevToolkit.executeCommand`.
148
+ * GS2-16snapshot of the last turn's analytics (token usage + invoked tools), captured from
149
+ * the agent at {@link cleanup} time. Needed because {@link runSingleShot} reads stats AFTER it
150
+ * has already called `cleanup()` (which nulls the agent); interactive callers read live via
151
+ * {@link getRunStats} before cleanup. Defaults to an empty tally.
152
+ */
153
+ lastRunStats = { tools: [] };
154
+ /**
155
+ * CFG-27 — the runtime, session-scoped approvals posture, seeded at {@link init} from
156
+ * {@link resolveApprovals} and thereafter switchable for the session by `/approvals <rung>`.
157
+ * **This field, not the interrupt wiring, is where the rung lives.** The backends wire the
158
+ * interrupt rung-independently, so every tool any rung could gate arrives at the top of
159
+ * {@link decideToolApproval} and is judged against the rung recorded here — which is what makes
160
+ * `/approvals manual` take effect mid-session, and what keeps a config that pre-selects
161
+ * `bypass` switchable back. Never persisted.
162
+ *
163
+ * It does NOT disable the hardline floor — catastrophic commands are still refused at exec time
164
+ * in `GthDevToolkit.executeCommand` under every rung.
165
+ */
166
+ sessionApprovals = {
167
+ rung: DEFAULT_APPROVAL_RUNG,
168
+ allow: [],
169
+ deny: [],
170
+ escalate: [],
171
+ };
172
+ /**
173
+ * CFG-26 — the model the AI rater rates with, when `approvals.rater.profile` names an identity
174
+ * profile. Resolved ONCE at {@link init} (never mid-turn) and handed to `rateShellCommand`;
175
+ * `undefined` means no profile is configured and the rater uses the session model.
176
+ */
177
+ raterModel;
178
+ /**
179
+ * EXT-66 — how many rating calls this session gave up on. Counted so the notice can say "3 times
180
+ * this session" rather than repeating an identical line, and so a silent drift toward
181
+ * escalate-everything has a number attached to it.
41
182
  */
42
- sessionYolo = false;
183
+ raterTimeouts = 0;
43
184
  /**
44
- * EXT-9 Tier-2 session allow-list approved command prefixes that auto-approve for the
45
- * life of THIS runner instance. Instance-scoped (not module-global) so concurrent
46
- * sessions (ACP / AG-UI multi-session) cannot stomp each other's approvals.
185
+ * EXT-71 §3.1/§6 what the escalation menu granted at run time, for the life of THIS runner
186
+ * instance: {@link ApprovalEntry} objects, never prefixes, and never anything from config (the
187
+ * declared lists are read-only input consulted straight from the posture). Instance-scoped so
188
+ * concurrent sessions (ACP / AG-UI multi-session) cannot stomp each other's approvals.
47
189
  */
48
- sessionAllowlist = new AllowlistStore();
190
+ sessionGrants = new ApprovalGrantStore();
49
191
  /**
50
- * EXT-9 Tier-2 persisted (`always`) allow-list, loaded lazily on first use from
51
- * `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until the shell tool is gated
52
- * and the allow-list is enabled; null also when persistence is disabled by config.
192
+ * CFG-27 §3 what the escalation menu's *always reject* choice adds at run time, in the same
193
+ * grammar ([[TUI-C26]] wires that writer; the store speaks it already). The entries DECLARED in
194
+ * `approvals.deny` are not here they are read-only config input, matched from the posture — and
195
+ * both are handed to the same matcher, so a runtime refusal and a declared one are one list.
53
196
  */
54
- persistedAllowlist = null;
55
- persistedAllowlistLoaded = false;
197
+ denyGrants = new ApprovalGrantStore();
198
+ /**
199
+ * The persisted (`always`) grant store, loaded lazily on first use from
200
+ * `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until a gated call actually needs it, and
201
+ * null when the file cannot be loaded at all (in which case `always` grants degrade to session).
202
+ */
203
+ persistedGrants = null;
204
+ persistedGrantsLoaded = false;
205
+ /**
206
+ * [[EXT-29]] §5 — the state of the agent↔rater negotiation at `auto`: the transcript, §5.3's
207
+ * consecutive-rejection counter and the reachability bound. Instance-scoped for the same reason
208
+ * the grant stores are — a concurrent ACP / AG-UI session must not inherit another's argument.
209
+ */
210
+ negotiation = new ShellNegotiationState();
211
+ /**
212
+ * [[TUI-C27]] — the diagnostic record of every gated decision this session made, for
213
+ * `/debug-dump`. Instance-scoped for the same reason the negotiation and the grant stores are: a
214
+ * concurrent ACP / AG-UI session must not inherit another's approvals history, and a dump taken
215
+ * in one must not describe the other.
216
+ */
217
+ approvalCaptures = new ApprovalCaptureLog();
218
+ /**
219
+ * GS2-81 — whether the caller supplied a backend factory. When it did NOT, this runner is
220
+ * hard-wired to the lean default below, so a config asking for `agent.backend: 'deep'` cannot be
221
+ * honored no matter what it says. Recorded here (rather than inferred later) because by then the
222
+ * fallback has already collapsed both cases into one function.
223
+ */
224
+ backendFactorySupplied;
56
225
  /**
57
226
  * @param agentFactory Produces the {@link GthAgentInterface} the runner drives.
58
227
  * Defaults to the lean {@link GthLangChainAgent} (core). `@gaunt-sloth/agent`
59
228
  * passes a factory returning a deep `GthDeepAgent` so the same runner can drive a
60
229
  * `createDeepAgent` graph without core depending on deepagents.
230
+ * **Omitting it opts the caller out of `agent.backend`** — see {@link init}, which says so out
231
+ * loud rather than letting the key be dropped in silence.
61
232
  */
62
233
  constructor(statusUpdate, resolvers, agentFactory) {
63
234
  this.statusUpdate = statusUpdate;
64
235
  this.resolvers = resolvers;
236
+ this.backendFactorySupplied = agentFactory !== undefined;
65
237
  this.agentFactory =
66
238
  agentFactory ?? ((status, agentResolvers) => new GthLangChainAgent(status, agentResolvers));
67
239
  }
@@ -74,38 +246,334 @@ export class GthAgentRunner {
74
246
  this.toolApprovalCallback = callback;
75
247
  }
76
248
  /**
77
- * EXT-12flip the runtime, session-scoped yolo flag (the `/yolo` slash command). When ON,
78
- * gated `run_shell_command` calls auto-approve without prompting for the rest of this session;
79
- * the hardline floor still applies at exec time. Returns the NEW state so the caller can render
80
- * a notice. Session-scoped only nothing is written to config.
249
+ * [[TUI-C68]] §6.1 register the handler that shows the **attack banner**, the one way a human
250
+ * gets past an `attack` verdict. Pass `null` to clear.
251
+ *
252
+ * Separate from {@link setToolApprovalCallback} because it is a separate question with an
253
+ * inverted default: an absent approval callback means *this session has nobody to ask*, and an
254
+ * absent one here means *end the run*. Wiring it is what an interactive surface opts into; every
255
+ * other surface keeps the halt (see {@link attackHaltCallback}).
256
+ */
257
+ setAttackHaltCallback(callback) {
258
+ this.attackHaltCallback = callback;
259
+ }
260
+ /**
261
+ * §6.1 — **the single seam between an `attack` verdict and the end of the run.** Both rating
262
+ * paths — the §3.2 allow-match tripwire and the ordinary rater decision — go through here, so the
263
+ * banner cannot be present on one and missing on the other, which is the shape of bug that leaves
264
+ * a halt answerable in some sessions and not others with nothing on screen to tell them apart.
265
+ *
266
+ * It returns a decision for the one answer that grants and throws for everything else:
267
+ *
268
+ * - **no callback → throw**, immediately and unchanged. §6.2's rule is that a run with nobody to
269
+ * ask never blocks and never times out into a grant; the way that is guaranteed is that waiting
270
+ * is something only a wired surface can cause.
271
+ * - **`run-anyway` → approve, scope `once`.** Exactly one command runs. `once` is not a default
272
+ * restated: it is what keeps §6.1's three "never"s true. Returning here is also returning from
273
+ * *before* the block that records a sticky grant, so no allow-list entry and no session grant
274
+ * can be written on this path — the next identical call is rated again and reaches this banner
275
+ * again. Nothing here touches the rung, and nothing disables the rater, the escalation or the
276
+ * halt for anything else.
277
+ * - **anything else → throw.** `stop`, and equally a value a surface invents or forgets to
278
+ * return: the grant is one exact answer and everything else is a refusal.
279
+ */
280
+ async haltOrRunAnyway(command, reason,
281
+ /** [[TUI-C27]] — this decision's record; the banner's answer is a HUMAN's answer. */
282
+ record) {
283
+ if (this.attackHaltCallback) {
284
+ const answer = await this.attackHaltCallback({ command, reason });
285
+ // [[TUI-C27]] — **recorded here, or a run-anyway is indistinguishable from a `safe`
286
+ // rating.** The banner returns an ordinary approval, so without this line the archive would
287
+ // show `approve` at the `rater` stage with nobody named — i.e. the rater appearing to have
288
+ // approved a command it called an attack. That is precisely the misattribution this node
289
+ // exists to remove, on the branch where it costs the most.
290
+ record.humanAnswer = answer === 'run-anyway' ? 'approve' : 'reject';
291
+ if (answer === 'run-anyway')
292
+ return { type: 'approve', scope: 'once' };
293
+ }
294
+ else {
295
+ // §6.2 — no surface wired the banner, so nobody was asked and the run ends.
296
+ record.humanAnswer = 'no-human';
297
+ }
298
+ throw new AttackHaltError(command, reason);
299
+ }
300
+ /**
301
+ * CFG-27 — switch the session-scoped rung (`/approvals <rung>`). Idempotent; returns the NEW
302
+ * rung so the caller can render a notice. Session-scoped only — nothing is written to config,
303
+ * and the declared allow/deny lists are unaffected (they are config input, not session state).
304
+ */
305
+ setSessionApprovalRung(rung) {
306
+ this.sessionApprovals = { ...this.sessionApprovals, rung };
307
+ return this.sessionApprovals.rung;
308
+ }
309
+ /** CFG-27 — the session's current approvals posture (rung + rater profile + declared lists). */
310
+ getSessionApprovals() {
311
+ return this.sessionApprovals;
312
+ }
313
+ /**
314
+ * [[TUI-C27]] — every gated decision this session made, oldest first, for the `/debug-dump`
315
+ * archive.
316
+ *
317
+ * Threaded by each surface into `writeDebugDump`, exactly as `agent.lastModelRequest` is: the
318
+ * writer redacts it with the same pass it applies to every other artifact, and a surface that
319
+ * does not thread it simply omits the file.
320
+ */
321
+ getApprovalCaptures() {
322
+ return this.approvalCaptures.snapshot();
323
+ }
324
+ /**
325
+ * CFG-26 — the allow-list sizes for the `/approvals` display: how many command prefixes the
326
+ * human has trusted this session, and how many are persisted in the project file.
327
+ *
328
+ * READ-ONLY BY CONSTRUCTION: it reports the persisted count only when the store has ALREADY
329
+ * been loaded (or persistence is on and it can be read), and never through a path that would
330
+ * CREATE the store as a side effect of showing a display — a status command must not mutate
331
+ * session state. `always: undefined` therefore means "not loaded / persistence off", which the
332
+ * caller renders as `—` rather than a misleading `0`.
333
+ */
334
+ getAllowlistCounts() {
335
+ const always = this.persistedGrantsLoaded
336
+ ? (this.persistedGrants?.size() ?? undefined)
337
+ : undefined;
338
+ // EXT-71 §3 — every list MUST be inspectable, and the declared entries are in force for this
339
+ // session exactly as the human's own grants are. They are counted alongside them rather than
340
+ // hidden, which is what the count meant before the declared lists stopped seeding the store.
341
+ const session = this.sessionGrants.size() + this.sessionApprovals.allow.length;
342
+ return { session, always };
343
+ }
344
+ /**
345
+ * CFG-27 — the session's deny entries for display: the declared `approvals.deny` entries
346
+ * (rendered one line each) followed by whatever the escalation menu's *always reject* added at
347
+ * run time. Both refuse a call, so both are shown.
348
+ */
349
+ getDenylist() {
350
+ return [...this.sessionApprovals.deny, ...this.denyGrants.entries()].map(describeApprovalEntry);
351
+ }
352
+ /**
353
+ * §3/§4.7.4 — **the grants themselves**, for an approvals view that shows *what* was granted,
354
+ * *when*, and *under which effective annotations*. The counterpart of {@link getAllowlistCounts},
355
+ * which answers only how many.
356
+ *
357
+ * The declared config lists are deliberately NOT here. They are something a human wrote and
358
+ * reviewed, they carry no `grantedAt` and no scope, and `getAllowlistCounts` already counts them
359
+ * alongside these; mixing them in would present a config line as something the session granted.
360
+ *
361
+ * **Read-only in both senses.** It never loads the persisted store — same rule as
362
+ * {@link getAllowlistCounts}: a display must not create the store in order to show it, so a
363
+ * session that has not yet needed the file lists its session grants alone. And every grant is
364
+ * **deep-copied on the way out**, because the stores hand back their live records: the copy on the
365
+ * way in is what makes a snapshot private to its grant, and handing the same object to a renderer
366
+ * would put what the gate matches against one property assignment away from any consumer.
81
367
  */
82
- toggleSessionYolo() {
83
- this.sessionYolo = !this.sessionYolo;
84
- return this.sessionYolo;
368
+ getGrants() {
369
+ const held = [
370
+ ...this.sessionGrants.list(),
371
+ ...(this.persistedGrantsLoaded ? (this.persistedGrants?.list() ?? []) : []),
372
+ ];
373
+ const seen = new Set();
374
+ const grants = [];
375
+ for (const grant of held) {
376
+ // An `always` grant is written to BOTH stores, so identity de-duplication is what keeps it
377
+ // from being displayed twice. The same question `ApprovalGrantStore.add` asks.
378
+ const key = renderApprovalEntryObject(grant.entry);
379
+ if (seen.has(key))
380
+ continue;
381
+ seen.add(key);
382
+ grants.push({
383
+ ...grant,
384
+ entry: copyApprovalEntry(grant.entry),
385
+ ...(grant.annotations ? { annotations: { ...grant.annotations } } : {}),
386
+ });
387
+ }
388
+ return grants;
85
389
  }
86
- /** EXT-12 — current state of the runtime session-scoped yolo flag (see {@link toggleSessionYolo}). */
87
- isSessionYolo() {
88
- return this.sessionYolo;
390
+ /**
391
+ * §4.7.1 — **which of each server's annotation hints this session believes**, for display.
392
+ *
393
+ * Every key either side names is listed: a configured `mcpServers` key with no policy of its own
394
+ * (which resolves through `defaults`), and a policy key naming a server the config does not have
395
+ * (which is what a typo looks like). Resolution is {@link trustedAnnotationHints}, the same
396
+ * function the gate derives effective annotations through, so the display cannot claim a
397
+ * relationship the gate does not act on.
398
+ */
399
+ getMcpAnnotationTrust() {
400
+ const mcp = this.sessionApprovals.mcp;
401
+ const configured = new Set(this.configuredMcpServerKeys());
402
+ const named = Object.keys(mcp?.servers ?? {});
403
+ const keys = [...new Set([...configured, ...named])];
404
+ return {
405
+ defaults: [...(mcp?.defaults?.trustAnnotations ?? [])],
406
+ servers: keys.map((server) => ({
407
+ server,
408
+ trusted: [...trustedAnnotationHints(mcp, server)],
409
+ configured: configured.has(server),
410
+ })),
411
+ };
412
+ }
413
+ /**
414
+ * §4.7.1 — **believe, or stop believing, specific hints from one server**, for the life of this
415
+ * session. The runtime half of `approvals.mcp.servers.<key>.trustAnnotations` (§9), so a user can
416
+ * do from the TUI what they can do in config.
417
+ *
418
+ * **Per hint, never per server.** `hints` names the hints this call moves and leaves every other
419
+ * hint of that server's exactly as it was, because believing a server's `readOnlyHint` while
420
+ * disbelieving its `openWorldHint` is a coherent position and the common one. A "trust this
421
+ * server" flag is the design §4.7.1 rejects.
422
+ *
423
+ * **The previous set is what was IN FORCE, resolved through `defaults`.** A server not named
424
+ * under `servers` inherits `defaults`, and naming it makes it state its relationship in full (§9)
425
+ * — so seeding from the empty set would mean that believing one more hint silently withdrew every
426
+ * hint `defaults` had granted, which is a weakening the user did not ask for and would invalidate
427
+ * their grants.
428
+ *
429
+ * **Session-scoped only.** Nothing is written to config: the declared block is read-only input
430
+ * (§9.1), exactly as the rung is.
431
+ *
432
+ * A trusted external annotation still never grants more than the same annotation grants one of
433
+ * our own built-ins — that holds in `core/approvals/annotations.ts` by construction, and this
434
+ * changes only which hints are read.
435
+ */
436
+ setMcpAnnotationTrust(server, hints, believe) {
437
+ const mcp = this.sessionApprovals.mcp;
438
+ const before = trustedAnnotationHints(mcp, server);
439
+ const requested = new Set(hints);
440
+ const after = believe
441
+ ? TOOL_ANNOTATION_HINTS.filter((hint) => before.includes(hint) || requested.has(hint))
442
+ : TOOL_ANNOTATION_HINTS.filter((hint) => before.includes(hint) && !requested.has(hint));
443
+ const added = after.filter((hint) => !before.includes(hint));
444
+ const removed = before.filter((hint) => !after.includes(hint));
445
+ const nextMcp = {
446
+ ...mcp,
447
+ servers: {
448
+ ...mcp?.servers,
449
+ [server]: { ...mcp?.servers?.[server], trustAnnotations: after },
450
+ },
451
+ };
452
+ // A fresh posture object, and fresh nested ones above: the resolved posture may share its `mcp`
453
+ // block with the loaded config, and a session change must not rewrite what the user configured.
454
+ this.sessionApprovals = { ...this.sessionApprovals, mcp: nextMcp };
455
+ return {
456
+ server,
457
+ configured: this.configuredMcpServerKeys().includes(server),
458
+ trusted: [...after],
459
+ added,
460
+ removed,
461
+ weakening: removed.filter(trustWithdrawalWeakens),
462
+ invalidates: this.grantsWeakenedByCurrentTrust(server),
463
+ };
464
+ }
465
+ /**
466
+ * §4.7.4 — which of this server's saved approvals the trust now in force weakens, for the notice
467
+ * that reports a trust change. **It predicts; it never removes.** The removal stays where Task
468
+ * 4 put it — at the call being decided — because that is the only moment the tool's declaration
469
+ * can be read for certain; here a server that is merely offline declares nothing and would read
470
+ * as having weakened everything.
471
+ *
472
+ * It compares through the same two functions the gate does: the effective-annotation source built
473
+ * from the posture as it stands *after* the change, and `annotationWeakenings`. A second
474
+ * comparison written for the display is how a warning comes to describe a rule the gate does not
475
+ * have.
476
+ */
477
+ grantsWeakenedByCurrentTrust(server) {
478
+ const source = this.effectiveToolAnnotationSource();
479
+ return this.getGrants()
480
+ .filter((grant) => grant.entry.type === 'mcpTool' &&
481
+ grant.entry.server === server &&
482
+ typeof grant.entry.pattern === 'string' &&
483
+ grant.annotations !== undefined)
484
+ .filter((grant) => {
485
+ // `EffectiveToolAnnotationSource` admits `undefined` for a source that genuinely cannot
486
+ // answer; `createEffectiveToolAnnotationSource` never returns it — a tool nothing has
487
+ // declared for resolves to the fail-closed constant, which for a grant made under anything
488
+ // softer reads as a weakening. The guard is therefore a type-level obligation and not a
489
+ // live branch: it discharges the union the contract declares, and nothing reaches it.
490
+ const current = source({
491
+ kind: 'mcpTool',
492
+ server,
493
+ name: grant.entry.pattern,
494
+ ...(grant.entry.host !== undefined ? { host: grant.entry.host } : {}),
495
+ });
496
+ return current !== undefined && annotationWeakenings(grant.annotations, current).length > 0;
497
+ })
498
+ .map((grant) => describeApprovalEntry(grant.entry));
89
499
  }
90
500
  /**
91
501
  * Init is split into a separate method. This may create a number of connections,
92
502
  * and we'd better have an instance by that moment, for the case things will go wrong,
93
503
  * so we can wrap init into try-catch and then call {@link #cleanup} within finally.
94
504
  */
95
- async init(command, configIn, checkpointSaver) {
505
+ async init(command, configIn, checkpointSaver, options) {
96
506
  this.config = configIn;
97
507
  this.command = command;
508
+ // GS2-48 — register the effective config with the crash handler so an uncaughtException /
509
+ // unhandledRejection mid-run captures it in the (redacted) snapshot. Pure data hand-off; no
510
+ // behaviour change.
511
+ updateCrashContext({ config: configIn, modelDisplayName: configIn.modelDisplayName });
512
+ // TUI-C32 residual a — register the live config with the shared tool-display redactor so its
513
+ // secret-literal collection walks INLINE config secrets (a pasted `apiKey`/`token` value), not
514
+ // only env-derived ones. Both surfaces (plain observer + Ink TUI) render through this module.
515
+ setToolDisplayConfig(configIn);
516
+ // CFG-27 — seed the session posture from config, so a config that pre-selects `bypass` starts
517
+ // there while the shell tool stays gated (see `resolveShellApprovalGate`) and therefore
518
+ // remains switchable (`/approvals write`). Resolved per-command, mirroring where the shell
519
+ // tool is actually emitted; no effect where the tool is ungated.
520
+ this.sessionApprovals = resolveApprovals(configIn, command);
521
+ // §3/§9.1 — the DECLARED lists are read-only config input, consulted through the EXT-71 rule
522
+ // matcher (`core/approvals/matcher.ts`) and NEVER copied into the runtime stores, which hold
523
+ // only what the escalation menu grants at run time. Both are handed to the same matcher, so
524
+ // there is one grammar and one comparison, not a config path and a runtime path.
525
+ this.denyGrants = new ApprovalGrantStore();
526
+ // CFG-26 — resolve the rater's own model when a profile is named, so the documented mitigation
527
+ // for a weak model ("point approvals.rater at a stronger one") actually takes effect.
528
+ //
529
+ // EAGERLY, here, rather than lazily at first use: `initConfig` re-runs discovery and prints
530
+ // "Activating profile: …", which mid-turn would write raw over the Ink TUI's managed frame,
531
+ // and a broken profile should fail at startup rather than three turns in. It deliberately does
532
+ // NOT catch — a named-but-unusable rater profile is an error, never a silent fallback to the
533
+ // session model (GS2-62).
534
+ //
535
+ // Loaded whenever a profile is NAMED, without a second "will the rater actually run?" gate:
536
+ // naming a rater profile at an unrated rung is a config the user can hold (they may switch to
537
+ // `assisted` mid-session with `/approvals`), and a broken profile should still fail loudly at
538
+ // startup rather than at the moment they switch.
539
+ const raterProfile = this.sessionApprovals.rater;
540
+ this.raterModel = raterProfile ? await resolveRaterModel(raterProfile) : undefined;
98
541
  // Initialize debug logging
99
542
  initDebugLogging(configIn.debugLog ?? false);
100
543
  debugLog(`Initializing GthAgentRunner with command: ${command || 'default'}`);
101
544
  this.runConfig = getNewRunnableConfig();
102
545
  debugLogObject('Runnable Config', this.runConfig);
546
+ this.warnIfBackendCannotBeHonored(configIn, command ?? options?.owningCommand);
103
547
  this.agent = this.agentFactory(this.statusUpdate, this.resolvers);
104
548
  // Initialize the agent
105
549
  debugLog('Initializing agent...');
106
550
  await this.agent.init(command, configIn, checkpointSaver);
107
551
  debugLog('Agent initialization complete');
108
552
  }
553
+ /**
554
+ * GS2-81 — `agent.backend` is a COMMAND-SCOPED key, and this is where a command opts out of it.
555
+ *
556
+ * A caller that hands the runner no factory gets the lean {@link GthLangChainAgent}, whatever the
557
+ * config asked for: `agent.backend: 'deep'` is then accepted by the schema, resolved into the
558
+ * config, and dropped on the floor. `gth review` and `gth pr` are in exactly that position — the
559
+ * review module builds its runner without one, and `@gaunt-sloth/review` does not depend on
560
+ * `@gaunt-sloth/agent`, so the deep backend is out of its reach — as is the `gth pr` change-
561
+ * requirements discovery agent.
562
+ *
563
+ * The check lives HERE, on the `agentFactory ?? lean` fallback itself, rather than in a list of
564
+ * verbs that cannot honor the key: a list is a second source of truth that goes stale the first
565
+ * time someone adds a command, whereas anything that reaches this fallback is by construction a
566
+ * run the key cannot reach. `lean` and the unset default need no warning — that IS what runs.
567
+ */
568
+ warnIfBackendCannotBeHonored(config, command) {
569
+ if (this.backendFactorySupplied || config.agent?.backend !== 'deep') {
570
+ return;
571
+ }
572
+ const scope = command ? `the ${command} command` : 'this run';
573
+ this.statusUpdate(StatusLevel.WARNING, `Config sets agent.backend: deep, but ${scope} always runs the lean backend, so the ` +
574
+ 'setting has no effect here. Which commands honor it: ' +
575
+ AGENT_BACKEND_SCOPE_DOCS_URL);
576
+ }
109
577
  /**
110
578
  * processMessages deals with both streaming and non-streaming approaches.
111
579
  */
@@ -113,6 +581,16 @@ export class GthAgentRunner {
113
581
  if (!this.agent || !this.config || !this.runConfig) {
114
582
  throw new Error('AgentRunner not initialized. Call init() first.');
115
583
  }
584
+ // GS2-16: start this turn's analytics tally from zero (the runner is reused across turns).
585
+ this.resetRunStats();
586
+ // GS2-48 — record this turn's transcript tail for the crash handler.
587
+ updateCrashContext({ transcriptTail: messages.slice(-CRASH_TRANSCRIPT_TAIL_MESSAGES) });
588
+ // [[EXT-29]] §5 — a new user turn is the human being reached, so it ends any negotiation still
589
+ // standing from the previous one and clears BOTH bounds. The turn's own messages then enter
590
+ // §5.1's last-5 window, which is what makes "just the last two" reach the rater at all — the
591
+ // reply that narrows what the agent proposes is worthless to the gate if only the agent hears it.
592
+ this.negotiation.humanReached();
593
+ this.negotiation.noteUserMessages(humanMessageTexts(messages));
116
594
  debugLog('Processing messages...');
117
595
  debugLogObject('Input Messages', messages);
118
596
  try {
@@ -130,11 +608,36 @@ export class GthAgentRunner {
130
608
  result += await this.resolveToolInterrupts();
131
609
  }
132
610
  catch (streamError) {
611
+ // CFG-27 — an approvals STOP is not a stream failure: it is the gate deliberately
612
+ // ending the run, and its message IS the explanation the spec requires it to carry.
613
+ // Re-thrown unchanged (the outer catch does the same) so nothing buries it.
614
+ if (streamError instanceof ApprovalStopError)
615
+ throw streamError;
133
616
  // Handle streaming-specific errors
134
617
  debugLogError('Stream processing', streamError);
135
618
  throw new Error(`Stream processing failed: ${streamError instanceof Error ? streamError.message : String(streamError)}`);
136
619
  }
137
620
  debugLog(`Stream completed. Total response length: ${result.length}`);
621
+ // EXT-37: a content-policy refusal (OpenAI content_filter / Anthropic stop_reason=refusal /
622
+ // Bedrock guardrail_intervened) is detected one layer down in GthAbstractAgent — the only
623
+ // place a message's response_metadata is visible — and surfaced there as a clear, non-empty
624
+ // terminal answer. That non-empty result intentionally bypasses this empty-response retry:
625
+ // a refusal is deterministic, so re-invoking the SAME model would only burn a paid call and
626
+ // fail identically. Only a genuinely empty turn reaches the retry below.
627
+ //
628
+ // Fallback-model EXTENSION POINT: hermes tries a *different* model once on refusal (a
629
+ // different model may not refuse). gaunt-sloth has no runtime fallback-model config today
630
+ // (`getCuratedFallbackModel` is init-time per-provider defaulting, not runtime failover), so
631
+ // per YAGNI none is built. When such a config is added, the one-shot fallback belongs where
632
+ // the refusal is detected (GthAbstractAgent.surfaceRefusal): try the fallback model ONCE
633
+ // before surfacing, and never retry the same model.
634
+ // GS2-72: the GS2-36 retry-budget's terminal notice — injected via a jumpTo:'end' STATE
635
+ // update on the lean createAgent graph — is streamed as an AIMessage chunk under
636
+ // streamMode:'messages' (verified on langchain 1.5.x), so it already arrives as a NON-empty
637
+ // `result` above and does NOT reach this fallback. Only a genuinely empty model turn falls
638
+ // through here, where the single retry invoke is the intended recovery. (If a future
639
+ // langchain stops streaming jumpTo-injected messages, the budget notice would drain empty
640
+ // and hit this fallback; GthAbstractAgentTerminalNotice.spec pins the current behaviour.)
138
641
  if (result.trim().length === 0) {
139
642
  debugLog('Stream produced empty response, retrying once with non-streaming invoke.');
140
643
  const fallback = await this.agent.invoke(messages, this.runConfig);
@@ -149,7 +652,15 @@ export class GthAgentRunner {
149
652
  else {
150
653
  // Use non-streaming
151
654
  debugLog('Using non-streaming mode');
152
- const result = await this.agent.invoke(messages, this.runConfig);
655
+ let result = await this.agent.invoke(messages, this.runConfig);
656
+ // EXT-52 — the SAME interrupt drain the streaming branch does above. A gated
657
+ // `run_shell_command` suspends the graph, so `invoke` returns with the tool-calling
658
+ // AIMessage (empty content) as the last message and the command not yet run. Draining here
659
+ // — BEFORE the empty-response check — is what makes the approval prompt fire and the
660
+ // approved command's output reach the caller on `streamOutput: false`; without it the turn
661
+ // died with the misleading empty-response error, so the check may only see a genuinely
662
+ // empty turn.
663
+ result += await this.resolveToolInterrupts();
153
664
  debugLog(`Non-stream response length: ${result.length}`);
154
665
  if (result.trim().length === 0) {
155
666
  throw new Error('Model returned an empty response. Try again or switch to a more stable model.');
@@ -158,6 +669,12 @@ export class GthAgentRunner {
158
669
  }
159
670
  }
160
671
  catch (error) {
672
+ // CFG-27 §4.2/§6.2 — an approvals STOP is not an agent failure and must reach the user with
673
+ // its own words: the command, the rating and its reason are the whole point of it. Wrapping
674
+ // it as "Agent processing failed: …" would bury the explanation the spec requires it to
675
+ // carry, so it is re-thrown unchanged.
676
+ if (error instanceof ApprovalStopError)
677
+ throw error;
161
678
  // Handle agent invocation errors
162
679
  debugLogError('Agent processing', error);
163
680
  const originalMessage = error instanceof Error ? error.message : String(error);
@@ -187,7 +704,10 @@ export class GthAgentRunner {
187
704
  * streamed across all resume turns (empty when nothing was resumed).
188
705
  *
189
706
  * No-ops (returns '') when the agent does not support interrupts (`getPendingToolInterrupts`/
190
- * `streamResume` absent), so the lean agent and non-HITL configs are unaffected.
707
+ * `streamResume` absent) that is the only exemption. As of EXT-52 BOTH backends gate
708
+ * `run_shell_command` and expose the interrupt surface, so the lean (default) agent is now
709
+ * exactly the agent this loop serves; only an agent implementation without those methods
710
+ * (e.g. a test double) skips it.
191
711
  */
192
712
  async resolveToolInterrupts() {
193
713
  const agent = this.agent;
@@ -212,140 +732,963 @@ export class GthAgentRunner {
212
732
  return resumedText;
213
733
  }
214
734
  /**
215
- * Decide a single pending tool call (EXT-9 Tier-2). For the opt-in `run_shell_command`,
216
- * consult the scoped allow-list FIRST: if the command's classified prefix is already
217
- * approved (session or persisted `always`) and survives the safe-bin anti-widening
218
- * re-validation, auto-approve SILENTLY (no human prompt). Otherwise fall through to the
219
- * human callback; when the human grants `session`/`always` scope, record the command's
220
- * classified prefix into the matching store so future flag-variants stop re-prompting.
735
+ * EXT-71 §3.1/§3.2, EXT-70 §4.7.5 — the subject a pending tool call presents to the rule matcher.
221
736
  *
222
- * When no human callback is wired (non-TTY exec run) and nothing is allow-listed, reject
223
- * never auto-approve. Non-shell tools (or any tool when the allow-list is disabled) skip the
224
- * allow-list and go straight to the human callback / default-reject, preserving prior behaviour.
737
+ * A gated `run_shell_command` is a **shell** subject and nothing else: it is matched by `shell`
738
+ * entries, against the command. It is deliberately NOT also offered as a `tool` subject named
739
+ * `run_shell_command`, which would create a second allow path to every shell command carrying a
740
+ * different §3.2 `rate` default and a match that never saw the command it was approving.
741
+ *
742
+ * **Everything else splits by provenance**, which is the distinction §4.7.1 rests on: `tool` is
743
+ * the TRUSTED provenance, read verbatim, so an MCP tool arriving as one would be asking the
744
+ * trusted path for a third party's annotations — a gate any server can opt itself out of. Every
745
+ * MCP-namespaced name therefore becomes an `mcpTool` subject carrying the user's own `mcpServers`
746
+ * key, and one whose server cannot be resolved stays an `mcpTool` subject under an unnameable
747
+ * server rather than falling back to `tool` (see `approvalSubjectForToolName`).
748
+ *
749
+ * **The host (§4.7.4)** is attached here, so the one subject the whole decision runs on carries
750
+ * it: the rule matcher treats a `host` on an entry as an additional exact-match condition, and a
751
+ * grant the menu writes records it. A call naming no single host has none, which fails toward a
752
+ * prompt at both sites.
753
+ *
754
+ * Widening which tools the gate actually suspends on is still [[EXT-30]]; this decides what a
755
+ * suspended call *is* whenever one arrives.
756
+ *
757
+ * @param hosts Every distinct host the call's arguments name ({@link toolCallHosts}).
758
+ */
759
+ approvalSubjectFor(tool, command, hosts) {
760
+ if (tool.name === SHELL_TOOL_NAME && command !== null)
761
+ return { kind: 'shell', command };
762
+ const subject = approvalSubjectForToolName(tool.name, this.configuredMcpServerKeys());
763
+ return hosts.length === 1 ? { ...subject, host: hosts[0] } : subject;
764
+ }
765
+ /**
766
+ * §4.7.5 — the user's own `mcpServers` keys, the only identity a server has here. Own enumerable
767
+ * keys via `Object.keys`, so nothing inherited can pose as a configured server.
768
+ */
769
+ configuredMcpServerKeys() {
770
+ const servers = this.config?.mcpServers;
771
+ return servers && typeof servers === 'object' ? Object.keys(servers) : [];
772
+ }
773
+ /**
774
+ * EXT-70 §4.7.1 — the source a `hint` entry reads a call's EFFECTIVE annotations through, built
775
+ * from the session's `approvals.mcp` block and the two declared-annotation lookups.
776
+ *
777
+ * Built per decision rather than cached at {@link init}, for two reasons that both bite: the
778
+ * agent registers its tools *inside* `agent.init()`, so an init-time snapshot would be empty; and
779
+ * a re-init re-resolves the tool list, which for MCP may hand back different declarations.
780
+ *
781
+ * The two lookups are deliberately different in kind. `builtIn` reads OUR OWN authored table and
782
+ * never the bound tool list — the bound list contains every server's tools, and a `builtIn`
783
+ * lookup over it would read a third party's declaration through the trusted-verbatim path.
784
+ * `mcp` reads what the servers declared, keyed by the registered tool name so the server key is
785
+ * never split apart and re-joined differently.
786
+ */
787
+ effectiveToolAnnotationSource() {
788
+ return createEffectiveToolAnnotationSource({
789
+ mcp: this.sessionApprovals.mcp,
790
+ declared: {
791
+ builtIn: builtInToolAnnotations,
792
+ mcp: mcpDeclaredAnnotationLookup(this.agent?.getDeclaredMcpToolAnnotations?.()),
793
+ },
794
+ });
795
+ }
796
+ /**
797
+ * Decide a single pending tool call. Spec order — **deny → bypass → escalate → allow → rater →
798
+ * human prompt**, with the hardline floor at exec time regardless. The two adjacencies that carry
799
+ * the design are that deny comes BEFORE `bypass` and escalate comes AFTER it:
800
+ *
801
+ * 1. **deny** (§3) — a declared entry or a runtime *always reject* grant is refused with no
802
+ * prompt and no rating call. It is consulted FIRST, and it is the one
803
+ * check that **still applies under `bypass`**: choosing `bypass` says *"stop asking me"*, not
804
+ * *"forget what I told you never to do"*. A deny entry MAY match a compound command, because a
805
+ * prohibition that catches something unresolvable errs in the direction that costs nothing.
806
+ * 2. **`bypass`** — the gate is off for this session; approve at scope `once`.
807
+ * 3. **escalate** (§3.2) — a declared entry always asks the human, whatever the rung would have
808
+ * done, **including outranking the automatic grants of `manual` and `write`** and any allow
809
+ * entry that also matched. It goes straight to the human with **no rating call**, and it never
810
+ * enters the `auto` negotiation. It is **inert at `bypass`**, which is why it sits below
811
+ * the rung check: the rung chosen for this session wins, and a stop that must survive `bypass`
812
+ * is a deny entry and only that.
813
+ * 4. **allow** (§3, §3.2) — a declared entry or a grant the human made at an earlier prompt this
814
+ * session (or persisted), matched against the whole normalized command and only when that
815
+ * command statically resolves. An allow match settles the human's part: no prompt. Whether the rater
816
+ * still reviews the call is the entry's own `rate` (§3.2) — honored at the rater rungs and
817
+ * inert at the deterministic ones, so no entry can smuggle a model call into `manual` or
818
+ * `write` — and a rated allow match is a TRIPWIRE, not a re-adjudication
819
+ * ({@link mapAllowMatchedVerdictToAction}).
820
+ * 5. **auto-rater** (`assisted` / `auto` only) — `safe` approves, `destructive` and
821
+ * `catastrophic` escalate, and `attack` HALTS the run ({@link AttackHaltError}). The other
822
+ * three rungs consult no model at all. A command whose target the gate cannot statically
823
+ * resolve is rated **exactly like any other** ([[EXT-81]]), with a neutral note in the rating
824
+ * prompt naming the shape the parser saw. It used to skip the call and be refused straight
825
+ * back to the model instead; §6.1's rule is that a deterministic layer fires only where it is
826
+ * confident something is a threat, and a parser reporting it could not read a string has
827
+ * detected nothing. At those same two rungs a **tool**
828
+ * call is instead floored deterministically by §4.7.3's open-world rule
829
+ * ({@link openWorldToolFloorReason} into {@link applyDestructiveFloor} — the one floor the
830
+ * shell path also reaches): a call whose effective `openWorldHint` is true is `destructive`,
831
+ * whatever its `readOnlyHint` says.
832
+ * 6. **human prompt** — the approval callback; when the human grants `session`/`always` scope,
833
+ * **that command** is recorded as an `exact` entry (§3.1/§6 — the menu never widens), so the
834
+ * same command stops re-prompting and a longer variant of it still asks.
835
+ *
836
+ * §6.2 — where no human can answer (CI, a one-shot run, a server), an escalation is **not** a
837
+ * rejection handed back to the model: it is an immediate non-zero exit
838
+ * ({@link NonInteractiveEscalationError}) carrying the command, the rating and its reason. No
839
+ * prompt, no waiting, and never a timeout into approval. Declaring commands in `approvals.allow`
840
+ * is the supported way to make a pipeline pass.
225
841
  *
226
842
  * Hardline catastrophic commands remain refused at exec time regardless of any approval here
227
843
  * (defense in depth in `GthDevToolkit.executeCommand`), so an allow-listed `rm -rf /` still
228
844
  * cannot run.
845
+ *
846
+ * **Step 0 is the rung.** The backends wire the interrupt over every tool ANY rung could gate,
847
+ * because the graph is built once and `/approvals <rung>` moves the rung under it for the rest of
848
+ * the session. So a call arriving here has not yet been judged against the rung in force: this is
849
+ * where that happens, on `sessionApprovals.rung`, which a mid-session switch has already updated.
850
+ * A call the live rung does not gate is approved on the spot — no rule matching, no rating, no
851
+ * prompt — which is what keeps `assisted`, `auto` and `bypass` behaving exactly as they did
852
+ * when the interrupt held the shell alone. It sits ABOVE the deny check for the same reason: an
853
+ * ungated call never reached this method at all before, so a deny entry could not fire on one, and
854
+ * a security fix for two rungs is not the place to change that. (The shell is gated at every rung
855
+ * whenever the shell gate is on, so §2.5's rule that the deny list survives `bypass` is untouched.)
229
856
  */
230
857
  async decideToolApproval(tool) {
858
+ // [[TUI-C27]] — the record is opened (and already in the log) BEFORE the decision runs, and
859
+ // filled in as it goes. Assembling it at the end would lose the calls most worth keeping: an
860
+ // `attack` verdict throws `AttackHaltError` out of the decision, so a halted run would carry no
861
+ // record of the rating that halted it — and an approval, which relays nothing to anyone, is
862
+ // exactly the branch that used to leave no trace at all.
863
+ const record = this.approvalCaptures.begin({
864
+ at: new Date().toISOString(),
865
+ tool: tool.name,
866
+ ...(typeof tool.args?.command === 'string' ? { command: tool.args.command } : {}),
867
+ rung: this.sessionApprovals.rung,
868
+ budget: this.negotiation.counters(),
869
+ });
870
+ const decision = await this.recordedDecision(tool, record);
871
+ // [[EXT-29]] §5.3 — **the reset, at the one site that sees every approval.** "A successful
872
+ // intervening tool call — the agent going away to gather information and returning better
873
+ // informed — resets [the count], because that is progress, not ping-pong." Every way a call can
874
+ // be let through arrives here: the rung not gating it, an allow entry, the §3.2 tripwire, a
875
+ // `safe` rating, the human saying yes. Wrapping is what makes that exhaustive — an approval
876
+ // added below cannot forget to reset, and the alternative (a call at each of the six `approve`
877
+ // returns) is a §5.3 hole that is invisible the day it opens.
878
+ //
879
+ // Nothing else resets: a `reject` (the negotiation's own rounds, and the §8 floor's refusal)
880
+ // must not, or the bound it is counted against could never be reached.
881
+ if (decision.type === 'approve')
882
+ this.negotiation.noteProgress();
883
+ return decision;
884
+ }
885
+ /**
886
+ * [[TUI-C27]] — {@link decideToolApprovalInner} with the record closed off on EVERY exit.
887
+ *
888
+ * The final action is written here rather than at each of the decision's many returns, because
889
+ * "what became of the call" is one fact with one source: what this method returns or throws. A
890
+ * per-return assignment is a list that a new branch joins without noticing, and the branch that
891
+ * would be forgotten is the one that ends the run.
892
+ */
893
+ async recordedDecision(tool, record) {
894
+ try {
895
+ const decision = await this.decideToolApprovalInner(tool, record);
896
+ record.action = decision.type === 'approve' ? 'approve' : 'reject';
897
+ if (decision.type === 'approve' && decision.scope)
898
+ record.scope = decision.scope;
899
+ return decision;
900
+ }
901
+ catch (error) {
902
+ if (error instanceof AttackHaltError) {
903
+ record.action = 'halt';
904
+ }
905
+ else if (error instanceof NonInteractiveEscalationError) {
906
+ // §6.2 — there was nobody to ask, so the escalation ended the run instead of reaching one.
907
+ record.action = 'escalate';
908
+ record.humanAnswer = 'no-human';
909
+ }
910
+ else {
911
+ record.action = 'error';
912
+ }
913
+ record.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
914
+ throw error;
915
+ }
916
+ }
917
+ /** The decision itself; {@link decideToolApproval} wraps it with §5.3's reset. */
918
+ async decideToolApprovalInner(tool, record) {
231
919
  const command = typeof tool.args?.command === 'string' ? tool.args.command : null;
232
- const isShellCommand = tool.name === 'run_shell_command' && command !== null;
233
- const allowlistApplies = isShellCommand && this.isShellAllowlistOn();
234
- // EXT-12 runtime session yolo (`/yolo`): when ON, auto-approve a gated shell command WITHOUT
235
- // prompting, judging, or persisting. Scope `once` so nothing is written to the allow-list (the
236
- // bypass is intentionally ephemeral and reversible). The hardline floor is NOT bypassed here
237
- // it is enforced at exec time in GthDevToolkit.executeCommand regardless of this decision, so a
238
- // catastrophic command is still refused even under yolo.
239
- if (isShellCommand && this.sessionYolo) {
240
- return { type: 'approve', scope: 'once' };
241
- }
242
- // Auto-approve from the allow-list without prompting. The allow-list ALWAYS wins over the
243
- // judge: a human-trusted prefix shouldn't pay for an LLM call on every variant.
244
- if (allowlistApplies && this.isApprovedByAllowlist(command)) {
245
- return { type: 'approve', scope: 'session' };
246
- }
247
- // EXT-10 LLM-as-judge safety gate (default OFF). Runs BEFORE the human callback for a
248
- // `run_shell_command` not already allow-listed: auto-approve clearly-safe (fatigue reducer),
249
- // reject clearly-catastrophic (only when blockHigh), otherwise fall through to the human with
250
- // the verdict attached. When disabled this is a no-op and behaviour is exactly EXT-9.
251
- let safetyVerdict;
252
- if (isShellCommand && command !== null && this.isShellJudgeOn()) {
253
- const settings = getShellJudgeSettings(getEffectiveDevToolsConfig(this.config ?? undefined, this.command));
254
- const verdict = await judgeShellCommand(command, this.config, {
255
- home: env?.HOME,
256
- });
257
- const action = mapVerdictToAction(command, verdict, {
258
- autoApproveLow: settings.autoApproveLow,
259
- blockHigh: settings.blockHigh,
920
+ const isShellCommand = tool.name === SHELL_TOOL_NAME && command !== null;
921
+ const approvals = this.sessionApprovals;
922
+ // (0) Does the rung IN FORCE gate this tool at all? Same shared predicate the backends built the
923
+ // interrupt from, asked about this one call, so the wiring and the decision cannot disagree.
924
+ // Scope `once`, so nothing is written to any allow-list: this is not a grant, it is the absence
925
+ // of a gate.
926
+ const { gateShell } = resolveShellApprovalGate(this.config ?? undefined, this.command);
927
+ if (!isToolGatedAtRung({ toolName: tool.name, rung: approvals.rung, gateShell })) {
928
+ return this.stage(record, 'not-gated', { type: 'approve', scope: 'once' });
929
+ }
930
+ // [[TUI-C27]] [[EXT-81]]'s surviving observable: what the gate's own parser made of the
931
+ // command, recorded whether or not a rating follows. Computed once here rather than at the
932
+ // rating site, so a call the floor or a list settles still says whether the command was one the
933
+ // parser could resolve.
934
+ if (isShellCommand && command !== null) {
935
+ const defect = describeAbstention(command);
936
+ if (defect)
937
+ record.parserUnresolved = defect;
938
+ }
939
+ // ONE subject and ONE annotation source per decision, shared by the rule matcher and the
940
+ // §4.7.3 floor below. Building a second source for the floor would let a `hint` entry and the
941
+ // floor read different effective values for the same call — the two-derivations-disagreeing
942
+ // failure `core/approvals/annotations.ts` exists to prevent.
943
+ // §4.7.4 — the hosts this call names, read ONCE. The subject carries the single host where
944
+ // there is exactly one; the sticky-grant decision needs the count as well, because "named no
945
+ // host" and "named several" are the same absent `host` on the subject and are not the same
946
+ // question for the menu.
947
+ const hosts = tool.name === SHELL_TOOL_NAME && command !== null ? [] : toolCallHosts(tool.args);
948
+ const subject = this.approvalSubjectFor(tool, command, hosts);
949
+ const annotationSource = this.effectiveToolAnnotationSource();
950
+ // ONE read of that source per decision as well, so the §4.7.4 invalidation below, the §4.7.3
951
+ // floor and the snapshot a grant records are all the same set. A shell subject has none — a
952
+ // command carries no tool annotations.
953
+ const effective = subject.kind === 'shell' ? undefined : annotationSource(subject);
954
+ // §4.7.4 — **before any rule is resolved**, so a grant the tool has since weakened out from
955
+ // under cannot auto-approve the very call that revealed the weakening.
956
+ if (subject.kind !== 'shell' && effective)
957
+ this.invalidateWeakenedGrants(subject, effective);
958
+ // The declared lists AND the runtime grant stores, resolved most-restrictive-wins in ONE pass
959
+ // through the ONE comparison engine, so author order and the order the lists were concatenated
960
+ // in cannot change the outcome — and a grant the menu wrote is compared exactly as a line the
961
+ // user typed into their config is.
962
+ const rule = resolveApprovalRules(subject, this.approvalRuleLists(), {
963
+ // EXT-70 §4.7.1 — a `hint` entry reads EFFECTIVE annotations, which is where per-server,
964
+ // per-hint trust is applied. Without this the matcher falls back to the fail-closed source,
965
+ // where no tool is ever read-only and a `hint` entry could only ever describe the default.
966
+ annotations: annotationSource,
967
+ onNotice: (notice) => this.statusUpdate(notice.level, notice.message),
968
+ });
969
+ // (1) Deny — before everything, including `bypass`.
970
+ if (rule?.action === 'deny') {
971
+ // [[TUI-C26]] §6 — the message names the refusal the user actually made. A deny entry now has
972
+ // two possible authors: a line in `approvals.deny`, and the escalation menu's *always reject*
973
+ // choice earlier in this session. Telling the model (and, through it, the user) to edit a
974
+ // config file that the second kind was never written to is the same class of wrongness as
975
+ // confirming a persistence that did not happen. The declared list is checked FIRST so an
976
+ // entry a user wrote is described as theirs even when the menu recorded the identical one.
977
+ const declared = this.sessionApprovals.deny.some((entry) => renderApprovalEntryObject(entry) === renderApprovalEntryObject(rule.entry));
978
+ const described = describeApprovalEntry(rule.entry);
979
+ record.ruleMatch = { action: 'deny', entry: described };
980
+ return this.stage(record, 'deny-list', {
981
+ type: 'reject',
982
+ message: declared
983
+ ? `Refused: your deny list forbids this call (matched "${described}"). ` +
984
+ 'Remove the entry from approvals.deny if you want it to run.'
985
+ : `Refused: the user chose to always refuse this earlier in this session (matched ` +
986
+ `"${described}"). That refusal lasts until the session ends; ask the user if you ` +
987
+ 'believe it should be lifted.',
260
988
  });
261
- if (action === 'auto-approve') {
262
- // Scope `once`: judge approvals are NEVER persisted to the allow-list.
263
- return { type: 'approve', scope: 'once' };
989
+ }
990
+ // (2) `bypass` (config or `/approvals bypass`): approve a gated shell command WITHOUT
991
+ // prompting or rating. Scope `once` so nothing is written to the allow-list (the bypass is
992
+ // intentionally ephemeral and reversible). The hardline floor is NOT bypassed here — it is
993
+ // enforced at exec time in GthDevToolkit.executeCommand regardless of this decision.
994
+ if (isShellCommand && approvals.rung === 'bypass') {
995
+ return this.stage(record, 'bypass', { type: 'approve', scope: 'once' });
996
+ }
997
+ // (2b) [[EXT-29]] §4.2/§8 — **the hardline floor, consulted BEFORE anything opens.** "If the
998
+ // deterministic floor matches, the command is refused at execution regardless of rating, rung,
999
+ // or approval — so it MUST NOT be negotiated and SHOULD NOT be escalated."
1000
+ //
1001
+ // **This is a second call site, not the exec-time one, and the promise is different.** The
1002
+ // toolkit's check guarantees such a command never RUNS; it does nothing about what happens on
1003
+ // the way there, so without this line `auto` spends three rating calls and a human dialog
1004
+ // arguing about a fork bomb that was never going to run — and "asking a human to approve
1005
+ // something that is then refused anyway teaches them their answer does not count, which is
1006
+ // worse than a flat refusal". The exec-time check stays exactly where it is: it is the
1007
+ // guarantee, this is the courtesy of not wasting a decision on it.
1008
+ //
1009
+ // **Scoped to the rated rungs, which is where §4.2 speaks.** `bypass` has already returned
1010
+ // above, and at `manual`/`write` the human is the gate rather than a second opinion on a
1011
+ // rating; neither is a path §5 or §4.2's table governs. It sits above the allow branch because
1012
+ // the floor is unappealable — an allow entry cannot buy past it — and below the deny check
1013
+ // because a deny match refuses the same call for the user's own reason.
1014
+ //
1015
+ // **`checkHardline` is asked, not `catastrophic`.** They are different predicates and the
1016
+ // difference is measured: EXT-60 recorded `chown -R /` as missing from the floor, so "the floor
1017
+ // matched" and "the rater said catastrophic" name overlapping, non-identical sets. A
1018
+ // `catastrophic` rating still escalates (§4.2 settled that deliberately); only a floor match
1019
+ // refuses here.
1020
+ //
1021
+ // **A floor match refuses; it never halts, whichever of §8's two subsets matched.** The floor is
1022
+ // a lexical test with no notion of direction or role, so it fires on ordinary work — the deploy
1023
+ // authenticated by an identity file, the fetch that writes a credential INBOUND — and ending the
1024
+ // session on one leaves a restart as the only recovery. The run-ending halt stays where a model
1025
+ // has actually said the command is an attack (the rating path below): a floor match is a
1026
+ // model-free assertion, and the model-free consequence is the floor's own refusal, reached
1027
+ // earlier here than at exec and without spending a prompt on it.
1028
+ //
1029
+ // **Both rated rungs, deliberately.** `assisted` gets the refusal without a prompt for the same
1030
+ // reason `auto` gets it without a round: §4.2 is a statement about the command, not about who
1031
+ // was going to be asked about it.
1032
+ if (isShellCommand && command !== null && isRatedRung(approvals.rung)) {
1033
+ const floor = checkHardline(command);
1034
+ if (floor) {
1035
+ // [[TUI-C27]] — **the archive names the matched pattern; the refusal below does not.**
1036
+ // §8.1 ("the floor is never advertised") and [[CFG-31]] bind USER-FACING rung copy —
1037
+ // text inviting someone to feel safe — and the resolution taken here is that a diagnostic
1038
+ // archive a user opens about their own session is not that surface: "a floor matched"
1039
+ // without saying which rule leaves nobody able to act on it. `buildHardlineRefusal` is
1040
+ // untouched and still carries only the description.
1041
+ record.hardline = { description: floor.description, pattern: floor.pattern };
1042
+ const refusal = buildHardlineRefusal(command, floor);
1043
+ // Visible, because the exec-time refusal is: a refusal the user never sees reads as the
1044
+ // agent quietly deciding not to do what it was asked.
1045
+ this.statusUpdate(StatusLevel.WARNING, `\n⛔ ${refusal}`);
1046
+ // §7's moves are deliberately absent — see `buildHardlineRefusal`. Neither bound moves
1047
+ // either: this refusal opens no round, so counting it would walk an unappealable refusal
1048
+ // toward the human escalation §4.2 says it must not reach. It follows that a model spamming
1049
+ // a floor-matching command is bounded only by the tool-loop guard and `recursionLimit`,
1050
+ // which is the right place for "the model did something unproductive" to end and the wrong
1051
+ // place for a fork bomb to acquire an audience.
1052
+ return this.stage(record, 'hardline-floor', { type: 'reject', message: refusal });
1053
+ }
1054
+ }
1055
+ // (3) Escalate — §3.2 sends it straight to the human with no rating call, outranking any allow
1056
+ // entry that also matched.
1057
+ //
1058
+ // The `bypass` term is deliberate and not redundant with the early return above. That return
1059
+ // only covers a SHELL call, so without this term a non-shell subject would still carry an
1060
+ // escalate match into the prompt at `bypass`, and §2.5's rule is about the rung, not about which
1061
+ // tool asked. Non-shell subjects do reach this line — the deterministic rungs gate the write
1062
+ // built-ins, MCP and custom tools — so the term is doing work rather than guarding a hypothesis.
1063
+ const escalatedBy = rule?.action === 'escalate' && approvals.rung !== 'bypass'
1064
+ ? describeApprovalEntry(rule.entry)
1065
+ : undefined;
1066
+ if (escalatedBy) {
1067
+ record.ruleMatch = { action: 'escalate', entry: escalatedBy };
1068
+ record.stage = 'escalate-entry';
1069
+ }
1070
+ // (4) Approve from the allow list without prompting. It ALWAYS wins over the rater — a
1071
+ // human-trusted call shouldn't pay for an LLM call on every variant — but never over escalate.
1072
+ const allowlistApplies = approvals.rung !== 'bypass' && escalatedBy === undefined;
1073
+ let safetyVerdict;
1074
+ if (allowlistApplies && rule?.action === 'allow') {
1075
+ record.ruleMatch = {
1076
+ action: 'allow',
1077
+ entry: describeApprovalEntry(rule.entry),
1078
+ rate: rule.rate === true,
1079
+ };
1080
+ // §3.2 — `rate` is honored at the rater rungs and INERT at the deterministic ones, so an
1081
+ // entry can never smuggle a model call into `manual` or `write`. A tool subject is not
1082
+ // rated either: the rater's first implementation covers the shell only (§4.3, [[EXT-30]]).
1083
+ if (!rule.rate || !isRatedRung(approvals.rung) || !isShellCommand || command === null) {
1084
+ return this.stage(record, 'allow-list', { type: 'approve', scope: 'session' });
1085
+ }
1086
+ // Attributed once, before the call, for the reason the rater path below is: the tripwire's
1087
+ // own `attack` arm throws, and a second writer on the return would make this one unfalsifiable.
1088
+ record.stage = 'allow-tripwire';
1089
+ const verdict = await this.rateCommand(command, { allowMatched: true }, record);
1090
+ const tripwire = mapAllowMatchedVerdictToAction(verdict);
1091
+ if (tripwire.action === 'approve') {
1092
+ return { type: 'approve', scope: 'session' };
1093
+ }
1094
+ if (tripwire.action === 'halt') {
1095
+ // §3.2/§4.2 — `attack` halts exactly as it would have without the match. A standing human
1096
+ // grant answers "may this run"; it does not answer "is this command's structure hostile".
1097
+ //
1098
+ // §6.1 — and it halts through the SAME seam as the rater's own path below, so an allow
1099
+ // entry does not decide whether the banner appears. The entry has already been overruled by
1100
+ // the time this line is reached; letting it also silence the one way out would make the
1101
+ // recovery depend on a match the human cannot see from the banner.
1102
+ return await this.haltOrRunAnyway(command, tripwire.verdict?.reason ?? '', record);
1103
+ }
1104
+ // `catastrophic` — the one outcome the tripwire escalates. Fall through to the human.
1105
+ safetyVerdict = tripwire.verdict;
1106
+ }
1107
+ // (5) What the gate makes of the call itself, at the two rated rungs only. Skipped entirely
1108
+ // for an escalate match (§3.2: the user pre-decided that a human answers, so a rating would
1109
+ // decorate a mandatory prompt) and for a call the tripwire above already rated. The rung test
1110
+ // is the SAME one `mapVerdictToAction` applies, so both arms below floor exactly where the
1111
+ // shell does and nowhere else: `bypass` and the two deterministic rungs consult neither the
1112
+ // rater nor a preflight, and at those rungs the human is asked regardless.
1113
+ if (isRatedRung(approvals.rung) && escalatedBy === undefined && safetyVerdict === undefined) {
1114
+ // **The SUBJECT is what splits the two arms, not a second reading of the tool name.**
1115
+ // `approvalSubjectFor` returns `kind: 'shell'` under exactly the condition `isShellCommand`
1116
+ // states, so branching on the discriminant says the same thing once instead of twice — and
1117
+ // any future divergence in that function sends the call to the FLOOR (fail-closed) rather
1118
+ // than silently past it. It is also what carries the command as a non-null string.
1119
+ if (subject.kind === 'shell') {
1120
+ // The auto-rater. `safe` is approved (the fatigue reducer), `destructive` and
1121
+ // `catastrophic` fall through to the human with the verdict attached, and `attack` ends the
1122
+ // run outright. §4.6's deterministic preflights are applied inside `mapVerdictToAction`,
1123
+ // ahead of the `safe` check.
1124
+ //
1125
+ // **[[EXT-81]] — EVERY shell command reaches this call, including the ones the gate's own
1126
+ // parser cannot resolve.** There used to be a branch above it that skipped the rating for a
1127
+ // composed, substituting or redirecting command and refused the call instead. Two things
1128
+ // followed from that skip, and both are gone with it: `attack` and `catastrophic` were
1129
+ // UNREACHABLE for that entire class (nobody rated, so nobody could say worse than
1130
+ // `destructive`), and the party that heard about it was the parser's, not the rater's — a
1131
+ // component that has just announced it could not read a command is in no position to say
1132
+ // how to rewrite it, and the rewrite it named turned `cd src && ls` into a no-op plus a
1133
+ // listing of the wrong directory, both exit 0. The parser's finding is now a neutral note
1134
+ // in the rating prompt (`buildRaterPrompt`) and nothing else.
1135
+ // [[EXT-29]] §5.1 — the negotiation this rating is a round of. At `assisted` the context is
1136
+ // empty and `negotiable` is false, so the whole call is byte-identical to what it was.
1137
+ const negotiable = isNegotiatingRung(approvals.rung);
1138
+ const justification = negotiable ? shellJustification(tool.args) : undefined;
1139
+ const context = negotiable
1140
+ ? this.negotiation.contextFor(justification)
1141
+ : undefined;
1142
+ // [[TUI-C27]] — attributed BEFORE the call, and ONCE. Before, because `attack` throws out
1143
+ // of the decision below and a record left unattributed would say a halt came from nowhere.
1144
+ // Once, because a second assignment on each `return` would make the first unfalsifiable:
1145
+ // deleting either would leave the other still writing 'rater', and a fact with two writers
1146
+ // is one no test can pin.
1147
+ record.stage = 'rater';
1148
+ const verdict = await this.rateCommand(subject.command, {
1149
+ allowMatched: false,
1150
+ negotiation: context,
1151
+ negotiable,
1152
+ }, record);
1153
+ const decision = mapVerdictToAction(subject.command, verdict, { rung: approvals.rung });
1154
+ // [[TUI-C27]] — WHICH deterministic preflight fired, and whether it actually rewrote the
1155
+ // rating. The two are separate facts: a preflight only ever RAISES, and only `safe` sits
1156
+ // below the floor, so a finding on a `destructive` verdict is the floor AGREEING with the
1157
+ // rater rather than overriding it — and attributing the decision to the floor in that case
1158
+ // would be wrong. Recomputed from the same raw command `mapVerdictToAction` recomputes it
1159
+ // from, through the same one function, so the two cannot disagree.
1160
+ const preflight = preflightFloorFinding(subject.command);
1161
+ if (preflight) {
1162
+ record.preflight = {
1163
+ ...preflight,
1164
+ rewroteRating: isBelowDestructiveFloor(verdict.outcome),
1165
+ };
1166
+ }
1167
+ if (decision.action === 'approve') {
1168
+ // Scope `once`: rater approvals are NEVER persisted to the allow-list.
1169
+ return { type: 'approve', scope: 'once' };
1170
+ }
1171
+ if (decision.action === 'halt') {
1172
+ // §4.2 — not a rejection the model can respond to. It ends the agent loop.
1173
+ //
1174
+ // **`neg-04d`: a negotiation already in flight ends here too**, mid-way and without a
1175
+ // further round. `attack` is exempt from the whole mechanism (§5.1), so the counter, the
1176
+ // transcript and the loop all stop together rather than the argument continuing around a
1177
+ // halt that only ended one call.
1178
+ //
1179
+ // The reset itself is defence in depth: the throw ends `processMessages`, and a later
1180
+ // turn would clear the negotiation on its own first line anyway. **No PRODUCTION reader
1181
+ // sees this state again — which is not the same as it being unobservable**, and the
1182
+ // difference decides whether the line is pinned. `neg-04d` asserts both halves through
1183
+ // the spec harness's private-state cast: the cleared transcript, and `sinceHuman` back at
1184
+ // zero. That second one is what distinguishes this call from `noteProgress()`, which
1185
+ // clears the transcript and the consecutive count and deliberately leaves the
1186
+ // reachability bound standing.
1187
+ this.negotiation.humanReached();
1188
+ // §6.1 — the banner, when an interactive surface wired one, and the halt otherwise. It
1189
+ // sits AFTER the reset above on purpose: a human is reached either way (that is what the
1190
+ // banner is), so the negotiation ends here whichever answer comes back, and neither
1191
+ // answer leaves a transcript behind for a later turn to argue from.
1192
+ return await this.haltOrRunAnyway(subject.command, decision.verdict?.reason ?? '', record);
1193
+ }
1194
+ if (decision.action === 'reject') {
1195
+ // §5 — `destructive` at `auto`. The round is recorded first, so the attempt being ruled
1196
+ // on is itself on the transcript the human sees (§5.6).
1197
+ const outcome = this.negotiation.recordRejection({
1198
+ command: subject.command,
1199
+ ...(justification ? { justification } : {}),
1200
+ outcome: decision.verdict?.outcome ?? 'destructive',
1201
+ reason: decision.verdict?.reason ?? '',
1202
+ });
1203
+ if (outcome === 'reject') {
1204
+ // §7 — the refusal PLUS the moves: re-call with a justification (the tool argument
1205
+ // exists for this), call a different command, or ask the user. Rendered through the
1206
+ // one builder the human's own "no" uses, differing only in who refused, so the model
1207
+ // never meets two shapes of the same event.
1208
+ return {
1209
+ type: 'reject',
1210
+ message: buildRejectionMessage({
1211
+ source: 'rater',
1212
+ toolName: tool.name,
1213
+ verdict: decision.verdict,
1214
+ }),
1215
+ };
1216
+ }
1217
+ // A bound is spent — the agent and the rater cannot agree, and that is a human's call.
1218
+ // Falls through to the escalation below, carrying this last round's verdict.
1219
+ }
1220
+ // Escalate: carry the verdict (the honest one — see mapVerdictToAction) to the human.
1221
+ safetyVerdict = decision.verdict;
264
1222
  }
265
- if (action === 'reject') {
266
- return {
267
- type: 'reject',
268
- message: `Safety judge blocked the command: ${verdict.reason}`,
269
- };
1223
+ else {
1224
+ // EXT-70 §4.7.2/§4.7.3 — a tool call whose EFFECTIVE `openWorldHint` is true is floored at
1225
+ // `destructive`, through the SAME `applyDestructiveFloor` the shell path reaches via
1226
+ // `mapVerdictToAction`. No rating call: §4.3's scope boundary keeps the rater on the shell
1227
+ // until [[EXT-30]], and the floor is deterministic anyway — §4.6 states it as coming
1228
+ // *before* any model call, so it does not wait for one.
1229
+ //
1230
+ // **This is the branch a malformed `run_shell_command` lands in**, and it is the one shape
1231
+ // that reaches this floor under today's gate: a call with no `command` argument, or one
1232
+ // that is not a string, has nothing to rate, so it presents as a `tool` subject — and
1233
+ // `run_shell_command` carries no authored annotations, so its effective set is the
1234
+ // fail-closed one and it floors. That is the right direction: a shell call whose command
1235
+ // cannot even be read is not one anything can say something reassuring about.
1236
+ //
1237
+ // The annotations are the effective set (§4.7.1), read through the same source the `hint`
1238
+ // matcher just used, so an untrusted server's `openWorldHint: false` has already collapsed
1239
+ // to the fail-closed `true` and cannot buy its way past this.
1240
+ //
1241
+ // Reached only when no allow entry claimed the call: §4.6's fourth bullet makes an allow
1242
+ // match lift this floor, and step (4) above has already returned in that case.
1243
+ const toolFloor = openWorldToolFloorReason(effective);
1244
+ if (toolFloor !== null)
1245
+ record.stage = 'tool-open-world-floor';
1246
+ safetyVerdict = applyDestructiveFloor(safetyVerdict, toolFloor);
270
1247
  }
271
- // Escalate: carry the verdict to the human approval surface.
272
- safetyVerdict = verdict;
273
1248
  }
1249
+ // [[EXT-29]] §6 — **the human is shown the whole negotiation, not the last attempt.** Snapshot
1250
+ // it BEFORE the state is cleared, because "that the agent proposed the same command three times
1251
+ // unchanged, against two rejections that each told it what to fix, is itself the most important
1252
+ // thing on the screen". Empty for every escalation that had no negotiation — `catastrophic`
1253
+ // (which §4.2 gives no rounds at all), a declared escalate entry, an unrated rung, a tool
1254
+ // subject — so nothing renders a heading over an argument that never happened.
1255
+ // [[TUI-C27]] — everything that reaches a person has an attribution by now EXCEPT the plainest
1256
+ // case of all: a deterministic rung, no rule matched, no rating made. That is a decision the
1257
+ // rung itself made, so it is named rather than left blank — a record with no stage reads as the
1258
+ // recorder having failed, which is the opposite of what happened.
1259
+ record.stage ??= 'unrated-rung';
1260
+ const negotiationRounds = this.negotiation.transcript();
1261
+ // Reaching a person ends the negotiation (§5.3) and is the ONE thing that clears the
1262
+ // reachability bound: an escalation the human is about to answer is exactly the event that
1263
+ // bound exists to make happen, so it is spent here rather than accumulated across it.
1264
+ this.negotiation.humanReached();
274
1265
  if (!this.toolApprovalCallback) {
275
- // No interactive handler (e.g. non-TTY exec run): reject rather than auto-approve.
276
- return {
277
- type: 'reject',
278
- message: 'Tool call rejected: no interactive approval handler available.',
279
- };
1266
+ // §6.2 no one to ask. Exit non-zero with everything a person needs, rather than handing
1267
+ // the model a rejection it would just work around. The transcript goes into the message
1268
+ // because that message is the only thing anyone sees on this path.
1269
+ throw new NonInteractiveEscalationError(command ?? tool.name, safetyVerdict?.outcome, safetyVerdict?.reason, escalatedBy, renderNegotiationTranscript(negotiationRounds) ?? undefined);
280
1270
  }
281
- // Surface the judge's verdict to the human prompt (if the judge escalated) without mutating
282
- // the original interrupt object the caller holds.
283
- const pending = safetyVerdict ? { ...tool, safetyVerdict } : tool;
1271
+ // §4.2 **a `catastrophic` approval is NEVER sticky.** "The human may approve this one
1272
+ // invocation, and only this one": no always-allow, and no session-scoped allow either. The
1273
+ // surface withdraws the affordance ([[TUI-C26]] drops `always approve` from the menu for this
1274
+ // outcome), but the allow-list WRITE is decided here, and §3 has the allow-list consulted
1275
+ // *before* the rater — so one sticky grant would remove the command from rating permanently,
1276
+ // and the next `terraform destroy` would never be rated at all. Clamping here means the policy
1277
+ // does not depend on which surface asked, or on a surface that has not been built yet.
1278
+ const catastrophic = safetyVerdict?.outcome === 'catastrophic';
1279
+ // §6 — **the menu must display what it is about to store**, at the moment of the choice, on
1280
+ // every surface. It is rendered from the very grant {@link recordApproval} will write, because a
1281
+ // menu that describes a grant one way and stores it another is the drift this design cannot
1282
+ // afford. Absent exactly where no sticky grant is available — a `catastrophic` outcome (§4.2
1283
+ // withdraws the persistent grants for EVERY subject, not only the shell one), or a call nothing
1284
+ // would remember — so the prompt never advertises a control that has already been withdrawn.
1285
+ const grant = catastrophic ? undefined : this.stickyGrantFor(subject, effective, hosts);
1286
+ const grantPreview = grant ? renderApprovalEntryObject(grant.entry) : undefined;
1287
+ // §6 — the same grant in the words the menu's *always approve* control is written in, through
1288
+ // the one-liner the §4.7.4 withdrawal notice also uses, so the two cannot describe one grant
1289
+ // two ways. For a tool call this is where "the stored thing is the tool, not the arguments"
1290
+ // becomes visible: it names the tool, its server and the host bound, and nothing else.
1291
+ const grantSummary = grant ? describeApprovalEntry(grant.entry) : undefined;
1292
+ // [[TUI-C26]] §6 — the deny half, computed SEPARATELY rather than read off the grant. The two
1293
+ // are available under different conditions and `grant === undefined` is the wrong test for
1294
+ // both: a command that does not statically resolve, and every `catastrophic` verdict, have no
1295
+ // grant on offer and a perfectly good deny entry.
1296
+ const denyEntry = this.denyEntryFor(subject);
1297
+ const denyPreview = denyEntry ? renderApprovalEntryObject(denyEntry) : undefined;
1298
+ const denySummary = denyEntry ? describeApprovalEntry(denyEntry) : undefined;
1299
+ // Surface the rater's verdict, the escalate entry that fired as provenance (§3.2), and what
1300
+ // each sticky choice would store (§6) — without mutating the original interrupt object the
1301
+ // caller holds.
1302
+ //
1303
+ // **`denyPreview` belongs in this condition, and leaving it out is a silent hole rather than a
1304
+ // tidiness question.** The most ordinary prompt in the system — a deterministic rung, no
1305
+ // rating, no escalate entry, no negotiation, a command that does not statically resolve — has
1306
+ // none of the other four, so without this term the interrupt would pass through unchanged and
1307
+ // the *always reject* control would vanish from exactly the case it exists for.
1308
+ const pending = safetyVerdict || escalatedBy || grantPreview || denyPreview || negotiationRounds.length > 0
1309
+ ? {
1310
+ ...tool,
1311
+ ...(safetyVerdict ? { safetyVerdict } : {}),
1312
+ ...(escalatedBy ? { escalatedBy } : {}),
1313
+ ...(grantPreview ? { grantPreview } : {}),
1314
+ ...(grantSummary ? { grantSummary } : {}),
1315
+ ...(denyPreview ? { denyPreview } : {}),
1316
+ ...(denySummary ? { denySummary } : {}),
1317
+ ...(negotiationRounds.length > 0 ? { negotiationRounds } : {}),
1318
+ }
1319
+ : tool;
284
1320
  const decision = await this.toolApprovalCallback(pending);
285
- // Persist the human's scoped grant so future variants of the same operation skip the prompt.
286
- if (decision.type === 'approve' && allowlistApplies && command) {
287
- this.recordApproval(command, decision.scope ?? 'once');
1321
+ // [[TUI-C27]] a person was reached and answered. The STAGE stays whatever decided to ask
1322
+ // them (a rating, an escalate entry, an unrated rung): "who decided to interrupt" and "what
1323
+ // they said" are two different questions, and collapsing them into one field is what makes a
1324
+ // dump unable to tell a rater escalation from a declared one.
1325
+ record.humanAnswer = decision.type === 'approve' ? 'approve' : 'reject';
1326
+ // Record the human's scoped grant so the same call stops re-prompting.
1327
+ if (decision.type === 'approve' && grant) {
1328
+ this.recordApproval(grant, decision.scope ?? 'once');
1329
+ }
1330
+ // §6 — and the mirror: *always reject* records the refusal, so the next identical call is
1331
+ // refused by rule at step (1) without reaching a person. Scoped `session` and nothing else,
1332
+ // because that is the only lifetime the store has (see {@link ToolRejectScope}).
1333
+ if (decision.type === 'reject' && decision.scope === 'session' && denyEntry) {
1334
+ this.recordDenial(denyEntry);
288
1335
  }
289
1336
  return decision;
290
1337
  }
291
- /** Whether the EXT-10 LLM-as-judge safety gate is enabled for the active command's config. */
292
- isShellJudgeOn() {
293
- if (!this.config)
294
- return false;
295
- const devTools = getEffectiveDevToolsConfig(this.config, this.command);
296
- return isShellJudgeEnabled(devTools);
297
- }
298
- /** Whether the EXT-9 Tier-2 allow-list is enabled for the active command's devTools config. */
299
- isShellAllowlistOn() {
300
- const devTools = getEffectiveDevToolsConfig(this.config ?? undefined, this.command);
301
- return isShellAllowlistEnabled(devTools);
1338
+ /**
1339
+ * [[TUI-C27]] — attribute the deciding stage and hand the decision straight back.
1340
+ *
1341
+ * A one-liner so a stage can be recorded ON the `return` that carries it rather than on the line
1342
+ * above: two statements let an early return be added between them, and the record would then name
1343
+ * a stage that did not decide.
1344
+ */
1345
+ stage(record, stage, decision) {
1346
+ record.stage = stage;
1347
+ return decision;
302
1348
  }
303
1349
  /**
304
- * Lazily load (once per instance) the persisted `always` allow-list, unless persistence is
305
- * disabled by config. Returns null when persistence is off so `always` grants behave as
306
- * `session` (in-memory only).
1350
+ * One rating call, with EXT-66's timeout reporting attached. Extracted so the §3.2 tripwire (a
1351
+ * rated allow match) and the ordinary rater path cannot drift apart in WHAT they hand the rater —
1352
+ * only in what they do with the answer.
307
1353
  */
308
- getPersistedAllowlist() {
309
- if (this.persistedAllowlistLoaded)
310
- return this.persistedAllowlist;
311
- this.persistedAllowlistLoaded = true;
312
- const devTools = getEffectiveDevToolsConfig(this.config ?? undefined, this.command);
313
- if (!isShellAllowlistPersisted(devTools)) {
314
- this.persistedAllowlist = null;
315
- return null;
1354
+ async rateCommand(command, opts,
1355
+ /** [[TUI-C27]] — the decision's record; the rating attaches itself to it at the send site. */
1356
+ record) {
1357
+ const approvals = this.sessionApprovals;
1358
+ const verdict = await rateShellCommand(command, this.config, {
1359
+ home: env?.HOME,
1360
+ negotiation: opts.negotiation,
1361
+ negotiable: opts.negotiable,
1362
+ // [[TUI-C27]] — the sink fires BEFORE the model is invoked, with the prompt that is about to
1363
+ // be sent, so the record carries what the rater was SHOWN rather than a later re-render of
1364
+ // it. Assigning it here (rather than pushing a finished record afterwards) is what makes a
1365
+ // hung, timed-out or halting call still leave the question behind.
1366
+ onCapture: (capture) => {
1367
+ record.rating = capture;
1368
+ },
1369
+ raterProfile: approvals.rater,
1370
+ // The profile's model when one is configured; undefined lets rateShellCommand use the
1371
+ // session model. `init` throws rather than leaving this undefined for a NAMED profile, so
1372
+ // a configured profile can never silently degrade to the session model here.
1373
+ model: this.raterModel,
1374
+ // EXT-58 (§4.4) — the already-granted built-ins of the CURRENT rung, so a non-`safe`
1375
+ // outcome can name one the model could call for free instead. Computed per rating rather
1376
+ // than cached at init, because `/approvals <rung>` moves the rung mid-session and a stale
1377
+ // list would offer a tool that is no longer granted.
1378
+ grantedTools: this.getGrantedBuiltInTools(),
1379
+ // EXT-66 — the user-owned budget for ONE rating call, `undefined` when unset so
1380
+ // rateShellCommand applies RATER_DEFAULT_TIMEOUT_MS. 30s is a hosted-model number and a
1381
+ // local rater is knowably slower; without this a local `auto` session drifts toward
1382
+ // escalating everything, which is the failure the rung exists to prevent.
1383
+ timeoutMs: approvals.raterTimeoutMs,
1384
+ });
1385
+ // EXT-66 — a timeout is the gate giving up, not a judgement, and the two were previously
1386
+ // indistinguishable in the action column. Say it once per occurrence: the only symptom
1387
+ // otherwise is the gate becoming mysteriously more talkative, which reads as the rater
1388
+ // working rather than as the rater never being heard from.
1389
+ if (isRaterTimeout(verdict)) {
1390
+ this.raterTimeouts += 1;
1391
+ this.statusUpdate(StatusLevel.WARNING, `The command safety rater did not answer in time (${approvals.raterTimeoutMs ?? RATER_DEFAULT_TIMEOUT_MS}ms), so this command ` +
1392
+ // §3.2 — on an allow match the rating is a tripwire, so a timeout does not escalate: the
1393
+ // human's standing grant still stands and the call runs. Saying "escalated" there would
1394
+ // be simply false, and a notice that misreports the action it accompanies is worse than
1395
+ // none.
1396
+ (opts.allowMatched
1397
+ ? 'ran on its approvals.allow match alone, without the rating that entry asked for'
1398
+ : 'was escalated without being rated') +
1399
+ (this.raterTimeouts > 1 ? ` — ${this.raterTimeouts} times this session` : '') +
1400
+ '. Raise approvals.raterTimeoutMs if the rater is a local model.');
316
1401
  }
1402
+ return verdict;
1403
+ }
1404
+ /**
1405
+ * EXT-58 (§4.3/§4.4) — the built-in tools already granted at the session's CURRENT rung, as
1406
+ * names plus one-line locally-authored descriptions, for the rater prompt.
1407
+ *
1408
+ * Two filters make this safe to place outside the rater's fenced untrusted block:
1409
+ * - the names come from what the agent actually registered
1410
+ * ({@link GthAgentInterface.getRegisteredToolNames}), so the rater can only ever offer a tool
1411
+ * this session has;
1412
+ * - the descriptions come from core's own `BUILT_IN_TOOL_SUMMARIES` table, so no MCP, custom or
1413
+ * A2A tool's own (attacker-influenceable) description can reach the prompt.
1414
+ *
1415
+ * Empty when the agent does not expose its tools — the rater then gets no list and, per the
1416
+ * prompt, offers nothing.
1417
+ */
1418
+ getGrantedBuiltInTools() {
1419
+ const registered = this.agent?.getRegisteredToolNames?.() ?? [];
1420
+ if (registered.length === 0)
1421
+ return [];
1422
+ // The LIVE gated set, from the SAME shared policy `decideToolApproval` decides on and the
1423
+ // backends derive their interrupt from, so "granted" here means exactly what it means at
1424
+ // tool-registration time (§4.5) and at the gate.
1425
+ //
1426
+ // EXT-80 makes this non-drift property load-bearing rather than incidental. At `manual` the
1427
+ // write built-ins are gated, so they are NOT granted, and a summary still offering `write_file`
1428
+ // there would tell the model a tool is free while the gate stops and asks for it — the rater
1429
+ // suggesting the one thing guaranteed to interrupt the user. It is computed per rating from
1430
+ // `sessionApprovals.rung`, so it follows a mid-session `/approvals` change — unlike the
1431
+ // interrupt set, which is fixed when the graph is built and is rung-independent for exactly
1432
+ // that reason.
1433
+ const { gateShell } = resolveShellApprovalGate(this.config ?? undefined, this.command);
1434
+ const gatedTools = resolveGatedToolNames({
1435
+ rung: this.sessionApprovals.rung,
1436
+ gateShell,
1437
+ boundToolNames: registered,
1438
+ });
1439
+ return describeGrantedBuiltInTools(registered, this.sessionApprovals.rung, gatedTools);
1440
+ }
1441
+ /**
1442
+ * §3/§3.3 — the three rule lists this session decides by: the DECLARED entries from config
1443
+ * (read-only input) concatenated with the runtime grants the escalation menu made. One set of
1444
+ * lists, handed to the one comparison engine; the concatenation cannot change any outcome
1445
+ * because `resolveApprovalRules` consults every deny entry before any escalate entry and every
1446
+ * escalate entry before any allow entry.
1447
+ *
1448
+ * The persisted store is loaded here rather than at {@link init} — lazily, once per instance, and
1449
+ * NEVER at `bypass`, where the allow list is moot and a session that has switched the gate off
1450
+ * should not be reading or rewriting the project's grant file.
1451
+ */
1452
+ approvalRuleLists() {
1453
+ const approvals = this.sessionApprovals;
1454
+ const persisted = approvals.rung === 'bypass' ? null : this.getPersistedGrants();
1455
+ return {
1456
+ deny: [...approvals.deny, ...this.denyGrants.entries()],
1457
+ escalate: approvals.escalate,
1458
+ allow: [...approvals.allow, ...this.sessionGrants.entries(), ...(persisted?.entries() ?? [])],
1459
+ };
1460
+ }
1461
+ /**
1462
+ * Lazily load (once per instance) the persisted `always` grant store.
1463
+ *
1464
+ * CFG-27 removed the `persistAllowlist` switch: §3 makes persistence a per-decision choice in
1465
+ * the escalation menu (`approve` forgets, `always approve` persists), and a global "never
1466
+ * persist" setting would only duplicate a keystroke. Returns null when the store cannot be
1467
+ * loaded at all, in which case `always` grants degrade to `session` (in-memory only).
1468
+ *
1469
+ * The v1→v2 migration notice is routed to `statusUpdate` from here, which is the only place that
1470
+ * knows how to reach the user.
1471
+ */
1472
+ getPersistedGrants() {
1473
+ if (this.persistedGrantsLoaded)
1474
+ return this.persistedGrants;
1475
+ this.persistedGrantsLoaded = true;
317
1476
  try {
318
1477
  const filePath = getGslothConfigWritePath(SHELL_ALLOWLIST_FILE);
319
- this.persistedAllowlist = new PersistedAllowlist(filePath);
1478
+ this.persistedGrants = new PersistedApprovalGrants(filePath, {
1479
+ onNotice: (notice) => this.statusUpdate(notice.level, notice.message),
1480
+ });
320
1481
  }
321
1482
  catch (e) {
322
1483
  // Path/IO failure → behave as no persisted store (still safe: just prompts more).
323
- debugLogError('Loading persisted shell allow-list', e);
324
- this.persistedAllowlist = null;
1484
+ debugLogError('Loading persisted shell approvals', e);
1485
+ this.persistedGrants = null;
325
1486
  }
326
- return this.persistedAllowlist;
1487
+ return this.persistedGrants;
327
1488
  }
328
- /** Check the command against the session + persisted stores (with anti-widening re-validation). */
329
- isApprovedByAllowlist(command) {
330
- return matchesApproval(command, {
331
- session: this.sessionAllowlist,
332
- always: this.getPersistedAllowlist() ?? undefined,
333
- });
1489
+ /**
1490
+ * §3.1/§4.7.4/§6 — **the grant a sticky choice would write for this call**, or `undefined` when
1491
+ * none is on offer. The one place that question is answered, so the menu's *this is what will be
1492
+ * stored* line (§6) and the store can never disagree.
1493
+ *
1494
+ * - **A shell call** records the command itself as an `exact` entry (§3.1) — never a prefix,
1495
+ * never a pattern. One that does not statically resolve (composition, substitution,
1496
+ * redirection) is not on offer: no allow entry of any matcher matches such a command, so the
1497
+ * entry would be inert, and an inert entry sitting in a list §3 requires to be inspectable
1498
+ * tells the user something is in force when nothing is.
1499
+ * - **A tool call** records identity — the tool, its server, and the host where the call carries
1500
+ * one (§4.7.4, {@link toolGrantEntry}) — never arguments, which would produce a grant that
1501
+ * never matches twice. A call naming no host records the tool alone, which is §6's own example
1502
+ * (*always approve `mcp__jira__create_issue`*, where no host is involved); what keeps that from
1503
+ * being unbounded is §3.2's default that a tool entry is still `rate: true`, so the rater goes
1504
+ * on seeing every call's full arguments.
1505
+ *
1506
+ * Four cases have **no grant on offer at all**, each fail-closed:
1507
+ *
1508
+ * 1. **`bypass`** — the gate is off for this session and nothing is remembered from it.
1509
+ * 2. **`run_shell_command` arriving as a tool subject.** That is what a shell call with no
1510
+ * readable `command` argument presents as, and it names no host, so without this it would take
1511
+ * the tool-only arm and write a `{"type":"tool","pattern":"run_shell_command"}` grant that
1512
+ * auto-approves every future call whose command cannot even be read. This exclusion is what
1513
+ * stops that, not a side effect of anything else, and it must survive [[EXT-30]] widening the
1514
+ * gate.
1515
+ * 3. **A call naming more than one distinct host.** The grammar has no entry for it. `host` is a
1516
+ * single optional string on every tool arm of `approvalEntrySchema`, and every arm is a
1517
+ * `z.strictObject`, so recording the *set* is not a policy this code may choose — a `hosts`
1518
+ * array is an unrecognized-key error, and writing one would be a §3.1 grammar change. Of the
1519
+ * two entries that would parse, the host-bound one displays a bound the grant does not have,
1520
+ * which §6 forbids (the menu shows exactly what will be stored). And a grammar that did record
1521
+ * the set, matching only when all of it recurred, would fail §4.7.4's opening test anyway: a
1522
+ * tool whose host set varies per call would get a grant that never matches a second time — not
1523
+ * a narrower grant, the useless one §4.7.4 rejects by name.
1524
+ *
1525
+ * **What this arm does not claim.** It is not a narrowing. A hostless entry imposes no host
1526
+ * condition at all (`resolveApprovalRules`), so the tool-only grant that any host-less call to
1527
+ * the same tool produces already auto-approves a multi-host one. Refusing here withholds a
1528
+ * grant; it does not close a hole, and the reason to keep it is the grammar above rather than
1529
+ * any breadth it prevents. Asserted, so this cannot drift back into a claim the system does not
1530
+ * support.
1531
+ * 4. **An MCP call whose server could not be resolved** ({@link toolGrantEntry} returns `null`) —
1532
+ * a call nobody can attribute is not one anything can remember.
1533
+ */
1534
+ stickyGrantFor(subject, effective, hosts) {
1535
+ if (this.sessionApprovals.rung === 'bypass')
1536
+ return undefined;
1537
+ if (subject.kind === 'shell') {
1538
+ if (classifyCommand(subject.command, normalizeCommand) === null)
1539
+ return undefined;
1540
+ return { entry: shellGrantEntry(subject.command) };
1541
+ }
1542
+ if (subject.name === SHELL_TOOL_NAME)
1543
+ return undefined;
1544
+ // A snapshot is what invalidation compares against, so a grant with no readable effective set
1545
+ // is a grant nothing could ever invalidate.
1546
+ if (!effective)
1547
+ return undefined;
1548
+ if (hosts.length > 1)
1549
+ return undefined;
1550
+ const entry = toolGrantEntry(subject);
1551
+ if (!entry)
1552
+ return undefined;
1553
+ // §4.7.4 — the effective set the human approved this tool AS. `annotationWeakenings` compares a
1554
+ // later one against it, and the store copies it so the record is private to this grant.
1555
+ return { entry, annotations: effective };
1556
+ }
1557
+ /**
1558
+ * [[TUI-C26]] §6 — **the entry the escalation menu's *always reject* choice would record**, or
1559
+ * `undefined` when the grammar cannot hold one. The deny mirror of {@link stickyGrantFor}, and a
1560
+ * separate function rather than a flag on it, because the two answer different questions.
1561
+ *
1562
+ * **Nearly every reason an allow entry is withheld does not apply here.** §3 has one rule for
1563
+ * this and it runs the other way — *undecidable is a non-match on the allow side and a match on
1564
+ * the deny side* — so:
1565
+ *
1566
+ * - **A command that does not statically resolve gets an entry.** `stickyGrantFor` refuses one
1567
+ * because no allow entry of any matcher would ever match it, making the entry inert; a deny
1568
+ * entry for the same command is matched against the whole normalized command *and* every
1569
+ * segment a shell would run, so it is the opposite of inert.
1570
+ * - **A `catastrophic` verdict changes nothing.** §4.2 withdraws the sticky grants there; it says
1571
+ * nothing about refusals, and refusing more is never the direction that needs withdrawing.
1572
+ * - **`bypass` changes nothing either**, and that is a positive statement rather than a gap. Deny
1573
+ * is resolved at step (1) of {@link decideToolApprovalInner}, *before* the `bypass` return, so
1574
+ * a recorded refusal is in force at every rung — which is why this does not copy the allow
1575
+ * side's `bypass` guard.
1576
+ * - **A call naming several hosts gets the host-less entry.** On the allow side that would show a
1577
+ * bound the grant does not have; here the entry covers every host of that tool, which is
1578
+ * broader than the call and safe in the direction breadth is safe. The menu shows exactly that
1579
+ * entry, so the breadth is on screen rather than inferred.
1580
+ * - **`run_shell_command` arriving as a TOOL subject gets a tool entry** — a shell call whose
1581
+ * `command` argument cannot even be read. On the allow side that entry would auto-approve every
1582
+ * future unreadable shell call, which is why it is excluded there; as a refusal it stops the
1583
+ * shell tool for the session, and the dialog says so in the words the entry is written in.
1584
+ *
1585
+ * The one genuine exclusion is an **MCP call whose server could not be attributed**
1586
+ * ({@link toolGrantEntry} returns `null`): the grammar's `server` cannot be the empty string, so
1587
+ * the entry would be dropped by its own validator and the human would be told a refusal had been
1588
+ * recorded when none was. A shell command that normalizes to nothing is excluded for the same
1589
+ * reason — an empty `pattern` is not a legal entry.
1590
+ */
1591
+ denyEntryFor(subject) {
1592
+ if (subject.kind === 'shell') {
1593
+ const entry = shellGrantEntry(subject.command);
1594
+ return entry.pattern.length > 0 ? entry : undefined;
1595
+ }
1596
+ return toolGrantEntry(subject) ?? undefined;
1597
+ }
1598
+ /**
1599
+ * §6 — record the menu's *always reject* choice, for the life of this runner instance.
1600
+ *
1601
+ * It lands in the same store `approvals.deny` entries are matched from ({@link approvalRuleLists}
1602
+ * concatenates the two), so a refusal the human made at the prompt and one they wrote in their
1603
+ * config are one list to the matcher and one list to `/approvals`.
1604
+ *
1605
+ * **Session-lifetime, and there is nothing else to choose.** There is no persisted deny file;
1606
+ * whether there should be is a question about a file users live with, not about this prompt. What
1607
+ * the surfaces must not do is say otherwise — a confirmation promising a persistence that did not
1608
+ * happen is §6's *offered and then refused* with the evidence hidden.
1609
+ */
1610
+ recordDenial(entry) {
1611
+ this.denyGrants.add({ entry, grantedAt: new Date().toISOString(), scope: 'session' });
334
1612
  }
335
1613
  /**
336
- * Record a human-granted approval at the given scope. `once` persists nothing. `session`
337
- * adds the classified prefix to the in-memory store. `always` additionally persists it (or
338
- * falls back to session-only when persistence is disabled).
1614
+ * §3.1/§6 — record a human-granted approval at the given scope. `once` remembers nothing.
1615
+ * `session` adds the entry to the in-memory store; `always` additionally persists it (falling
1616
+ * back to session-only when the file cannot be written).
1617
+ *
1618
+ * What is recorded was decided by {@link stickyGrantFor} and shown to the human before they
1619
+ * answered; this only stamps it with when and at what scope.
339
1620
  */
340
- recordApproval(command, scope) {
1621
+ recordApproval(grant, scope) {
341
1622
  if (scope === 'once')
342
1623
  return;
343
- const classification = classifyCommand(command, normalizeCommand);
344
- if (!classification)
345
- return; // unclassifiable (composition/redirection) → never remember.
346
- this.sessionAllowlist.add(classification.prefix);
1624
+ const grantScope = scope;
1625
+ const record = {
1626
+ ...grant,
1627
+ grantedAt: new Date().toISOString(),
1628
+ scope: grantScope,
1629
+ };
1630
+ this.sessionGrants.add(record);
347
1631
  if (scope === 'always') {
348
- this.getPersistedAllowlist()?.add(classification.prefix);
1632
+ this.getPersistedGrants()?.add(record);
1633
+ }
1634
+ }
1635
+ /**
1636
+ * §4.7.4 — **drop a tool grant the tool has since weakened out from under, with a notice naming
1637
+ * the tool, the server and the hint that moved.**
1638
+ *
1639
+ * The human approved a tool *as annotated*; a tool that re-annotates itself into a more dangerous
1640
+ * shape is a different proposition wearing the same name, so the grant is invalidated and the next
1641
+ * call prompts again. Only a **trusted** server can produce a weakening — an untrusted server's
1642
+ * effective set is the constant fail-closed default (§4.7.1) and cannot move — which is exactly
1643
+ * where it matters, since the trusted server is the one whose rug-pull would otherwise ride an
1644
+ * existing grant.
1645
+ *
1646
+ * **Scoped to the call being decided, never a sweep of the store.** A sweep would read every held
1647
+ * grant against a source that can only answer for the tools registered right now, so a server that
1648
+ * happened to be offline would read as having weakened everything it ever declared — and the
1649
+ * grants would be deleted for it.
1650
+ *
1651
+ * **The scope is every grant that could auto-approve THIS call, which is at most two.** A grant
1652
+ * with no `host` imposes no host condition, so it matches a call that carries one; looking up only
1653
+ * the entry this call would grant (`host` included) would miss the tool-only grant that is about
1654
+ * to auto-approve it, and the weakening would ride straight through — the exact failure §4.7.4
1655
+ * exists to stop. The host-bound entry of a DIFFERENT host is deliberately not a candidate: it
1656
+ * does not match this call either, so this call's annotations say nothing about it.
1657
+ *
1658
+ * **Only allow-side grants.** A weakening makes a tool more dangerous, so dropping an *always
1659
+ * reject* over one would be the unsafe direction: the reason to withdraw an approval is the reason
1660
+ * to keep a refusal.
1661
+ */
1662
+ invalidateWeakenedGrants(subject, effective) {
1663
+ const candidates = [
1664
+ toolGrantEntry(subject),
1665
+ ...(subject.host !== undefined ? [toolGrantEntry({ ...subject, host: undefined })] : []),
1666
+ ].filter((entry) => entry !== null);
1667
+ if (candidates.length === 0)
1668
+ return;
1669
+ // Never at `bypass`, for the same reason `approvalRuleLists` does not read the file there: a
1670
+ // session that has switched the gate off should not be rewriting the project's grant file.
1671
+ const persisted = this.sessionApprovals.rung === 'bypass' ? null : this.getPersistedGrants();
1672
+ for (const entry of candidates) {
1673
+ // The session store wins, and a session grant with no snapshot therefore hides a persisted one
1674
+ // that has one. Safe only because all three of these hold, and each is a premise a later change
1675
+ // could break silently: (1) every tool grant this runner writes carries a snapshot — a call with
1676
+ // no readable effective set is refused a grant at all ({@link stickyGrantFor}); (2) a `shell`
1677
+ // subject, the one kind whose grant has no snapshot by design, never reaches this method; and
1678
+ // (3) a persisted grant already in force auto-approves the call, so no prompt happens and no
1679
+ // session grant is written over it. Break any one of them and this line starts skipping a
1680
+ // weakening it should have caught — check both stores then, rather than the first that answers.
1681
+ const held = this.sessionGrants.find(entry) ?? persisted?.find(entry);
1682
+ if (!held?.annotations)
1683
+ continue;
1684
+ const weakened = annotationWeakenings(held.annotations, effective);
1685
+ if (weakened.length === 0)
1686
+ continue;
1687
+ // Removed rather than skipped: the stores de-duplicate by entry identity, so a grant left in
1688
+ // place would silently swallow the human's re-approval of the same tool.
1689
+ this.sessionGrants.remove(entry);
1690
+ persisted?.remove(entry);
1691
+ this.statusUpdate(StatusLevel.WARNING, describeWeakenedGrant(entry, weakened, held.annotations, effective));
349
1692
  }
350
1693
  }
351
1694
  /**
@@ -363,7 +1706,7 @@ export class GthAgentRunner {
363
1706
  * leaves the graph suspended on a `humanInTheLoopMiddleware` interrupt rather than
364
1707
  * completing. This is the event-stream counterpart to the readline path's
365
1708
  * {@link resolveToolInterrupts}: it drains any pending interrupts through
366
- * {@link decideToolApproval} (allow-list → judge → bridged human prompt), resumes via
1709
+ * {@link decideToolApproval} (bypass → allow-list → rater → bridged human prompt), resumes via
367
1710
  * `streamWithEventsResume({ decisions })`, and loops until the graph completes with no
368
1711
  * pending interrupts — so the executed command's output renders into the TUI. Without
369
1712
  * this the TUI silently finalized an empty turn (approval gate was dead code on the
@@ -373,6 +1716,16 @@ export class GthAgentRunner {
373
1716
  if (!this.agent || !this.config || !this.runConfig) {
374
1717
  throw new Error('AgentRunner not initialized. Call init() first.');
375
1718
  }
1719
+ // GS2-16: start this turn's analytics tally from zero (the runner is reused across turns).
1720
+ this.resetRunStats();
1721
+ // GS2-48 — record this turn's transcript tail for the crash handler.
1722
+ updateCrashContext({ transcriptTail: messages.slice(-CRASH_TRANSCRIPT_TAIL_MESSAGES) });
1723
+ // [[EXT-29]] §5 — a new user turn is the human being reached, so it ends any negotiation still
1724
+ // standing from the previous one and clears BOTH bounds. The turn's own messages then enter
1725
+ // §5.1's last-5 window, which is what makes "just the last two" reach the rater at all — the
1726
+ // reply that narrows what the agent proposes is worthless to the gate if only the agent hears it.
1727
+ this.negotiation.humanReached();
1728
+ this.negotiation.noteUserMessages(humanMessageTexts(messages));
376
1729
  debugLog('Processing messages (event stream)...');
377
1730
  debugLogObject('Input Messages', messages);
378
1731
  yield* this.agent.streamWithEvents(messages, this.runConfig, signal);
@@ -383,15 +1736,18 @@ export class GthAgentRunner {
383
1736
  * resolve any tool-approval interrupts it suspended on, yielding the resumed run's typed
384
1737
  * {@link AgentStreamEvent}s so the renderer (the Ink TUI) shows the executed command's
385
1738
  * output. Each pending tool call is consulted via {@link decideToolApproval} — the SAME
386
- * three-layer gate the readline path uses (allow-list auto-approve → EXT-10 judge
1739
+ * gate the readline path uses (bypass → allow-list approve → CFG-26 AI rater
387
1740
  * bridged human callback, defaulting to REJECT when no handler is wired) — and the
388
1741
  * collected decisions are sent back via `streamWithEventsResume` as a LangChain HITL
389
1742
  * resume (`{ decisions }`). Because a resumed run can suspend again on the next gated
390
1743
  * tool call, this loops until the graph completes with no pending interrupts.
391
1744
  *
392
1745
  * No-ops (yields nothing) when the agent does not support interrupts
393
- * (`getPendingToolInterrupts`/`streamWithEventsResume` absent), so the lean agent and
394
- * non-HITL configs are unaffected. Aborts (`signal`) propagate through the resumed stream.
1746
+ * (`getPendingToolInterrupts`/`streamWithEventsResume` absent) that is the only exemption.
1747
+ * As of EXT-52 BOTH backends gate `run_shell_command` and expose the interrupt surface, so the
1748
+ * lean (default) agent is now exactly the agent this loop serves; only an agent implementation
1749
+ * without those methods (e.g. a test double) skips it. Aborts (`signal`) propagate through the
1750
+ * resumed stream.
395
1751
  */
396
1752
  async *resolveToolInterruptsWithEvents(signal) {
397
1753
  const agent = this.agent;
@@ -418,6 +1774,44 @@ export class GthAgentRunner {
418
1774
  getAgent() {
419
1775
  return this.agent;
420
1776
  }
1777
+ /**
1778
+ * GS2-16 — reset the current turn's analytics tally on both the live agent and the runner's
1779
+ * cached snapshot, so a new turn starts clean. Fail-soft (an agent without stats support is a
1780
+ * no-op). Called at the top of each `processMessages` / `processMessagesWithEvents`.
1781
+ */
1782
+ resetRunStats() {
1783
+ this.lastRunStats = { tools: [] };
1784
+ try {
1785
+ this.agent?.resetRunStats?.();
1786
+ }
1787
+ catch {
1788
+ /* fail-soft: analytics must never affect a run */
1789
+ }
1790
+ }
1791
+ /** GS2-16 — read the live agent's run stats (fail-soft; empty tally if unavailable). */
1792
+ captureRunStats() {
1793
+ try {
1794
+ const stats = this.agent?.getRunStats?.();
1795
+ if (stats)
1796
+ return stats;
1797
+ }
1798
+ catch {
1799
+ /* fail-soft */
1800
+ }
1801
+ return { tools: [] };
1802
+ }
1803
+ /**
1804
+ * GS2-16 — the analytics harvested from the just-finished turn (token usage + invoked tools),
1805
+ * to thread into the opt-in history recorder. Reads live from the agent when one is present,
1806
+ * otherwise the snapshot captured at {@link cleanup} (the single-shot path reads post-cleanup).
1807
+ * Never throws.
1808
+ */
1809
+ getRunStats() {
1810
+ if (this.agent) {
1811
+ this.lastRunStats = this.captureRunStats();
1812
+ }
1813
+ return this.lastRunStats;
1814
+ }
421
1815
  /**
422
1816
  * Rotate the thread the runner drives by minting a fresh `runConfig` (new `thread_id`),
423
1817
  * so subsequent turns start from an empty checkpointer thread rather than retrieving the
@@ -428,11 +1822,18 @@ export class GthAgentRunner {
428
1822
  * of any checkpointer-specific delete API, mirroring how `init()` mints the initial config.
429
1823
  */
430
1824
  resetThread() {
1825
+ // [[EXT-29]] §5.1 — the negotiation goes with the thread, user messages included. The rater's
1826
+ // last-5 window is conversation context; leaving it behind a `/clear` would quote the user's
1827
+ // previous conversation into a rating made after they asked for it to be forgotten.
1828
+ this.negotiation.clear();
431
1829
  this.runConfig = getNewRunnableConfig();
432
1830
  debugLogObject('Reset Runnable Config', this.runConfig);
433
1831
  }
434
1832
  async cleanup() {
435
1833
  debugLog('Cleaning up GthAgentRunner...');
1834
+ // GS2-16: snapshot the agent's run stats BEFORE nulling it, so a post-cleanup reader
1835
+ // (runSingleShot records history after calling cleanup) still gets this turn's analytics.
1836
+ this.lastRunStats = this.captureRunStats();
436
1837
  if (this.agent && 'cleanup' in this.agent && typeof this.agent.cleanup === 'function') {
437
1838
  await this.agent.cleanup();
438
1839
  }