@gaunt-sloth/core 2.0.0-alpha.24 → 2.0.0-alpha.26

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 (109) hide show
  1. package/README.md +1 -1
  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 +1 -0
  6. package/dist/config/defaults.js +1 -0
  7. package/dist/config/defaults.js.map +1 -1
  8. package/dist/config/loader.d.ts +13 -6
  9. package/dist/config/loader.js +171 -28
  10. package/dist/config/loader.js.map +1 -1
  11. package/dist/config/mouse.d.ts +50 -0
  12. package/dist/config/mouse.js +44 -0
  13. package/dist/config/mouse.js.map +1 -0
  14. package/dist/config/schema.d.ts +2372 -114
  15. package/dist/config/schema.js +845 -16
  16. package/dist/config/schema.js.map +1 -1
  17. package/dist/config/shell-policy.d.ts +466 -116
  18. package/dist/config/shell-policy.js +273 -62
  19. package/dist/config/shell-policy.js.map +1 -1
  20. package/dist/config/tool-descriptions.d.ts +165 -0
  21. package/dist/config/tool-descriptions.js +229 -0
  22. package/dist/config/tool-descriptions.js.map +1 -0
  23. package/dist/config/types.d.ts +36 -1
  24. package/dist/config/types.js.map +1 -1
  25. package/dist/config.d.ts +3 -0
  26. package/dist/config.js +3 -0
  27. package/dist/config.js.map +1 -1
  28. package/dist/core/GthAbstractAgent.d.ts +59 -1
  29. package/dist/core/GthAbstractAgent.js +72 -0
  30. package/dist/core/GthAbstractAgent.js.map +1 -1
  31. package/dist/core/GthAgentRunner.d.ts +341 -58
  32. package/dist/core/GthAgentRunner.js +817 -149
  33. package/dist/core/GthAgentRunner.js.map +1 -1
  34. package/dist/core/GthLangChainAgent.d.ts +16 -0
  35. package/dist/core/GthLangChainAgent.js +72 -2
  36. package/dist/core/GthLangChainAgent.js.map +1 -1
  37. package/dist/core/approvals/annotations.d.ts +122 -0
  38. package/dist/core/approvals/annotations.js +137 -0
  39. package/dist/core/approvals/annotations.js.map +1 -0
  40. package/dist/core/approvals/grants.d.ts +216 -0
  41. package/dist/core/approvals/grants.js +469 -0
  42. package/dist/core/approvals/grants.js.map +1 -0
  43. package/dist/core/approvals/matcher.d.ts +202 -0
  44. package/dist/core/approvals/matcher.js +267 -0
  45. package/dist/core/approvals/matcher.js.map +1 -0
  46. package/dist/core/approvals/mcpSubjects.d.ts +40 -0
  47. package/dist/core/approvals/mcpSubjects.js +99 -0
  48. package/dist/core/approvals/mcpSubjects.js.map +1 -0
  49. package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
  50. package/dist/core/approvals/toolAnnotationSources.js +277 -0
  51. package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
  52. package/dist/core/approvals/toolHost.d.ts +46 -0
  53. package/dist/core/approvals/toolHost.js +108 -0
  54. package/dist/core/approvals/toolHost.js.map +1 -0
  55. package/dist/core/launchBanner.d.ts +120 -0
  56. package/dist/core/launchBanner.js +399 -0
  57. package/dist/core/launchBanner.js.map +1 -0
  58. package/dist/core/plainToolIndication.js +13 -7
  59. package/dist/core/plainToolIndication.js.map +1 -1
  60. package/dist/core/shell/approvalStop.d.ts +75 -0
  61. package/dist/core/shell/approvalStop.js +108 -0
  62. package/dist/core/shell/approvalStop.js.map +1 -0
  63. package/dist/core/shell/arity.d.ts +6 -0
  64. package/dist/core/shell/arity.js +20 -6
  65. package/dist/core/shell/arity.js.map +1 -1
  66. package/dist/core/shell/denylist.d.ts +11 -0
  67. package/dist/core/shell/denylist.js +37 -0
  68. package/dist/core/shell/denylist.js.map +1 -0
  69. package/dist/core/shell/normalize.d.ts +41 -1
  70. package/dist/core/shell/normalize.js +58 -4
  71. package/dist/core/shell/normalize.js.map +1 -1
  72. package/dist/core/shell/openWorld.d.ts +138 -0
  73. package/dist/core/shell/openWorld.js +523 -0
  74. package/dist/core/shell/openWorld.js.map +1 -0
  75. package/dist/core/shell/rater.d.ts +560 -0
  76. package/dist/core/shell/rater.js +895 -0
  77. package/dist/core/shell/rater.js.map +1 -0
  78. package/dist/core/shell/raterModel.d.ts +41 -0
  79. package/dist/core/shell/raterModel.js +51 -0
  80. package/dist/core/shell/raterModel.js.map +1 -0
  81. package/dist/core/shell/rejection.d.ts +66 -0
  82. package/dist/core/shell/rejection.js +38 -0
  83. package/dist/core/shell/rejection.js.map +1 -0
  84. package/dist/core/types.d.ts +71 -9
  85. package/dist/core/types.js.map +1 -1
  86. package/dist/runtime/askStructured.d.ts +8 -7
  87. package/dist/runtime/askStructured.js +8 -7
  88. package/dist/runtime/askStructured.js.map +1 -1
  89. package/dist/runtime/conversation.js +89 -76
  90. package/dist/runtime/conversation.js.map +1 -1
  91. package/dist/runtime/singleShot.js +71 -60
  92. package/dist/runtime/singleShot.js.map +1 -1
  93. package/dist/utils/ProgressIndicator.d.ts +21 -0
  94. package/dist/utils/ProgressIndicator.js +30 -3
  95. package/dist/utils/ProgressIndicator.js.map +1 -1
  96. package/dist/utils/consoleUtils.d.ts +30 -0
  97. package/dist/utils/consoleUtils.js +27 -2
  98. package/dist/utils/consoleUtils.js.map +1 -1
  99. package/dist/utils/systemUtils.d.ts +17 -0
  100. package/dist/utils/systemUtils.js +17 -0
  101. package/dist/utils/systemUtils.js.map +1 -1
  102. package/package.json +5 -2
  103. package/schema/gsloth-config.schema.json +1216 -417
  104. package/dist/core/shell/allowlist.d.ts +0 -75
  105. package/dist/core/shell/allowlist.js +0 -187
  106. package/dist/core/shell/allowlist.js.map +0 -1
  107. package/dist/core/shell/judge.d.ts +0 -161
  108. package/dist/core/shell/judge.js +0 -261
  109. package/dist/core/shell/judge.js.map +0 -1
@@ -1,9 +1,18 @@
1
- import { getEffectiveDevToolsConfig, getShellJudgeSettings, isShellAllowlistEnabled, isShellAllowlistPersisted, isShellJudgeEnabled, } from '#src/config.js';
1
+ import { DEFAULT_APPROVAL_RUNG, describeGrantedBuiltInTools, isRatedRung, resolveApprovals, 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';
5
7
  import { normalizeCommand } from '#src/core/shell/normalize.js';
6
- import { judgeShellCommand, mapVerdictToAction, } from '#src/core/shell/judge.js';
8
+ import { ApprovalStopError, AttackHaltError, NonInteractiveEscalationError, } from '#src/core/shell/approvalStop.js';
9
+ import { applyDestructiveFloor, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
10
+ import { describeApprovalEntry, resolveApprovalRules, } from '#src/core/approvals/matcher.js';
11
+ import { createEffectiveToolAnnotationSource, trustedAnnotationHints, } from '#src/core/approvals/annotations.js';
12
+ import { approvalSubjectForToolName } from '#src/core/approvals/mcpSubjects.js';
13
+ import { toolCallHosts } from '#src/core/approvals/toolHost.js';
14
+ import { builtInToolAnnotations, mcpDeclaredAnnotationLookup, } from '#src/core/approvals/toolAnnotationSources.js';
15
+ import { resolveRaterModel } from '#src/core/shell/raterModel.js';
7
16
  import { env } from '#src/utils/systemUtils.js';
8
17
  import { getGslothConfigWritePath } from '#src/utils/fileUtils.js';
9
18
  import { SHELL_ALLOWLIST_FILE } from '#src/constants.js';
@@ -18,6 +27,16 @@ import { setToolDisplayConfig } from '#src/core/toolDisplay.js';
18
27
  * kept; they are redacted (GS2-47) by the crash snapshot writer before anything reaches disk.
19
28
  */
20
29
  const CRASH_TRANSCRIPT_TAIL_MESSAGES = 8;
30
+ /**
31
+ * A private copy of a rule entry, for handing to a display. `pattern` is the one field that can be
32
+ * an object (a `hint` pattern, §3.1), so it is copied too — a shallow spread alone would leave the
33
+ * displayed entry sharing the very object the matcher compares against.
34
+ */
35
+ function copyApprovalEntry(entry) {
36
+ if (entry.type === 'shell' || typeof entry.pattern === 'string')
37
+ return { ...entry };
38
+ return { ...entry, pattern: { ...entry.pattern } };
39
+ }
21
40
  /**
22
41
  * Agent simplifies interaction with LLM and reduces it to calling a few methods
23
42
  * {@link GthAgentRunner#init} and {@link GthAgentRunner#processMessages}.
@@ -46,30 +65,54 @@ export class GthAgentRunner {
46
65
  */
47
66
  lastRunStats = { tools: [] };
48
67
  /**
49
- * EXT-12 — runtime, session-scoped auto-approve flag driven by the `/auto-approve` (a.k.a.
50
- * `/yolo`) slash command. Because the shell tool stays gated (in `interruptOn`) in the
51
- * interactive `code` mode, this flag is consulted at the TOP of {@link decideToolApproval}:
52
- * when ON, a gated `run_shell_command` is auto-approved WITHOUT prompting for the rest of this
53
- * runner's life. Never persisted, but INITIALIZED at {@link init} from the static
54
- * `run_shell_command.yolo` config knob — so a config that pre-enables auto-approval still keeps the
55
- * tool gated and therefore toggleable (`/auto-approve off` restores the per-command prompt).
68
+ * CFG-27the runtime, session-scoped approvals posture, seeded at {@link init} from
69
+ * {@link resolveApprovals} and thereafter switchable for the session by `/approvals <rung>`.
70
+ * The shell tool stays gated (in `interruptOn`) at EVERY rung, so this is consulted at the top
71
+ * of {@link decideToolApproval} and a config that pre-selects `bypass` remains switchable back
72
+ * mid-session. Never persisted.
73
+ *
56
74
  * It does NOT disable the hardline floor — catastrophic commands are still refused at exec time
57
- * in `GthDevToolkit.executeCommand`.
75
+ * in `GthDevToolkit.executeCommand` under every rung.
76
+ */
77
+ sessionApprovals = {
78
+ rung: DEFAULT_APPROVAL_RUNG,
79
+ allow: [],
80
+ deny: [],
81
+ escalate: [],
82
+ };
83
+ /**
84
+ * CFG-26 — the model the AI rater rates with, when `approvals.rater.profile` names an identity
85
+ * profile. Resolved ONCE at {@link init} (never mid-turn) and handed to `rateShellCommand`;
86
+ * `undefined` means no profile is configured and the rater uses the session model.
87
+ */
88
+ raterModel;
89
+ /**
90
+ * EXT-66 — how many rating calls this session gave up on. Counted so the notice can say "3 times
91
+ * this session" rather than repeating an identical line, and so a silent drift toward
92
+ * escalate-everything has a number attached to it.
93
+ */
94
+ raterTimeouts = 0;
95
+ /**
96
+ * EXT-71 §3.1/§6 — what the escalation menu granted at run time, for the life of THIS runner
97
+ * instance: {@link ApprovalEntry} objects, never prefixes, and never anything from config (the
98
+ * declared lists are read-only input consulted straight from the posture). Instance-scoped so
99
+ * concurrent sessions (ACP / AG-UI multi-session) cannot stomp each other's approvals.
58
100
  */
59
- sessionYolo = false;
101
+ sessionGrants = new ApprovalGrantStore();
60
102
  /**
61
- * EXT-9 Tier-2 session allow-list approved command prefixes that auto-approve for the
62
- * life of THIS runner instance. Instance-scoped (not module-global) so concurrent
63
- * sessions (ACP / AG-UI multi-session) cannot stomp each other's approvals.
103
+ * CFG-27 §3 what the escalation menu's *always reject* choice adds at run time, in the same
104
+ * grammar ([[TUI-C26]] wires that writer; the store speaks it already). The entries DECLARED in
105
+ * `approvals.deny` are not here — they are read-only config input, matched from the posture — and
106
+ * both are handed to the same matcher, so a runtime refusal and a declared one are one list.
64
107
  */
65
- sessionAllowlist = new AllowlistStore();
108
+ denyGrants = new ApprovalGrantStore();
66
109
  /**
67
- * EXT-9 Tier-2 persisted (`always`) allow-list, loaded lazily on first use from
68
- * `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until the shell tool is gated
69
- * and the allow-list is enabled; null also when persistence is disabled by config.
110
+ * The persisted (`always`) grant store, loaded lazily on first use from
111
+ * `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until a gated call actually needs it, and
112
+ * null when the file cannot be loaded at all (in which case `always` grants degrade to session).
70
113
  */
71
- persistedAllowlist = null;
72
- persistedAllowlistLoaded = false;
114
+ persistedGrants = null;
115
+ persistedGrantsLoaded = false;
73
116
  /**
74
117
  * @param agentFactory Produces the {@link GthAgentInterface} the runner drives.
75
118
  * Defaults to the lean {@link GthLangChainAgent} (core). `@gaunt-sloth/agent`
@@ -91,28 +134,193 @@ export class GthAgentRunner {
91
134
  this.toolApprovalCallback = callback;
92
135
  }
93
136
  /**
94
- * EXT-12flip the runtime, session-scoped auto-approve flag (the `/auto-approve` /
95
- * `/yolo` slash command with no argument). When ON, gated `run_shell_command` calls
96
- * auto-approve without prompting for the rest of this session; the hardline floor still applies
97
- * at exec time. Returns the NEW state so the caller can render a notice. Session-scoped only —
98
- * nothing is written to config.
137
+ * CFG-27switch the session-scoped rung (`/approvals <rung>`). Idempotent; returns the NEW
138
+ * rung so the caller can render a notice. Session-scoped only nothing is written to config,
139
+ * and the declared allow/deny lists are unaffected (they are config input, not session state).
140
+ */
141
+ setSessionApprovalRung(rung) {
142
+ this.sessionApprovals = { ...this.sessionApprovals, rung };
143
+ return this.sessionApprovals.rung;
144
+ }
145
+ /** CFG-27 — the session's current approvals posture (rung + rater profile + declared lists). */
146
+ getSessionApprovals() {
147
+ return this.sessionApprovals;
148
+ }
149
+ /**
150
+ * CFG-26 — the allow-list sizes for the `/approvals` display: how many command prefixes the
151
+ * human has trusted this session, and how many are persisted in the project file.
152
+ *
153
+ * READ-ONLY BY CONSTRUCTION: it reports the persisted count only when the store has ALREADY
154
+ * been loaded (or persistence is on and it can be read), and never through a path that would
155
+ * CREATE the store as a side effect of showing a display — a status command must not mutate
156
+ * session state. `always: undefined` therefore means "not loaded / persistence off", which the
157
+ * caller renders as `—` rather than a misleading `0`.
158
+ */
159
+ getAllowlistCounts() {
160
+ const always = this.persistedGrantsLoaded
161
+ ? (this.persistedGrants?.size() ?? undefined)
162
+ : undefined;
163
+ // EXT-71 §3 — every list MUST be inspectable, and the declared entries are in force for this
164
+ // session exactly as the human's own grants are. They are counted alongside them rather than
165
+ // hidden, which is what the count meant before the declared lists stopped seeding the store.
166
+ const session = this.sessionGrants.size() + this.sessionApprovals.allow.length;
167
+ return { session, always };
168
+ }
169
+ /**
170
+ * CFG-27 — the session's deny entries for display: the declared `approvals.deny` entries
171
+ * (rendered one line each) followed by whatever the escalation menu's *always reject* added at
172
+ * run time. Both refuse a call, so both are shown.
173
+ */
174
+ getDenylist() {
175
+ return [...this.sessionApprovals.deny, ...this.denyGrants.entries()].map(describeApprovalEntry);
176
+ }
177
+ /**
178
+ * §3/§4.7.4 — **the grants themselves**, for an approvals view that shows *what* was granted,
179
+ * *when*, and *under which effective annotations*. The counterpart of {@link getAllowlistCounts},
180
+ * which answers only how many.
181
+ *
182
+ * The declared config lists are deliberately NOT here. They are something a human wrote and
183
+ * reviewed, they carry no `grantedAt` and no scope, and `getAllowlistCounts` already counts them
184
+ * alongside these; mixing them in would present a config line as something the session granted.
185
+ *
186
+ * **Read-only in both senses.** It never loads the persisted store — same rule as
187
+ * {@link getAllowlistCounts}: a display must not create the store in order to show it, so a
188
+ * session that has not yet needed the file lists its session grants alone. And every grant is
189
+ * **deep-copied on the way out**, because the stores hand back their live records: the copy on the
190
+ * way in is what makes a snapshot private to its grant, and handing the same object to a renderer
191
+ * would put what the gate matches against one property assignment away from any consumer.
192
+ */
193
+ getGrants() {
194
+ const held = [
195
+ ...this.sessionGrants.list(),
196
+ ...(this.persistedGrantsLoaded ? (this.persistedGrants?.list() ?? []) : []),
197
+ ];
198
+ const seen = new Set();
199
+ const grants = [];
200
+ for (const grant of held) {
201
+ // An `always` grant is written to BOTH stores, so identity de-duplication is what keeps it
202
+ // from being displayed twice. The same question `ApprovalGrantStore.add` asks.
203
+ const key = renderApprovalEntryObject(grant.entry);
204
+ if (seen.has(key))
205
+ continue;
206
+ seen.add(key);
207
+ grants.push({
208
+ ...grant,
209
+ entry: copyApprovalEntry(grant.entry),
210
+ ...(grant.annotations ? { annotations: { ...grant.annotations } } : {}),
211
+ });
212
+ }
213
+ return grants;
214
+ }
215
+ /**
216
+ * §4.7.1 — **which of each server's annotation hints this session believes**, for display.
217
+ *
218
+ * Every key either side names is listed: a configured `mcpServers` key with no policy of its own
219
+ * (which resolves through `defaults`), and a policy key naming a server the config does not have
220
+ * (which is what a typo looks like). Resolution is {@link trustedAnnotationHints}, the same
221
+ * function the gate derives effective annotations through, so the display cannot claim a
222
+ * relationship the gate does not act on.
99
223
  */
100
- toggleSessionYolo() {
101
- this.sessionYolo = !this.sessionYolo;
102
- return this.sessionYolo;
224
+ getMcpAnnotationTrust() {
225
+ const mcp = this.sessionApprovals.mcp;
226
+ const configured = new Set(this.configuredMcpServerKeys());
227
+ const named = Object.keys(mcp?.servers ?? {});
228
+ const keys = [...new Set([...configured, ...named])];
229
+ return {
230
+ defaults: [...(mcp?.defaults?.trustAnnotations ?? [])],
231
+ servers: keys.map((server) => ({
232
+ server,
233
+ trusted: [...trustedAnnotationHints(mcp, server)],
234
+ configured: configured.has(server),
235
+ })),
236
+ };
103
237
  }
104
238
  /**
105
- * EXT-12set the session-scoped auto-approve flag explicitly (the `/auto-approve on|off`
106
- * slash command). Idempotent; returns the NEW state so the caller can render a notice.
107
- * Session-scoped only nothing is written to config.
239
+ * §4.7.1**believe, or stop believing, specific hints from one server**, for the life of this
240
+ * session. The runtime half of `approvals.mcp.servers.<key>.trustAnnotations` (§9), so a user can
241
+ * do from the TUI what they can do in config.
242
+ *
243
+ * **Per hint, never per server.** `hints` names the hints this call moves and leaves every other
244
+ * hint of that server's exactly as it was, because believing a server's `readOnlyHint` while
245
+ * disbelieving its `openWorldHint` is a coherent position and the common one. A "trust this
246
+ * server" flag is the design §4.7.1 rejects.
247
+ *
248
+ * **The previous set is what was IN FORCE, resolved through `defaults`.** A server not named
249
+ * under `servers` inherits `defaults`, and naming it makes it state its relationship in full (§9)
250
+ * — so seeding from the empty set would mean that believing one more hint silently withdrew every
251
+ * hint `defaults` had granted, which is a weakening the user did not ask for and would invalidate
252
+ * their grants.
253
+ *
254
+ * **Session-scoped only.** Nothing is written to config: the declared block is read-only input
255
+ * (§9.1), exactly as the rung is.
256
+ *
257
+ * A trusted external annotation still never grants more than the same annotation grants one of
258
+ * our own built-ins — that holds in `core/approvals/annotations.ts` by construction, and this
259
+ * changes only which hints are read.
108
260
  */
109
- setSessionYolo(on) {
110
- this.sessionYolo = on;
111
- return this.sessionYolo;
261
+ setMcpAnnotationTrust(server, hints, believe) {
262
+ const mcp = this.sessionApprovals.mcp;
263
+ const before = trustedAnnotationHints(mcp, server);
264
+ const requested = new Set(hints);
265
+ const after = believe
266
+ ? TOOL_ANNOTATION_HINTS.filter((hint) => before.includes(hint) || requested.has(hint))
267
+ : TOOL_ANNOTATION_HINTS.filter((hint) => before.includes(hint) && !requested.has(hint));
268
+ const added = after.filter((hint) => !before.includes(hint));
269
+ const removed = before.filter((hint) => !after.includes(hint));
270
+ const nextMcp = {
271
+ ...mcp,
272
+ servers: {
273
+ ...mcp?.servers,
274
+ [server]: { ...mcp?.servers?.[server], trustAnnotations: after },
275
+ },
276
+ };
277
+ // A fresh posture object, and fresh nested ones above: the resolved posture may share its `mcp`
278
+ // block with the loaded config, and a session change must not rewrite what the user configured.
279
+ this.sessionApprovals = { ...this.sessionApprovals, mcp: nextMcp };
280
+ return {
281
+ server,
282
+ configured: this.configuredMcpServerKeys().includes(server),
283
+ trusted: [...after],
284
+ added,
285
+ removed,
286
+ weakening: removed.filter(trustWithdrawalWeakens),
287
+ invalidates: this.grantsWeakenedByCurrentTrust(server),
288
+ };
112
289
  }
113
- /** EXT-12 — current state of the runtime session-scoped auto-approve flag (see {@link toggleSessionYolo}). */
114
- isSessionYolo() {
115
- return this.sessionYolo;
290
+ /**
291
+ * §4.7.4 — which of this server's saved approvals the trust now in force weakens, for the notice
292
+ * that reports a trust change. **It predicts; it never removes.** The removal stays where Task
293
+ * 4 put it — at the call being decided — because that is the only moment the tool's declaration
294
+ * can be read for certain; here a server that is merely offline declares nothing and would read
295
+ * as having weakened everything.
296
+ *
297
+ * It compares through the same two functions the gate does: the effective-annotation source built
298
+ * from the posture as it stands *after* the change, and `annotationWeakenings`. A second
299
+ * comparison written for the display is how a warning comes to describe a rule the gate does not
300
+ * have.
301
+ */
302
+ grantsWeakenedByCurrentTrust(server) {
303
+ const source = this.effectiveToolAnnotationSource();
304
+ return this.getGrants()
305
+ .filter((grant) => grant.entry.type === 'mcpTool' &&
306
+ grant.entry.server === server &&
307
+ typeof grant.entry.pattern === 'string' &&
308
+ grant.annotations !== undefined)
309
+ .filter((grant) => {
310
+ // `EffectiveToolAnnotationSource` admits `undefined` for a source that genuinely cannot
311
+ // answer; `createEffectiveToolAnnotationSource` never returns it — a tool nothing has
312
+ // declared for resolves to the fail-closed constant, which for a grant made under anything
313
+ // softer reads as a weakening. The guard is therefore a type-level obligation and not a
314
+ // live branch: it discharges the union the contract declares, and nothing reaches it.
315
+ const current = source({
316
+ kind: 'mcpTool',
317
+ server,
318
+ name: grant.entry.pattern,
319
+ ...(grant.entry.host !== undefined ? { host: grant.entry.host } : {}),
320
+ });
321
+ return current !== undefined && annotationWeakenings(grant.annotations, current).length > 0;
322
+ })
323
+ .map((grant) => describeApprovalEntry(grant.entry));
116
324
  }
117
325
  /**
118
326
  * Init is split into a separate method. This may create a number of connections,
@@ -130,11 +338,31 @@ export class GthAgentRunner {
130
338
  // secret-literal collection walks INLINE config secrets (a pasted `apiKey`/`token` value), not
131
339
  // only env-derived ones. Both surfaces (plain observer + Ink TUI) render through this module.
132
340
  setToolDisplayConfig(configIn);
133
- // EXT-12 — seed the runtime auto-approve flag from the static `run_shell_command.yolo` config so a
134
- // config that pre-enables auto-approval starts ON, while the shell tool stays gated (see
135
- // GthDeepAgent) and therefore remains toggleable (`/auto-approve off`). Resolved per-command,
136
- // mirroring where the shell tool is actually emitted; no effect where the tool is ungated.
137
- this.sessionYolo = getEffectiveDevToolsConfig(configIn, command)?.shellYolo === true;
341
+ // CFG-27 — seed the session posture from config, so a config that pre-selects `bypass` starts
342
+ // there while the shell tool stays gated (see `resolveShellApprovalGate`) and therefore
343
+ // remains switchable (`/approvals write`). Resolved per-command, mirroring where the shell
344
+ // tool is actually emitted; no effect where the tool is ungated.
345
+ this.sessionApprovals = resolveApprovals(configIn, command);
346
+ // §3/§9.1 — the DECLARED lists are read-only config input, consulted through the EXT-71 rule
347
+ // matcher (`core/approvals/matcher.ts`) and NEVER copied into the runtime stores, which hold
348
+ // only what the escalation menu grants at run time. Both are handed to the same matcher, so
349
+ // there is one grammar and one comparison, not a config path and a runtime path.
350
+ this.denyGrants = new ApprovalGrantStore();
351
+ // CFG-26 — resolve the rater's own model when a profile is named, so the documented mitigation
352
+ // for a weak model ("point approvals.rater at a stronger one") actually takes effect.
353
+ //
354
+ // EAGERLY, here, rather than lazily at first use: `initConfig` re-runs discovery and prints
355
+ // "Activating profile: …", which mid-turn would write raw over the Ink TUI's managed frame,
356
+ // and a broken profile should fail at startup rather than three turns in. It deliberately does
357
+ // NOT catch — a named-but-unusable rater profile is an error, never a silent fallback to the
358
+ // session model (GS2-62).
359
+ //
360
+ // Loaded whenever a profile is NAMED, without a second "will the rater actually run?" gate:
361
+ // naming a rater profile at an unrated rung is a config the user can hold (they may switch to
362
+ // `auto-safe` mid-session with `/approvals`), and a broken profile should still fail loudly at
363
+ // startup rather than at the moment they switch.
364
+ const raterProfile = this.sessionApprovals.rater;
365
+ this.raterModel = raterProfile ? await resolveRaterModel(raterProfile) : undefined;
138
366
  // Initialize debug logging
139
367
  initDebugLogging(configIn.debugLog ?? false);
140
368
  debugLog(`Initializing GthAgentRunner with command: ${command || 'default'}`);
@@ -174,6 +402,11 @@ export class GthAgentRunner {
174
402
  result += await this.resolveToolInterrupts();
175
403
  }
176
404
  catch (streamError) {
405
+ // CFG-27 — an approvals STOP is not a stream failure: it is the gate deliberately
406
+ // ending the run, and its message IS the explanation the spec requires it to carry.
407
+ // Re-thrown unchanged (the outer catch does the same) so nothing buries it.
408
+ if (streamError instanceof ApprovalStopError)
409
+ throw streamError;
177
410
  // Handle streaming-specific errors
178
411
  debugLogError('Stream processing', streamError);
179
412
  throw new Error(`Stream processing failed: ${streamError instanceof Error ? streamError.message : String(streamError)}`);
@@ -213,7 +446,15 @@ export class GthAgentRunner {
213
446
  else {
214
447
  // Use non-streaming
215
448
  debugLog('Using non-streaming mode');
216
- const result = await this.agent.invoke(messages, this.runConfig);
449
+ let result = await this.agent.invoke(messages, this.runConfig);
450
+ // EXT-52 — the SAME interrupt drain the streaming branch does above. A gated
451
+ // `run_shell_command` suspends the graph, so `invoke` returns with the tool-calling
452
+ // AIMessage (empty content) as the last message and the command not yet run. Draining here
453
+ // — BEFORE the empty-response check — is what makes the approval prompt fire and the
454
+ // approved command's output reach the caller on `streamOutput: false`; without it the turn
455
+ // died with the misleading empty-response error, so the check may only see a genuinely
456
+ // empty turn.
457
+ result += await this.resolveToolInterrupts();
217
458
  debugLog(`Non-stream response length: ${result.length}`);
218
459
  if (result.trim().length === 0) {
219
460
  throw new Error('Model returned an empty response. Try again or switch to a more stable model.');
@@ -222,6 +463,12 @@ export class GthAgentRunner {
222
463
  }
223
464
  }
224
465
  catch (error) {
466
+ // CFG-27 §4.2/§6.2 — an approvals STOP is not an agent failure and must reach the user with
467
+ // its own words: the command, the rating and its reason are the whole point of it. Wrapping
468
+ // it as "Agent processing failed: …" would bury the explanation the spec requires it to
469
+ // carry, so it is re-thrown unchanged.
470
+ if (error instanceof ApprovalStopError)
471
+ throw error;
225
472
  // Handle agent invocation errors
226
473
  debugLogError('Agent processing', error);
227
474
  const originalMessage = error instanceof Error ? error.message : String(error);
@@ -251,7 +498,10 @@ export class GthAgentRunner {
251
498
  * streamed across all resume turns (empty when nothing was resumed).
252
499
  *
253
500
  * No-ops (returns '') when the agent does not support interrupts (`getPendingToolInterrupts`/
254
- * `streamResume` absent), so the lean agent and non-HITL configs are unaffected.
501
+ * `streamResume` absent) that is the only exemption. As of EXT-52 BOTH backends gate
502
+ * `run_shell_command` and expose the interrupt surface, so the lean (default) agent is now
503
+ * exactly the agent this loop serves; only an agent implementation without those methods
504
+ * (e.g. a test double) skips it.
255
505
  */
256
506
  async resolveToolInterrupts() {
257
507
  const agent = this.agent;
@@ -276,16 +526,106 @@ export class GthAgentRunner {
276
526
  return resumedText;
277
527
  }
278
528
  /**
279
- * Decide a single pending tool call (EXT-9 Tier-2). For the opt-in `run_shell_command`,
280
- * consult the scoped allow-list FIRST: if the command's classified prefix is already
281
- * approved (session or persisted `always`) and survives the safe-bin anti-widening
282
- * re-validation, auto-approve SILENTLY (no human prompt). Otherwise fall through to the
283
- * human callback; when the human grants `session`/`always` scope, record the command's
284
- * classified prefix into the matching store so future flag-variants stop re-prompting.
529
+ * EXT-71 §3.1/§3.2, EXT-70 §4.7.5 — the subject a pending tool call presents to the rule matcher.
530
+ *
531
+ * A gated `run_shell_command` is a **shell** subject and nothing else: it is matched by `shell`
532
+ * entries, against the command. It is deliberately NOT also offered as a `tool` subject named
533
+ * `run_shell_command`, which would create a second allow path to every shell command carrying a
534
+ * different §3.2 `rate` default and a match that never saw the command it was approving.
535
+ *
536
+ * **Everything else splits by provenance**, which is the distinction §4.7.1 rests on: `tool` is
537
+ * the TRUSTED provenance, read verbatim, so an MCP tool arriving as one would be asking the
538
+ * trusted path for a third party's annotations — a gate any server can opt itself out of. Every
539
+ * MCP-namespaced name therefore becomes an `mcpTool` subject carrying the user's own `mcpServers`
540
+ * key, and one whose server cannot be resolved stays an `mcpTool` subject under an unnameable
541
+ * server rather than falling back to `tool` (see `approvalSubjectForToolName`).
542
+ *
543
+ * **The host (§4.7.4)** is attached here, so the one subject the whole decision runs on carries
544
+ * it: the rule matcher treats a `host` on an entry as an additional exact-match condition, and a
545
+ * grant the menu writes records it. A call naming no single host has none, which fails toward a
546
+ * prompt at both sites.
547
+ *
548
+ * Widening which tools the gate actually suspends on is still [[EXT-30]]; this decides what a
549
+ * suspended call *is* whenever one arrives.
550
+ *
551
+ * @param hosts Every distinct host the call's arguments name ({@link toolCallHosts}).
552
+ */
553
+ approvalSubjectFor(tool, command, hosts) {
554
+ if (tool.name === SHELL_TOOL_NAME && command !== null)
555
+ return { kind: 'shell', command };
556
+ const subject = approvalSubjectForToolName(tool.name, this.configuredMcpServerKeys());
557
+ return hosts.length === 1 ? { ...subject, host: hosts[0] } : subject;
558
+ }
559
+ /**
560
+ * §4.7.5 — the user's own `mcpServers` keys, the only identity a server has here. Own enumerable
561
+ * keys via `Object.keys`, so nothing inherited can pose as a configured server.
562
+ */
563
+ configuredMcpServerKeys() {
564
+ const servers = this.config?.mcpServers;
565
+ return servers && typeof servers === 'object' ? Object.keys(servers) : [];
566
+ }
567
+ /**
568
+ * EXT-70 §4.7.1 — the source a `hint` entry reads a call's EFFECTIVE annotations through, built
569
+ * from the session's `approvals.mcp` block and the two declared-annotation lookups.
570
+ *
571
+ * Built per decision rather than cached at {@link init}, for two reasons that both bite: the
572
+ * agent registers its tools *inside* `agent.init()`, so an init-time snapshot would be empty; and
573
+ * a re-init re-resolves the tool list, which for MCP may hand back different declarations.
574
+ *
575
+ * The two lookups are deliberately different in kind. `builtIn` reads OUR OWN authored table and
576
+ * never the bound tool list — the bound list contains every server's tools, and a `builtIn`
577
+ * lookup over it would read a third party's declaration through the trusted-verbatim path.
578
+ * `mcp` reads what the servers declared, keyed by the registered tool name so the server key is
579
+ * never split apart and re-joined differently.
580
+ */
581
+ effectiveToolAnnotationSource() {
582
+ return createEffectiveToolAnnotationSource({
583
+ mcp: this.sessionApprovals.mcp,
584
+ declared: {
585
+ builtIn: builtInToolAnnotations,
586
+ mcp: mcpDeclaredAnnotationLookup(this.agent?.getDeclaredMcpToolAnnotations?.()),
587
+ },
588
+ });
589
+ }
590
+ /**
591
+ * Decide a single pending tool call. Spec order — **deny → bypass → escalate → allow → rater →
592
+ * human prompt**, with the hardline floor at exec time regardless. The two adjacencies that carry
593
+ * the design are that deny comes BEFORE `bypass` and escalate comes AFTER it:
594
+ *
595
+ * 1. **deny** (§3) — a declared entry or a runtime *always reject* grant is refused with no
596
+ * prompt and no rating call. It is consulted FIRST, and it is the one
597
+ * check that **still applies under `bypass`**: choosing `bypass` says *"stop asking me"*, not
598
+ * *"forget what I told you never to do"*. A deny entry MAY match a compound command, because a
599
+ * prohibition that catches something unresolvable errs in the direction that costs nothing.
600
+ * 2. **`bypass`** — the gate is off for this session; approve at scope `once`.
601
+ * 3. **escalate** (§3.2) — a declared entry always asks the human, whatever the rung would have
602
+ * done, **including outranking the automatic grants of `read-only` and `write`** and any allow
603
+ * entry that also matched. It goes straight to the human with **no rating call**, and it never
604
+ * enters the `full-auto` negotiation. It is **inert at `bypass`**, which is why it sits below
605
+ * the rung check: the rung chosen for this session wins, and a stop that must survive `bypass`
606
+ * is a deny entry and only that.
607
+ * 4. **allow** (§3, §3.2) — a declared entry or a grant the human made at an earlier prompt this
608
+ * session (or persisted), matched against the whole normalized command and only when that
609
+ * command statically resolves. An allow match settles the human's part: no prompt. Whether the rater
610
+ * still reviews the call is the entry's own `rate` (§3.2) — honored at the rater rungs and
611
+ * inert at the deterministic ones, so no entry can smuggle a model call into `read-only` or
612
+ * `write` — and a rated allow match is a TRIPWIRE, not a re-adjudication
613
+ * ({@link mapAllowMatchedVerdictToAction}).
614
+ * 5. **auto-rater** (`auto-safe` / `full-auto` only) — `safe` approves, `destructive` and
615
+ * `catastrophic` escalate, and `attack` HALTS the run ({@link AttackHaltError}). The other
616
+ * three rungs consult no model at all. At those same two rungs a **tool** call is instead
617
+ * floored deterministically by §4.7.3's open-world rule ({@link openWorldToolFloorReason} into
618
+ * {@link applyDestructiveFloor} — the one floor the shell path also reaches): a call whose
619
+ * effective `openWorldHint` is true is `destructive`, whatever its `readOnlyHint` says.
620
+ * 6. **human prompt** — the approval callback; when the human grants `session`/`always` scope,
621
+ * **that command** is recorded as an `exact` entry (§3.1/§6 — the menu never widens), so the
622
+ * same command stops re-prompting and a longer variant of it still asks.
285
623
  *
286
- * When no human callback is wired (non-TTY exec run) and nothing is allow-listed, reject —
287
- * never auto-approve. Non-shell tools (or any tool when the allow-list is disabled) skip the
288
- * allow-list and go straight to the human callback / default-reject, preserving prior behaviour.
624
+ * §6.2 — where no human can answer (CI, a one-shot run, a server), an escalation is **not** a
625
+ * rejection handed back to the model: it is an immediate non-zero exit
626
+ * ({@link NonInteractiveEscalationError}) carrying the command, the rating and its reason. No
627
+ * prompt, no waiting, and never a timeout into approval. Declaring commands in `approvals.allow`
628
+ * is the supported way to make a pipeline pass.
289
629
  *
290
630
  * Hardline catastrophic commands remain refused at exec time regardless of any approval here
291
631
  * (defense in depth in `GthDevToolkit.executeCommand`), so an allow-listed `rm -rf /` still
@@ -293,123 +633,448 @@ export class GthAgentRunner {
293
633
  */
294
634
  async decideToolApproval(tool) {
295
635
  const command = typeof tool.args?.command === 'string' ? tool.args.command : null;
296
- const isShellCommand = tool.name === 'run_shell_command' && command !== null;
297
- const allowlistApplies = isShellCommand && this.isShellAllowlistOn();
298
- // EXT-12 runtime session yolo (`/yolo`): when ON, auto-approve a gated shell command WITHOUT
299
- // prompting, judging, or persisting. Scope `once` so nothing is written to the allow-list (the
300
- // bypass is intentionally ephemeral and reversible). The hardline floor is NOT bypassed here —
301
- // it is enforced at exec time in GthDevToolkit.executeCommand regardless of this decision, so a
302
- // catastrophic command is still refused even under yolo.
303
- if (isShellCommand && this.sessionYolo) {
304
- return { type: 'approve', scope: 'once' };
636
+ const isShellCommand = tool.name === SHELL_TOOL_NAME && command !== null;
637
+ const approvals = this.sessionApprovals;
638
+ // ONE subject and ONE annotation source per decision, shared by the rule matcher and the
639
+ // §4.7.3 floor below. Building a second source for the floor would let a `hint` entry and the
640
+ // floor read different effective values for the same call the two-derivations-disagreeing
641
+ // failure `core/approvals/annotations.ts` exists to prevent.
642
+ // §4.7.4 the hosts this call names, read ONCE. The subject carries the single host where
643
+ // there is exactly one; the sticky-grant decision needs the count as well, because "named no
644
+ // host" and "named several" are the same absent `host` on the subject and are not the same
645
+ // question for the menu.
646
+ const hosts = tool.name === SHELL_TOOL_NAME && command !== null ? [] : toolCallHosts(tool.args);
647
+ const subject = this.approvalSubjectFor(tool, command, hosts);
648
+ const annotationSource = this.effectiveToolAnnotationSource();
649
+ // ONE read of that source per decision as well, so the §4.7.4 invalidation below, the §4.7.3
650
+ // floor and the snapshot a grant records are all the same set. A shell subject has none — a
651
+ // command carries no tool annotations.
652
+ const effective = subject.kind === 'shell' ? undefined : annotationSource(subject);
653
+ // §4.7.4 — **before any rule is resolved**, so a grant the tool has since weakened out from
654
+ // under cannot auto-approve the very call that revealed the weakening.
655
+ if (subject.kind !== 'shell' && effective)
656
+ this.invalidateWeakenedGrants(subject, effective);
657
+ // The declared lists AND the runtime grant stores, resolved most-restrictive-wins in ONE pass
658
+ // through the ONE comparison engine, so author order and the order the lists were concatenated
659
+ // in cannot change the outcome — and a grant the menu wrote is compared exactly as a line the
660
+ // user typed into their config is.
661
+ const rule = resolveApprovalRules(subject, this.approvalRuleLists(), {
662
+ // EXT-70 §4.7.1 — a `hint` entry reads EFFECTIVE annotations, which is where per-server,
663
+ // per-hint trust is applied. Without this the matcher falls back to the fail-closed source,
664
+ // where no tool is ever read-only and a `hint` entry could only ever describe the default.
665
+ annotations: annotationSource,
666
+ onNotice: (notice) => this.statusUpdate(notice.level, notice.message),
667
+ });
668
+ // (1) Deny — before everything, including `bypass`.
669
+ if (rule?.action === 'deny') {
670
+ return {
671
+ type: 'reject',
672
+ message: `Refused: your deny list forbids this call (matched "${describeApprovalEntry(rule.entry)}"). ` +
673
+ 'Remove the entry from approvals.deny if you want it to run.',
674
+ };
305
675
  }
306
- // Auto-approve from the allow-list without prompting. The allow-list ALWAYS wins over the
307
- // judge: a human-trusted prefix shouldn't pay for an LLM call on every variant.
308
- if (allowlistApplies && this.isApprovedByAllowlist(command)) {
309
- return { type: 'approve', scope: 'session' };
676
+ // (2) `bypass` (config or `/approvals bypass`): approve a gated shell command WITHOUT
677
+ // prompting or rating. Scope `once` so nothing is written to the allow-list (the bypass is
678
+ // intentionally ephemeral and reversible). The hardline floor is NOT bypassed here — it is
679
+ // enforced at exec time in GthDevToolkit.executeCommand regardless of this decision.
680
+ if (isShellCommand && approvals.rung === 'bypass') {
681
+ return { type: 'approve', scope: 'once' };
310
682
  }
311
- // EXT-10LLM-as-judge safety gate (default OFF). Runs BEFORE the human callback for a
312
- // `run_shell_command` not already allow-listed: auto-approve clearly-safe (fatigue reducer),
313
- // reject clearly-catastrophic (only when blockHigh), otherwise fall through to the human with
314
- // the verdict attached. When disabled this is a no-op and behaviour is exactly EXT-9.
683
+ // (3) Escalate §3.2 sends it straight to the human with no rating call, outranking any allow
684
+ // entry that also matched.
685
+ //
686
+ // The `bypass` term is deliberate and not redundant with the early return above. That return
687
+ // only covers a SHELL call, so without this term a non-shell subject would still carry an
688
+ // escalate match into the prompt at `bypass` — unreachable while `run_shell_command` is the
689
+ // only gated tool, but §2.5's rule is about the rung, not about which tool asked. Make the
690
+ // invariant true rather than incidentally true, so [[EXT-30]] widening the gate cannot quietly
691
+ // break it.
692
+ const escalatedBy = rule?.action === 'escalate' && approvals.rung !== 'bypass'
693
+ ? describeApprovalEntry(rule.entry)
694
+ : undefined;
695
+ // (4) Approve from the allow list without prompting. It ALWAYS wins over the rater — a
696
+ // human-trusted call shouldn't pay for an LLM call on every variant — but never over escalate.
697
+ const allowlistApplies = approvals.rung !== 'bypass' && escalatedBy === undefined;
315
698
  let safetyVerdict;
316
- if (isShellCommand && command !== null && this.isShellJudgeOn()) {
317
- const settings = getShellJudgeSettings(getEffectiveDevToolsConfig(this.config ?? undefined, this.command));
318
- const verdict = await judgeShellCommand(command, this.config, {
319
- home: env?.HOME,
320
- });
321
- const action = mapVerdictToAction(command, verdict, {
322
- autoApproveLow: settings.autoApproveLow,
323
- blockHigh: settings.blockHigh,
324
- });
325
- if (action === 'auto-approve') {
326
- // Scope `once`: judge approvals are NEVER persisted to the allow-list.
327
- return { type: 'approve', scope: 'once' };
699
+ if (allowlistApplies && rule?.action === 'allow') {
700
+ // §3.2 `rate` is honored at the rater rungs and INERT at the deterministic ones, so an
701
+ // entry can never smuggle a model call into `read-only` or `write`. A tool subject is not
702
+ // rated either: the rater's first implementation covers the shell only (§4.3, [[EXT-30]]).
703
+ if (!rule.rate || !isRatedRung(approvals.rung) || !isShellCommand || command === null) {
704
+ return { type: 'approve', scope: 'session' };
328
705
  }
329
- if (action === 'reject') {
330
- return {
331
- type: 'reject',
332
- message: `Safety judge blocked the command: ${verdict.reason}`,
333
- };
706
+ const verdict = await this.rateCommand(command, { allowMatched: true });
707
+ const tripwire = mapAllowMatchedVerdictToAction(verdict);
708
+ if (tripwire.action === 'approve')
709
+ return { type: 'approve', scope: 'session' };
710
+ if (tripwire.action === 'halt') {
711
+ // §3.2/§4.2 — `attack` halts exactly as it would have without the match. A standing human
712
+ // grant answers "may this run"; it does not answer "is this command's structure hostile".
713
+ throw new AttackHaltError(command, tripwire.verdict?.reason ?? '');
714
+ }
715
+ // `catastrophic` — the one outcome the tripwire escalates. Fall through to the human.
716
+ safetyVerdict = tripwire.verdict;
717
+ }
718
+ // (5) What the gate makes of the call itself, at the two rated rungs only. Skipped entirely
719
+ // for an escalate match (§3.2: the user pre-decided that a human answers, so a rating would
720
+ // decorate a mandatory prompt) and for a call the tripwire above already rated. The rung test
721
+ // is the SAME one `mapVerdictToAction` applies, so both arms below floor exactly where the
722
+ // shell does and nowhere else: `bypass` and the two deterministic rungs consult neither the
723
+ // rater nor a preflight, and at those rungs the human is asked regardless.
724
+ if (isRatedRung(approvals.rung) && escalatedBy === undefined && safetyVerdict === undefined) {
725
+ // **The SUBJECT is what splits the two arms, not a second reading of the tool name.**
726
+ // `approvalSubjectFor` returns `kind: 'shell'` under exactly the condition `isShellCommand`
727
+ // states, so branching on the discriminant says the same thing once instead of twice — and
728
+ // any future divergence in that function sends the call to the FLOOR (fail-closed) rather
729
+ // than silently past it. It is also what carries the command as a non-null string.
730
+ if (subject.kind === 'shell') {
731
+ // The auto-rater. `safe` is approved (the fatigue reducer), `destructive` and
732
+ // `catastrophic` fall through to the human with the verdict attached, and `attack` ends
733
+ // the run outright. §4.6's deterministic preflights are applied inside
734
+ // `mapVerdictToAction`, ahead of the `safe` check.
735
+ const verdict = await this.rateCommand(subject.command, { allowMatched: false });
736
+ const decision = mapVerdictToAction(subject.command, verdict, { rung: approvals.rung });
737
+ if (decision.action === 'approve') {
738
+ // Scope `once`: rater approvals are NEVER persisted to the allow-list.
739
+ return { type: 'approve', scope: 'once' };
740
+ }
741
+ if (decision.action === 'halt') {
742
+ // §4.2 — not a rejection the model can respond to. It ends the agent loop.
743
+ throw new AttackHaltError(subject.command, decision.verdict?.reason ?? '');
744
+ }
745
+ // Escalate: carry the verdict (the honest one — see mapVerdictToAction) to the human.
746
+ safetyVerdict = decision.verdict;
747
+ }
748
+ else {
749
+ // EXT-70 §4.7.2/§4.7.3 — a tool call whose EFFECTIVE `openWorldHint` is true is floored at
750
+ // `destructive`, through the SAME `applyDestructiveFloor` the shell path reaches via
751
+ // `mapVerdictToAction`. No rating call: §4.3's scope boundary keeps the rater on the shell
752
+ // until [[EXT-30]], and the floor is deterministic anyway — §4.6 states it as coming
753
+ // *before* any model call, so it does not wait for one.
754
+ //
755
+ // **This is the branch a malformed `run_shell_command` lands in**, and it is the one shape
756
+ // that reaches this floor under today's gate: a call with no `command` argument, or one
757
+ // that is not a string, has nothing to rate, so it presents as a `tool` subject — and
758
+ // `run_shell_command` carries no authored annotations, so its effective set is the
759
+ // fail-closed one and it floors. That is the right direction: a shell call whose command
760
+ // cannot even be read is not one anything can say something reassuring about.
761
+ //
762
+ // The annotations are the effective set (§4.7.1), read through the same source the `hint`
763
+ // matcher just used, so an untrusted server's `openWorldHint: false` has already collapsed
764
+ // to the fail-closed `true` and cannot buy its way past this.
765
+ //
766
+ // Reached only when no allow entry claimed the call: §4.6's fourth bullet makes an allow
767
+ // match lift this floor, and step (4) above has already returned in that case.
768
+ safetyVerdict = applyDestructiveFloor(safetyVerdict, openWorldToolFloorReason(effective));
334
769
  }
335
- // Escalate: carry the verdict to the human approval surface.
336
- safetyVerdict = verdict;
337
770
  }
338
771
  if (!this.toolApprovalCallback) {
339
- // No interactive handler (e.g. non-TTY exec run): reject rather than auto-approve.
340
- return {
341
- type: 'reject',
342
- message: 'Tool call rejected: no interactive approval handler available.',
343
- };
772
+ // §6.2 no one to ask. Exit non-zero with everything a person needs, rather than handing
773
+ // the model a rejection it would just work around.
774
+ throw new NonInteractiveEscalationError(command ?? tool.name, safetyVerdict?.outcome, safetyVerdict?.reason, escalatedBy);
344
775
  }
345
- // Surface the judge's verdict to the human prompt (if the judge escalated) without mutating
346
- // the original interrupt object the caller holds.
347
- const pending = safetyVerdict ? { ...tool, safetyVerdict } : tool;
776
+ // §4.2 **a `catastrophic` approval is NEVER sticky.** "The human may approve this one
777
+ // invocation, and only this one": no always-allow, and no session-scoped allow either. The
778
+ // surface withdraws the affordance ([[TUI-C26]] drops `always approve` from the menu for this
779
+ // outcome), but the allow-list WRITE is decided here, and §3 has the allow-list consulted
780
+ // *before* the rater — so one sticky grant would remove the command from rating permanently,
781
+ // and the next `terraform destroy` would never be rated at all. Clamping here means the policy
782
+ // does not depend on which surface asked, or on a surface that has not been built yet.
783
+ const catastrophic = safetyVerdict?.outcome === 'catastrophic';
784
+ // §6 — **the menu must display what it is about to store**, at the moment of the choice, on
785
+ // every surface. It is rendered from the very grant {@link recordApproval} will write, because a
786
+ // menu that describes a grant one way and stores it another is the drift this design cannot
787
+ // afford. Absent exactly where no sticky grant is available — a `catastrophic` outcome (§4.2
788
+ // withdraws the persistent grants for EVERY subject, not only the shell one), or a call nothing
789
+ // would remember — so the prompt never advertises a control that has already been withdrawn.
790
+ const grant = catastrophic ? undefined : this.stickyGrantFor(subject, effective, hosts);
791
+ const grantPreview = grant ? renderApprovalEntryObject(grant.entry) : undefined;
792
+ // §6 — the same grant in the words the menu's *always approve* control is written in, through
793
+ // the one-liner the §4.7.4 withdrawal notice also uses, so the two cannot describe one grant
794
+ // two ways. For a tool call this is where "the stored thing is the tool, not the arguments"
795
+ // becomes visible: it names the tool, its server and the host bound, and nothing else.
796
+ const grantSummary = grant ? describeApprovalEntry(grant.entry) : undefined;
797
+ // Surface the rater's verdict, the escalate entry that fired as provenance (§3.2), and what a
798
+ // sticky choice would store (§6) — without mutating the original interrupt object the caller
799
+ // holds.
800
+ const pending = safetyVerdict || escalatedBy || grantPreview
801
+ ? {
802
+ ...tool,
803
+ ...(safetyVerdict ? { safetyVerdict } : {}),
804
+ ...(escalatedBy ? { escalatedBy } : {}),
805
+ ...(grantPreview ? { grantPreview } : {}),
806
+ ...(grantSummary ? { grantSummary } : {}),
807
+ }
808
+ : tool;
348
809
  const decision = await this.toolApprovalCallback(pending);
349
- // Persist the human's scoped grant so future variants of the same operation skip the prompt.
350
- if (decision.type === 'approve' && allowlistApplies && command) {
351
- this.recordApproval(command, decision.scope ?? 'once');
810
+ // Record the human's scoped grant so the same call stops re-prompting.
811
+ if (decision.type === 'approve' && grant) {
812
+ this.recordApproval(grant, decision.scope ?? 'once');
352
813
  }
353
814
  return decision;
354
815
  }
355
- /** Whether the EXT-10 LLM-as-judge safety gate is enabled for the active command's config. */
356
- isShellJudgeOn() {
357
- if (!this.config)
358
- return false;
359
- const devTools = getEffectiveDevToolsConfig(this.config, this.command);
360
- return isShellJudgeEnabled(devTools);
361
- }
362
- /** Whether the EXT-9 Tier-2 allow-list is enabled for the active command's devTools config. */
363
- isShellAllowlistOn() {
364
- const devTools = getEffectiveDevToolsConfig(this.config ?? undefined, this.command);
365
- return isShellAllowlistEnabled(devTools);
366
- }
367
- /**
368
- * Lazily load (once per instance) the persisted `always` allow-list, unless persistence is
369
- * disabled by config. Returns null when persistence is off so `always` grants behave as
370
- * `session` (in-memory only).
371
- */
372
- getPersistedAllowlist() {
373
- if (this.persistedAllowlistLoaded)
374
- return this.persistedAllowlist;
375
- this.persistedAllowlistLoaded = true;
376
- const devTools = getEffectiveDevToolsConfig(this.config ?? undefined, this.command);
377
- if (!isShellAllowlistPersisted(devTools)) {
378
- this.persistedAllowlist = null;
379
- return null;
816
+ /**
817
+ * One rating call, with EXT-66's timeout reporting attached. Extracted so the §3.2 tripwire (a
818
+ * rated allow match) and the ordinary rater path cannot drift apart in WHAT they hand the rater —
819
+ * only in what they do with the answer.
820
+ */
821
+ async rateCommand(command, opts) {
822
+ const approvals = this.sessionApprovals;
823
+ const verdict = await rateShellCommand(command, this.config, {
824
+ home: env?.HOME,
825
+ // The profile's model when one is configured; undefined lets rateShellCommand use the
826
+ // session model. `init` throws rather than leaving this undefined for a NAMED profile, so
827
+ // a configured profile can never silently degrade to the session model here.
828
+ model: this.raterModel,
829
+ // EXT-58 (§4.4) the already-granted built-ins of the CURRENT rung, so a non-`safe`
830
+ // outcome can name one the model could call for free instead. Computed per rating rather
831
+ // than cached at init, because `/approvals <rung>` moves the rung mid-session and a stale
832
+ // list would offer a tool that is no longer granted.
833
+ grantedTools: this.getGrantedBuiltInTools(),
834
+ // EXT-66 — the user-owned budget for ONE rating call, `undefined` when unset so
835
+ // rateShellCommand applies RATER_DEFAULT_TIMEOUT_MS. 30s is a hosted-model number and a
836
+ // local rater is knowably slower; without this a local `full-auto` session drifts toward
837
+ // escalating everything, which is the failure the rung exists to prevent.
838
+ timeoutMs: approvals.raterTimeoutMs,
839
+ });
840
+ // EXT-66 — a timeout is the gate giving up, not a judgement, and the two were previously
841
+ // indistinguishable in the action column. Say it once per occurrence: the only symptom
842
+ // otherwise is the gate becoming mysteriously more talkative, which reads as the rater
843
+ // working rather than as the rater never being heard from.
844
+ if (isRaterTimeout(verdict)) {
845
+ this.raterTimeouts += 1;
846
+ this.statusUpdate(StatusLevel.WARNING, `The command safety rater did not answer in time (${approvals.raterTimeoutMs ?? RATER_DEFAULT_TIMEOUT_MS}ms), so this command ` +
847
+ // §3.2 — on an allow match the rating is a tripwire, so a timeout does not escalate: the
848
+ // human's standing grant still stands and the call runs. Saying "escalated" there would
849
+ // be simply false, and a notice that misreports the action it accompanies is worse than
850
+ // none.
851
+ (opts.allowMatched
852
+ ? 'ran on its approvals.allow match alone, without the rating that entry asked for'
853
+ : 'was escalated without being rated') +
854
+ (this.raterTimeouts > 1 ? ` — ${this.raterTimeouts} times this session` : '') +
855
+ '. Raise approvals.raterTimeoutMs if the rater is a local model.');
380
856
  }
857
+ return verdict;
858
+ }
859
+ /**
860
+ * EXT-58 (§4.3/§4.4) — the built-in tools already granted at the session's CURRENT rung, as
861
+ * names plus one-line locally-authored descriptions, for the rater prompt.
862
+ *
863
+ * Two filters make this safe to place outside the rater's fenced untrusted block:
864
+ * - the names come from what the agent actually registered
865
+ * ({@link GthAgentInterface.getRegisteredToolNames}), so the rater can only ever offer a tool
866
+ * this session has;
867
+ * - the descriptions come from core's own `BUILT_IN_TOOL_SUMMARIES` table, so no MCP, custom or
868
+ * A2A tool's own (attacker-influenceable) description can reach the prompt.
869
+ *
870
+ * Empty when the agent does not expose its tools — the rater then gets no list and, per the
871
+ * prompt, offers nothing.
872
+ */
873
+ getGrantedBuiltInTools() {
874
+ const registered = this.agent?.getRegisteredToolNames?.() ?? [];
875
+ if (registered.length === 0)
876
+ return [];
877
+ // The gated set is resolved from the SAME shared policy both backends wire their interrupt
878
+ // from, so "granted" here means exactly what it means at tool-registration time (§4.5).
879
+ const { gateShell } = resolveShellApprovalGate(this.config ?? undefined, this.command);
880
+ const gatedTools = gateShell ? [SHELL_TOOL_NAME] : [];
881
+ return describeGrantedBuiltInTools(registered, this.sessionApprovals.rung, gatedTools);
882
+ }
883
+ /**
884
+ * §3/§3.3 — the three rule lists this session decides by: the DECLARED entries from config
885
+ * (read-only input) concatenated with the runtime grants the escalation menu made. One set of
886
+ * lists, handed to the one comparison engine; the concatenation cannot change any outcome
887
+ * because `resolveApprovalRules` consults every deny entry before any escalate entry and every
888
+ * escalate entry before any allow entry.
889
+ *
890
+ * The persisted store is loaded here rather than at {@link init} — lazily, once per instance, and
891
+ * NEVER at `bypass`, where the allow list is moot and a session that has switched the gate off
892
+ * should not be reading or rewriting the project's grant file.
893
+ */
894
+ approvalRuleLists() {
895
+ const approvals = this.sessionApprovals;
896
+ const persisted = approvals.rung === 'bypass' ? null : this.getPersistedGrants();
897
+ return {
898
+ deny: [...approvals.deny, ...this.denyGrants.entries()],
899
+ escalate: approvals.escalate,
900
+ allow: [...approvals.allow, ...this.sessionGrants.entries(), ...(persisted?.entries() ?? [])],
901
+ };
902
+ }
903
+ /**
904
+ * Lazily load (once per instance) the persisted `always` grant store.
905
+ *
906
+ * CFG-27 removed the `persistAllowlist` switch: §3 makes persistence a per-decision choice in
907
+ * the escalation menu (`approve` forgets, `always approve` persists), and a global "never
908
+ * persist" setting would only duplicate a keystroke. Returns null when the store cannot be
909
+ * loaded at all, in which case `always` grants degrade to `session` (in-memory only).
910
+ *
911
+ * The v1→v2 migration notice is routed to `statusUpdate` from here, which is the only place that
912
+ * knows how to reach the user.
913
+ */
914
+ getPersistedGrants() {
915
+ if (this.persistedGrantsLoaded)
916
+ return this.persistedGrants;
917
+ this.persistedGrantsLoaded = true;
381
918
  try {
382
919
  const filePath = getGslothConfigWritePath(SHELL_ALLOWLIST_FILE);
383
- this.persistedAllowlist = new PersistedAllowlist(filePath);
920
+ this.persistedGrants = new PersistedApprovalGrants(filePath, {
921
+ onNotice: (notice) => this.statusUpdate(notice.level, notice.message),
922
+ });
384
923
  }
385
924
  catch (e) {
386
925
  // Path/IO failure → behave as no persisted store (still safe: just prompts more).
387
- debugLogError('Loading persisted shell allow-list', e);
388
- this.persistedAllowlist = null;
926
+ debugLogError('Loading persisted shell approvals', e);
927
+ this.persistedGrants = null;
389
928
  }
390
- return this.persistedAllowlist;
929
+ return this.persistedGrants;
391
930
  }
392
- /** Check the command against the session + persisted stores (with anti-widening re-validation). */
393
- isApprovedByAllowlist(command) {
394
- return matchesApproval(command, {
395
- session: this.sessionAllowlist,
396
- always: this.getPersistedAllowlist() ?? undefined,
397
- });
931
+ /**
932
+ * §3.1/§4.7.4/§6 — **the grant a sticky choice would write for this call**, or `undefined` when
933
+ * none is on offer. The one place that question is answered, so the menu's *this is what will be
934
+ * stored* line (§6) and the store can never disagree.
935
+ *
936
+ * - **A shell call** records the command itself as an `exact` entry (§3.1) — never a prefix,
937
+ * never a pattern. One that does not statically resolve (composition, substitution,
938
+ * redirection) is not on offer: no allow entry of any matcher matches such a command, so the
939
+ * entry would be inert, and an inert entry sitting in a list §3 requires to be inspectable
940
+ * tells the user something is in force when nothing is.
941
+ * - **A tool call** records identity — the tool, its server, and the host where the call carries
942
+ * one (§4.7.4, {@link toolGrantEntry}) — never arguments, which would produce a grant that
943
+ * never matches twice. A call naming no host records the tool alone, which is §6's own example
944
+ * (*always approve `mcp__jira__create_issue`*, where no host is involved); what keeps that from
945
+ * being unbounded is §3.2's default that a tool entry is still `rate: true`, so the rater goes
946
+ * on seeing every call's full arguments.
947
+ *
948
+ * Four cases have **no grant on offer at all**, each fail-closed:
949
+ *
950
+ * 1. **`bypass`** — the gate is off for this session and nothing is remembered from it.
951
+ * 2. **`run_shell_command` arriving as a tool subject.** That is what a shell call with no
952
+ * readable `command` argument presents as, and it names no host, so without this it would take
953
+ * the tool-only arm and write a `{"type":"tool","pattern":"run_shell_command"}` grant that
954
+ * auto-approves every future call whose command cannot even be read. This exclusion is what
955
+ * stops that, not a side effect of anything else, and it must survive [[EXT-30]] widening the
956
+ * gate.
957
+ * 3. **A call naming more than one distinct host.** The grammar has no entry for it. `host` is a
958
+ * single optional string on every tool arm of `approvalEntrySchema`, and every arm is a
959
+ * `z.strictObject`, so recording the *set* is not a policy this code may choose — a `hosts`
960
+ * array is an unrecognized-key error, and writing one would be a §3.1 grammar change. Of the
961
+ * two entries that would parse, the host-bound one displays a bound the grant does not have,
962
+ * which §6 forbids (the menu shows exactly what will be stored). And a grammar that did record
963
+ * the set, matching only when all of it recurred, would fail §4.7.4's opening test anyway: a
964
+ * tool whose host set varies per call would get a grant that never matches a second time — not
965
+ * a narrower grant, the useless one §4.7.4 rejects by name.
966
+ *
967
+ * **What this arm does not claim.** It is not a narrowing. A hostless entry imposes no host
968
+ * condition at all (`resolveApprovalRules`), so the tool-only grant that any host-less call to
969
+ * the same tool produces already auto-approves a multi-host one. Refusing here withholds a
970
+ * grant; it does not close a hole, and the reason to keep it is the grammar above rather than
971
+ * any breadth it prevents. Asserted, so this cannot drift back into a claim the system does not
972
+ * support.
973
+ * 4. **An MCP call whose server could not be resolved** ({@link toolGrantEntry} returns `null`) —
974
+ * a call nobody can attribute is not one anything can remember.
975
+ */
976
+ stickyGrantFor(subject, effective, hosts) {
977
+ if (this.sessionApprovals.rung === 'bypass')
978
+ return undefined;
979
+ if (subject.kind === 'shell') {
980
+ if (classifyCommand(subject.command, normalizeCommand) === null)
981
+ return undefined;
982
+ return { entry: shellGrantEntry(subject.command) };
983
+ }
984
+ if (subject.name === SHELL_TOOL_NAME)
985
+ return undefined;
986
+ // A snapshot is what invalidation compares against, so a grant with no readable effective set
987
+ // is a grant nothing could ever invalidate.
988
+ if (!effective)
989
+ return undefined;
990
+ if (hosts.length > 1)
991
+ return undefined;
992
+ const entry = toolGrantEntry(subject);
993
+ if (!entry)
994
+ return undefined;
995
+ // §4.7.4 — the effective set the human approved this tool AS. `annotationWeakenings` compares a
996
+ // later one against it, and the store copies it so the record is private to this grant.
997
+ return { entry, annotations: effective };
398
998
  }
399
999
  /**
400
- * Record a human-granted approval at the given scope. `once` persists nothing. `session`
401
- * adds the classified prefix to the in-memory store. `always` additionally persists it (or
402
- * falls back to session-only when persistence is disabled).
1000
+ * §3.1/§6 — record a human-granted approval at the given scope. `once` remembers nothing.
1001
+ * `session` adds the entry to the in-memory store; `always` additionally persists it (falling
1002
+ * back to session-only when the file cannot be written).
1003
+ *
1004
+ * What is recorded was decided by {@link stickyGrantFor} and shown to the human before they
1005
+ * answered; this only stamps it with when and at what scope.
403
1006
  */
404
- recordApproval(command, scope) {
1007
+ recordApproval(grant, scope) {
405
1008
  if (scope === 'once')
406
1009
  return;
407
- const classification = classifyCommand(command, normalizeCommand);
408
- if (!classification)
409
- return; // unclassifiable (composition/redirection) → never remember.
410
- this.sessionAllowlist.add(classification.prefix);
1010
+ const grantScope = scope;
1011
+ const record = {
1012
+ ...grant,
1013
+ grantedAt: new Date().toISOString(),
1014
+ scope: grantScope,
1015
+ };
1016
+ this.sessionGrants.add(record);
411
1017
  if (scope === 'always') {
412
- this.getPersistedAllowlist()?.add(classification.prefix);
1018
+ this.getPersistedGrants()?.add(record);
1019
+ }
1020
+ }
1021
+ /**
1022
+ * §4.7.4 — **drop a tool grant the tool has since weakened out from under, with a notice naming
1023
+ * the tool, the server and the hint that moved.**
1024
+ *
1025
+ * The human approved a tool *as annotated*; a tool that re-annotates itself into a more dangerous
1026
+ * shape is a different proposition wearing the same name, so the grant is invalidated and the next
1027
+ * call prompts again. Only a **trusted** server can produce a weakening — an untrusted server's
1028
+ * effective set is the constant fail-closed default (§4.7.1) and cannot move — which is exactly
1029
+ * where it matters, since the trusted server is the one whose rug-pull would otherwise ride an
1030
+ * existing grant.
1031
+ *
1032
+ * **Scoped to the call being decided, never a sweep of the store.** A sweep would read every held
1033
+ * grant against a source that can only answer for the tools registered right now, so a server that
1034
+ * happened to be offline would read as having weakened everything it ever declared — and the
1035
+ * grants would be deleted for it.
1036
+ *
1037
+ * **The scope is every grant that could auto-approve THIS call, which is at most two.** A grant
1038
+ * with no `host` imposes no host condition, so it matches a call that carries one; looking up only
1039
+ * the entry this call would grant (`host` included) would miss the tool-only grant that is about
1040
+ * to auto-approve it, and the weakening would ride straight through — the exact failure §4.7.4
1041
+ * exists to stop. The host-bound entry of a DIFFERENT host is deliberately not a candidate: it
1042
+ * does not match this call either, so this call's annotations say nothing about it.
1043
+ *
1044
+ * **Only allow-side grants.** A weakening makes a tool more dangerous, so dropping an *always
1045
+ * reject* over one would be the unsafe direction: the reason to withdraw an approval is the reason
1046
+ * to keep a refusal.
1047
+ */
1048
+ invalidateWeakenedGrants(subject, effective) {
1049
+ const candidates = [
1050
+ toolGrantEntry(subject),
1051
+ ...(subject.host !== undefined ? [toolGrantEntry({ ...subject, host: undefined })] : []),
1052
+ ].filter((entry) => entry !== null);
1053
+ if (candidates.length === 0)
1054
+ return;
1055
+ // Never at `bypass`, for the same reason `approvalRuleLists` does not read the file there: a
1056
+ // session that has switched the gate off should not be rewriting the project's grant file.
1057
+ const persisted = this.sessionApprovals.rung === 'bypass' ? null : this.getPersistedGrants();
1058
+ for (const entry of candidates) {
1059
+ // The session store wins, and a session grant with no snapshot therefore hides a persisted one
1060
+ // that has one. Safe only because all three of these hold, and each is a premise a later change
1061
+ // could break silently: (1) every tool grant this runner writes carries a snapshot — a call with
1062
+ // no readable effective set is refused a grant at all ({@link stickyGrantFor}); (2) a `shell`
1063
+ // subject, the one kind whose grant has no snapshot by design, never reaches this method; and
1064
+ // (3) a persisted grant already in force auto-approves the call, so no prompt happens and no
1065
+ // session grant is written over it. Break any one of them and this line starts skipping a
1066
+ // weakening it should have caught — check both stores then, rather than the first that answers.
1067
+ const held = this.sessionGrants.find(entry) ?? persisted?.find(entry);
1068
+ if (!held?.annotations)
1069
+ continue;
1070
+ const weakened = annotationWeakenings(held.annotations, effective);
1071
+ if (weakened.length === 0)
1072
+ continue;
1073
+ // Removed rather than skipped: the stores de-duplicate by entry identity, so a grant left in
1074
+ // place would silently swallow the human's re-approval of the same tool.
1075
+ this.sessionGrants.remove(entry);
1076
+ persisted?.remove(entry);
1077
+ this.statusUpdate(StatusLevel.WARNING, describeWeakenedGrant(entry, weakened, held.annotations, effective));
413
1078
  }
414
1079
  }
415
1080
  /**
@@ -427,7 +1092,7 @@ export class GthAgentRunner {
427
1092
  * leaves the graph suspended on a `humanInTheLoopMiddleware` interrupt rather than
428
1093
  * completing. This is the event-stream counterpart to the readline path's
429
1094
  * {@link resolveToolInterrupts}: it drains any pending interrupts through
430
- * {@link decideToolApproval} (allow-list → judge → bridged human prompt), resumes via
1095
+ * {@link decideToolApproval} (bypass → allow-list → rater → bridged human prompt), resumes via
431
1096
  * `streamWithEventsResume({ decisions })`, and loops until the graph completes with no
432
1097
  * pending interrupts — so the executed command's output renders into the TUI. Without
433
1098
  * this the TUI silently finalized an empty turn (approval gate was dead code on the
@@ -451,15 +1116,18 @@ export class GthAgentRunner {
451
1116
  * resolve any tool-approval interrupts it suspended on, yielding the resumed run's typed
452
1117
  * {@link AgentStreamEvent}s so the renderer (the Ink TUI) shows the executed command's
453
1118
  * output. Each pending tool call is consulted via {@link decideToolApproval} — the SAME
454
- * three-layer gate the readline path uses (allow-list auto-approve → EXT-10 judge
1119
+ * gate the readline path uses (bypass → allow-list approve → CFG-26 AI rater
455
1120
  * bridged human callback, defaulting to REJECT when no handler is wired) — and the
456
1121
  * collected decisions are sent back via `streamWithEventsResume` as a LangChain HITL
457
1122
  * resume (`{ decisions }`). Because a resumed run can suspend again on the next gated
458
1123
  * tool call, this loops until the graph completes with no pending interrupts.
459
1124
  *
460
1125
  * No-ops (yields nothing) when the agent does not support interrupts
461
- * (`getPendingToolInterrupts`/`streamWithEventsResume` absent), so the lean agent and
462
- * non-HITL configs are unaffected. Aborts (`signal`) propagate through the resumed stream.
1126
+ * (`getPendingToolInterrupts`/`streamWithEventsResume` absent) that is the only exemption.
1127
+ * As of EXT-52 BOTH backends gate `run_shell_command` and expose the interrupt surface, so the
1128
+ * lean (default) agent is now exactly the agent this loop serves; only an agent implementation
1129
+ * without those methods (e.g. a test double) skips it. Aborts (`signal`) propagate through the
1130
+ * resumed stream.
463
1131
  */
464
1132
  async *resolveToolInterruptsWithEvents(signal) {
465
1133
  const agent = this.agent;