@gaunt-sloth/core 2.0.0-beta.0 → 2.0.0-beta.2

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 (90) hide show
  1. package/LICENSE +6 -6
  2. package/dist/config/loader.d.ts +13 -0
  3. package/dist/config/loader.js +55 -11
  4. package/dist/config/loader.js.map +1 -1
  5. package/dist/config/schema.d.ts +10 -0
  6. package/dist/config/schema.js +46 -24
  7. package/dist/config/schema.js.map +1 -1
  8. package/dist/config/shell-policy.d.ts +142 -1
  9. package/dist/config/shell-policy.js +114 -7
  10. package/dist/config/shell-policy.js.map +1 -1
  11. package/dist/config/types.d.ts +19 -0
  12. package/dist/config/types.js.map +1 -1
  13. package/dist/constants.d.ts +10 -0
  14. package/dist/constants.js +10 -0
  15. package/dist/constants.js.map +1 -1
  16. package/dist/core/GthAbstractAgent.d.ts +31 -0
  17. package/dist/core/GthAbstractAgent.js +159 -4
  18. package/dist/core/GthAbstractAgent.js.map +1 -1
  19. package/dist/core/GthAgentRunner.d.ts +314 -18
  20. package/dist/core/GthAgentRunner.js +1032 -101
  21. package/dist/core/GthAgentRunner.js.map +1 -1
  22. package/dist/core/approvals/approvalRequest.d.ts +247 -0
  23. package/dist/core/approvals/approvalRequest.js +315 -0
  24. package/dist/core/approvals/approvalRequest.js.map +1 -0
  25. package/dist/core/approvals/grants.d.ts +165 -9
  26. package/dist/core/approvals/grants.js +702 -57
  27. package/dist/core/approvals/grants.js.map +1 -1
  28. package/dist/core/plainToolIndication.d.ts +11 -1
  29. package/dist/core/plainToolIndication.js +25 -10
  30. package/dist/core/plainToolIndication.js.map +1 -1
  31. package/dist/core/shell/abstention.d.ts +1 -1
  32. package/dist/core/shell/abstention.js +65 -11
  33. package/dist/core/shell/abstention.js.map +1 -1
  34. package/dist/core/shell/alignment.d.ts +491 -0
  35. package/dist/core/shell/alignment.js +687 -0
  36. package/dist/core/shell/alignment.js.map +1 -0
  37. package/dist/core/shell/approvalCapture.d.ts +53 -41
  38. package/dist/core/shell/approvalCapture.js +0 -42
  39. package/dist/core/shell/approvalCapture.js.map +1 -1
  40. package/dist/core/shell/approvalStop.d.ts +121 -3
  41. package/dist/core/shell/approvalStop.js +153 -90
  42. package/dist/core/shell/approvalStop.js.map +1 -1
  43. package/dist/core/shell/hardline.d.ts +26 -4
  44. package/dist/core/shell/hardline.js +321 -16
  45. package/dist/core/shell/hardline.js.map +1 -1
  46. package/dist/core/shell/negotiation.d.ts +352 -63
  47. package/dist/core/shell/negotiation.js +295 -123
  48. package/dist/core/shell/negotiation.js.map +1 -1
  49. package/dist/core/shell/openWorld.d.ts +135 -0
  50. package/dist/core/shell/openWorld.js +572 -35
  51. package/dist/core/shell/openWorld.js.map +1 -1
  52. package/dist/core/shell/provenance.d.ts +91 -0
  53. package/dist/core/shell/provenance.js +136 -0
  54. package/dist/core/shell/provenance.js.map +1 -0
  55. package/dist/core/shell/rater.d.ts +394 -163
  56. package/dist/core/shell/rater.js +536 -238
  57. package/dist/core/shell/rater.js.map +1 -1
  58. package/dist/core/shell/raterHealth.d.ts +101 -0
  59. package/dist/core/shell/raterHealth.js +121 -0
  60. package/dist/core/shell/raterHealth.js.map +1 -0
  61. package/dist/core/shell/raterModel.d.ts +17 -8
  62. package/dist/core/shell/raterModel.js +11 -8
  63. package/dist/core/shell/raterModel.js.map +1 -1
  64. package/dist/core/shell/rejection.d.ts +20 -5
  65. package/dist/core/shell/rejection.js +18 -4
  66. package/dist/core/shell/rejection.js.map +1 -1
  67. package/dist/core/toolDisplay.d.ts +42 -0
  68. package/dist/core/toolDisplay.js +31 -0
  69. package/dist/core/toolDisplay.js.map +1 -1
  70. package/dist/core/types.d.ts +134 -16
  71. package/dist/core/types.js.map +1 -1
  72. package/dist/providers/configurationPassthrough.d.ts +36 -6
  73. package/dist/providers/configurationPassthrough.js +40 -28
  74. package/dist/providers/configurationPassthrough.js.map +1 -1
  75. package/dist/providers/openrouter.js +36 -6
  76. package/dist/providers/openrouter.js.map +1 -1
  77. package/dist/providers/vertexai.js +21 -0
  78. package/dist/providers/vertexai.js.map +1 -1
  79. package/dist/utils/binaryOutputUtils.js +103 -21
  80. package/dist/utils/binaryOutputUtils.js.map +1 -1
  81. package/dist/utils/fileUtils.d.ts +7 -1
  82. package/dist/utils/fileUtils.js +17 -3
  83. package/dist/utils/fileUtils.js.map +1 -1
  84. package/dist/utils/globalConfigUtils.d.ts +5 -2
  85. package/dist/utils/globalConfigUtils.js +14 -3
  86. package/dist/utils/globalConfigUtils.js.map +1 -1
  87. package/dist/utils/vertexaiUtils.js +61 -3
  88. package/dist/utils/vertexaiUtils.js.map +1 -1
  89. package/package.json +4 -4
  90. package/schema/gsloth-config.schema.json +34 -0
@@ -1,4 +1,4 @@
1
- import { DEFAULT_APPROVAL_RUNG, describeGrantedBuiltInTools, isNegotiatingRung, isRatedRung, isToolGatedAtRung, resolveApprovals, resolveGatedToolNames, resolveShellApprovalGate, SHELL_TOOL_NAME, TOOL_ANNOTATION_HINTS, } from '#src/config.js';
1
+ import { APPROVAL_RUNG_LABELS, DEFAULT_APPROVAL_RUNG, describeGrantedBuiltInTools, isNegotiatingRung, isRatedRung, commandCarriesUserProvenance, isToolGatedAtRung, resolveApprovals, resolveGatedToolNames, resolveShellApprovalGate, SHELL_TOOL_NAME, TOOL_ANNOTATION_HINTS, } from '#src/config.js';
2
2
  import { StatusLevel, } from '#src/core/types.js';
3
3
  import { GthLangChainAgent } from '#src/core/GthLangChainAgent.js';
4
4
  import { annotationWeakenings, ApprovalGrantStore, describeWeakenedGrant, PersistedApprovalGrants, shellGrantEntry, toolGrantEntry, trustWithdrawalWeakens, } from '#src/core/approvals/grants.js';
@@ -7,10 +7,15 @@ import { classifyCommand } from '#src/core/shell/arity.js';
7
7
  import { describeAbstention } from '#src/core/shell/abstention.js';
8
8
  import { normalizeCommand } from '#src/core/shell/normalize.js';
9
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';
10
+ import { applyDestructiveFloor, effectivePreflightFloorFinding, isBelowDestructiveFloor, isNegotiableCall, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, preflightFloorFinding, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
11
+ import { RaterHealth } from '#src/core/shell/raterHealth.js';
12
+ import { alignmentApprovalNotice, isAlignmentFailClosed, runAlignmentCheck, } from '#src/core/shell/alignment.js';
11
13
  import { ApprovalCaptureLog, } from '#src/core/shell/approvalCapture.js';
12
14
  import { buildHardlineRefusal, checkHardline } from '#src/core/shell/hardline.js';
13
- import { renderNegotiationTranscript, ShellNegotiationState } from '#src/core/shell/negotiation.js';
15
+ import { NEGOTIATED_APPROVAL_COOLDOWN_MS, renderNegotiationTranscript, ShellNegotiationState, } from '#src/core/shell/negotiation.js';
16
+ // [[EXT-106]] §4.6 — the user-provenance carve-out, read once per decision and handed to every
17
+ // reader of it (the floor, the negotiation test, the rating prompt, the archive and the warning).
18
+ import { carvedOpenWorldHosts } from '#src/core/shell/provenance.js';
14
19
  import { buildRejectionMessage } from '#src/core/shell/rejection.js';
15
20
  import { describeApprovalEntry, resolveApprovalRules, } from '#src/core/approvals/matcher.js';
16
21
  import { createEffectiveToolAnnotationSource, trustedAnnotationHints, } from '#src/core/approvals/annotations.js';
@@ -20,7 +25,7 @@ import { builtInToolAnnotations, mcpDeclaredAnnotationLookup, } from '#src/core/
20
25
  import { resolveRaterModel } from '#src/core/shell/raterModel.js';
21
26
  import { env } from '#src/utils/systemUtils.js';
22
27
  import { getGslothConfigWritePath } from '#src/utils/fileUtils.js';
23
- import { SHELL_ALLOWLIST_FILE } from '#src/constants.js';
28
+ import { SHELL_ALLOWLIST_FILE, SHELL_DENYLIST_FILE } from '#src/constants.js';
24
29
  import { enhanceVertexUnauthorizedMessage } from '#src/utils/vertexaiUtils.js';
25
30
  import { getNewRunnableConfig } from '#src/utils/llmUtils.js';
26
31
  import { initDebugLogging, debugLog, debugLogError, debugLogObject, } from '#src/utils/debugUtils.js';
@@ -64,13 +69,46 @@ function shellJustification(args) {
64
69
  return undefined;
65
70
  return value.trim().length === 0 ? undefined : value;
66
71
  }
72
+ /**
73
+ * §3.1 — **the one `approvals.allow` entry that would cover this shell command**, or `undefined`
74
+ * when the grammar cannot hold one.
75
+ *
76
+ * Two readers, one answer: what the escalation menu's *always approve* control would store
77
+ * ({@link GthAgentRunner.stickyGrantFor}), and what [[EXT-106]] §4's refusal tells a user to add to
78
+ * their config. Those are the same entry, and deriving it twice is how a menu and a message come to
79
+ * promise different things about one command.
80
+ *
81
+ * **`exact`, never a pattern.** The entry has to be correct enough to paste, and a `glob` inferred
82
+ * from a command is a guess about which part of it may vary: widened one token too far it grants
83
+ * more than the user was ever shown. A subtly-too-broad pasteable entry is worse than none, so the
84
+ * breadth stays where {@link shellGrantEntry} puts it — this command, and only this command.
85
+ *
86
+ * `undefined` for a command the allow classifier cannot resolve, because **no allow entry of any
87
+ * matcher would match it**: rendering one would hand someone a line that changes nothing and looks
88
+ * as though it should.
89
+ */
90
+ function shellApprovalEntryFor(command) {
91
+ if (classifyCommand(command, normalizeCommand) === null)
92
+ return undefined;
93
+ return shellGrantEntry(command);
94
+ }
67
95
  /**
68
96
  * [[EXT-29]] §5.1 — the text of the human messages in a turn's input, for the rater's last-5 window.
69
97
  *
70
98
  * Structural and fail-soft, like `runStats`'s accumulator: the runner is handed `BaseMessage`s from
71
99
  * 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.
100
+ * blocks rather than a string. Only the text of a `human` message is taken, and the filter beside
101
+ * the loop is why: a multimodal turn's non-text blocks (an image) contribute nothing.
102
+ *
103
+ * **What this does NOT do is exclude file contents, tool output or fetched pages, and the
104
+ * distinction matters.** §4.3 admits none of those *as messages of their own*, and none of them
105
+ * arrives as one — but a `human` message's own text is taken whole, whatever put it there. On the
106
+ * file-fed verbs that is a great deal: `exec` reads a prompt FILE from disk, `ask -f` reads a file
107
+ * and piped stdin, and `review`/`pr` carry the entire diff — all of them as `human` messages, all of
108
+ * them therefore in this window. Anything that treats these strings as *"what the user typed"* is
109
+ * treating a file the agent was pointed at as the user's own words; [[EXT-106]] §4.6's provenance
110
+ * carve-out does exactly that, deliberately and only at `auto`, and it is documented as such in
111
+ * `docs/guides/shell-tool-and-approvals.md`.
74
112
  */
75
113
  function humanMessageTexts(messages) {
76
114
  const texts = [];
@@ -130,6 +168,12 @@ export class GthAgentRunner {
130
168
  * for non-interactive entrypoints (a scripted `exec` run with no TTY to prompt on).
131
169
  */
132
170
  toolApprovalCallback = null;
171
+ /**
172
+ * [[EXT-150]] — consumer hook invoked once per human-answered approval, AFTER the answer has been
173
+ * recorded, carrying the lifetime it landed with. Set via {@link setApprovalOutcomeCallback}; when
174
+ * unset the outcome is simply not reported, which is where every surface was before this existed.
175
+ */
176
+ approvalOutcomeCallback = null;
133
177
  /**
134
178
  * [[TUI-C68]] §6.1 — consumer hook invoked when the rater rates a command an `attack`, so an
135
179
  * interactive surface can show the red banner before the run ends. Set via
@@ -138,6 +182,22 @@ export class GthAgentRunner {
138
182
  * therefore keeps the halt rather than losing it.
139
183
  */
140
184
  attackHaltCallback = null;
185
+ /**
186
+ * [[TUI-C69]] §5.4/§5.5 — **the surface showing this negotiation while it happens**, when one
187
+ * is. Set via {@link setNegotiationDisplay}.
188
+ *
189
+ * Its presence is the answer to *"is anyone watching?"*, and BOTH halves of the visible
190
+ * negotiation are keyed on it: the rounds are handed over as they are decided, and a negotiated
191
+ * approval is held on screen for {@link NEGOTIATED_APPROVAL_COOLDOWN_MS} before it takes effect.
192
+ *
193
+ * **`null` means neither happens, and that is the point.** An `exec` or CI run has nobody to
194
+ * show an approval to, so an 800 ms hold there would tax every headless run and every gate for a
195
+ * display that does not exist. Deliberately NOT keyed on {@link toolApprovalCallback}: that one
196
+ * answers *"is there a human to ASK"*, a different question with a different answer — a piped
197
+ * readline session can have one wired and no live display, and the §6.2 non-interactive path has
198
+ * a display and no one to ask.
199
+ */
200
+ negotiationDisplay = null;
141
201
  /** The command the runner was initialized for; selects which `devTools` config applies. */
142
202
  command = undefined;
143
203
  /**
@@ -171,12 +231,24 @@ export class GthAgentRunner {
171
231
  * `undefined` means no profile is configured and the rater uses the session model.
172
232
  */
173
233
  raterModel;
234
+ /**
235
+ * [[EXT-127]] — the model the ALIGNMENT CHECKER runs on, when `approvals.alignmentChecker` (or, by
236
+ * its read-site default, `approvals.rater`) names an identity profile. Resolved ONCE at
237
+ * {@link init}, exactly as {@link raterModel} is; `undefined` means the session model.
238
+ */
239
+ alignmentCheckerModel;
174
240
  /**
175
241
  * EXT-66 — how many rating calls this session gave up on. Counted so the notice can say "3 times
176
242
  * this session" rather than repeating an identical line, and so a silent drift toward
177
243
  * escalate-everything has a number attached to it.
178
244
  */
179
245
  raterTimeouts = 0;
246
+ /**
247
+ * [[EXT-82]] — the consecutive-failure tracker behind the session-level signal, per runner and
248
+ * therefore per session (the ACP surface runs several at once). See
249
+ * {@link import('#src/core/shell/raterHealth.js').RaterHealth}.
250
+ */
251
+ raterHealth = new RaterHealth();
180
252
  /**
181
253
  * EXT-71 §3.1/§6 — what the escalation menu granted at run time, for the life of THIS runner
182
254
  * instance: {@link ApprovalEntry} objects, never prefixes, and never anything from config (the
@@ -198,6 +270,19 @@ export class GthAgentRunner {
198
270
  */
199
271
  persistedGrants = null;
200
272
  persistedGrantsLoaded = false;
273
+ /**
274
+ * [[EXT-107]] — the persisted (`always`) **refusal** store, from
275
+ * `.gsloth/.gsloth-settings/shell-denylist.json`. The mirror of {@link persistedGrants}, and the
276
+ * reason the menu's most emphatic answer is no longer its most forgetful one.
277
+ *
278
+ * Null when the file cannot be loaded at all, in which case an `always` refusal degrades to a
279
+ * session one. **Not "a re-prompt next session, never an execution":** nothing in the unreadable
280
+ * file applies to any call, so next session a call it covered is left to the rest of the gate — it
281
+ * may be refused by another rule, it may be prompted for, or it may run without asking, under
282
+ * `bypass` or a matching saved allow. The user is told at load time.
283
+ */
284
+ persistedDenials = null;
285
+ persistedDenialsLoaded = false;
201
286
  /**
202
287
  * [[EXT-29]] §5 — the state of the agent↔rater negotiation at `auto`: the transcript, §5.3's
203
288
  * consecutive-rejection counter and the reachability bound. Instance-scoped for the same reason
@@ -230,6 +315,23 @@ export class GthAgentRunner {
230
315
  setToolApprovalCallback(callback) {
231
316
  this.toolApprovalCallback = callback;
232
317
  }
318
+ /**
319
+ * [[EXT-150]] — register the handler that is told **what a human's answer actually landed as**.
320
+ * Pass `null` to clear.
321
+ *
322
+ * Separate from {@link setToolApprovalCallback} for the reason {@link setAttackHaltCallback} is
323
+ * separate: it is a different question asked at a different moment. The approval callback is
324
+ * consulted *before* anything is written and returns the human's REQUEST; the scope that survives
325
+ * is decided here afterwards, because [[EXT-149]] degrades an `always` whose write did not reach
326
+ * disk to the `session` answer it really is. A surface with no way to hear that can only confirm
327
+ * the key that was pressed — and core's own ERROR naming the unwritten file then contradicts it.
328
+ *
329
+ * It reports; it never decides. Nothing downstream reads it, so a surface that ignores it, or
330
+ * never wires it, changes no behaviour of the gate.
331
+ */
332
+ setApprovalOutcomeCallback(callback) {
333
+ this.approvalOutcomeCallback = callback;
334
+ }
233
335
  /**
234
336
  * [[TUI-C68]] §6.1 — register the handler that shows the **attack banner**, the one way a human
235
337
  * gets past an `attack` verdict. Pass `null` to clear.
@@ -242,6 +344,19 @@ export class GthAgentRunner {
242
344
  setAttackHaltCallback(callback) {
243
345
  this.attackHaltCallback = callback;
244
346
  }
347
+ /**
348
+ * [[TUI-C69]] §5.4/§5.5 — **declare that this surface is showing the negotiation as it happens.**
349
+ * Pass `null` to clear.
350
+ *
351
+ * §5.4's requirement is not decoration: *"the spec's own justification for letting the agent
352
+ * argue with the rater at all is that a human can watch it, and an argument conducted in the dark
353
+ * is a different thing from one that can be interrupted."* Wiring this is a surface saying it
354
+ * has somewhere to draw that, which is also what makes §5.5's hold meaningful — see
355
+ * {@link negotiationDisplay} for why one seam carries both.
356
+ */
357
+ setNegotiationDisplay(display) {
358
+ this.negotiationDisplay = display;
359
+ }
245
360
  /**
246
361
  * §6.1 — **the single seam between an `attack` verdict and the end of the run.** Both rating
247
362
  * paths — the §3.2 allow-match tripwire and the ordinary rater decision — go through here, so the
@@ -264,7 +379,16 @@ export class GthAgentRunner {
264
379
  */
265
380
  async haltOrRunAnyway(command, reason,
266
381
  /** [[TUI-C27]] — this decision's record; the banner's answer is a HUMAN's answer. */
267
- record) {
382
+ record,
383
+ /**
384
+ * [[EXT-115]] — the subject the gate decided this call on, so the halt names what it actually
385
+ * halted rather than calling everything a `Command`. **Required, not optional**: both call
386
+ * sites hold the decision's own subject, and the type system is what keeps a future one from
387
+ * quietly reaching the class's hand-built fallback. Today every caller is on the shell arm —
388
+ * §4.3 keeps the rater on the shell until [[EXT-30]] — so this changes no message that exists
389
+ * yet; what it changes is that the halt stays correct when that arm widens.
390
+ */
391
+ subject) {
268
392
  if (this.attackHaltCallback) {
269
393
  const answer = await this.attackHaltCallback({ command, reason });
270
394
  // [[TUI-C27]] — **recorded here, or a run-anyway is indistinguishable from a `safe`
@@ -280,7 +404,7 @@ export class GthAgentRunner {
280
404
  // §6.2 — no surface wired the banner, so nobody was asked and the run ends.
281
405
  record.humanAnswer = 'no-human';
282
406
  }
283
- throw new AttackHaltError(command, reason);
407
+ throw new AttackHaltError(command, reason, subject);
284
408
  }
285
409
  /**
286
410
  * CFG-27 — switch the session-scoped rung (`/approvals <rung>`). Idempotent; returns the NEW
@@ -327,12 +451,107 @@ export class GthAgentRunner {
327
451
  return { session, always };
328
452
  }
329
453
  /**
330
- * CFG-27 — the session's deny entries for display: the declared `approvals.deny` entries
331
- * (rendered one line each) followed by whatever the escalation menu's *always reject* added at
332
- * run time. Both refuse a call, so both are shown.
454
+ * CFG-27/[[EXT-107]]**every refusal in force, and which of the three lists holds it**: the
455
+ * declared `approvals.deny` entries, the escalation menu's session-scoped refusals, and the ones
456
+ * saved to the project's deny file.
457
+ *
458
+ * The origins are kept apart rather than concatenated into one list of strings, because they have
459
+ * different lifetimes and different owners and only two of the three can be lifted from here.
460
+ * A merged list makes {@link liftRefusal} impossible to describe honestly.
461
+ *
462
+ * **This is the one list.** {@link liftRefusal} resolves its argument against exactly this
463
+ * sequence, so the number a user reads and the number they type cannot name different entries.
464
+ *
465
+ * Numbering is 1-based, and the order is config → saved → session: the entries a user cannot lift
466
+ * here come first and stay put, so the numbers of the ones they can are not reshuffled by a
467
+ * config edit between two renderings.
468
+ */
469
+ getRefusals() {
470
+ return this.refusalRecords().map((held, position) => ({
471
+ index: position + 1,
472
+ description: describeApprovalEntry(held.entry),
473
+ origin: held.origin,
474
+ ...(held.recordedAt !== undefined ? { recordedAt: held.recordedAt } : {}),
475
+ }));
476
+ }
477
+ /**
478
+ * [[EXT-107]] — the refusals in force with their entries attached, which is what
479
+ * {@link liftRefusal} needs and {@link getRefusals} renders. ONE builder, so the displayed order
480
+ * and the removal order are the same order by construction rather than by two functions agreeing.
481
+ *
482
+ * A saved refusal is held in both runtime stores ({@link recordDenial} writes both), so the
483
+ * session list is filtered against the saved one by entry identity — the same de-duplication
484
+ * {@link getGrants} does. A configured entry is NOT de-duplicated against them: it is a different
485
+ * thing with a different owner, and hiding it would let a lift report success while the config
486
+ * line went on refusing the call.
487
+ */
488
+ refusalRecords() {
489
+ const saved = this.getPersistedDenials()?.list() ?? [];
490
+ const savedKeys = new Set(saved.map((grant) => renderApprovalEntryObject(grant.entry)));
491
+ return [
492
+ ...this.sessionApprovals.deny.map((entry) => ({ entry, origin: 'config' })),
493
+ ...saved.map((grant) => ({
494
+ entry: grant.entry,
495
+ origin: 'persisted',
496
+ recordedAt: grant.grantedAt,
497
+ })),
498
+ ...this.denyGrants
499
+ .list()
500
+ .filter((grant) => !savedKeys.has(renderApprovalEntryObject(grant.entry)))
501
+ .map((grant) => ({
502
+ entry: grant.entry,
503
+ origin: 'session',
504
+ recordedAt: grant.grantedAt,
505
+ })),
506
+ ];
507
+ }
508
+ /**
509
+ * [[EXT-107]] — **lift one refusal**, by its number in {@link getRefusals}. The escape hatch, and
510
+ * the reason persisting a refusal is safe to ship: a saved refusal the user cannot find or undo
511
+ * is a trap, and the person who hits it first is whoever pressed `[d]` by reflex and needed the
512
+ * command an hour later. Telling them to delete a file they have not been told exists is not an
513
+ * answer.
514
+ *
515
+ * A saved refusal is removed from the file AND from the in-memory store, because it is in both —
516
+ * dropping only the file would leave the call refused for the rest of the session by a rule the
517
+ * display no longer shows.
518
+ *
519
+ * **A configured entry is reported, never removed.** `approvals.deny` is something the user
520
+ * wrote; rewriting their config file out from under them is not a thing a session command may do,
521
+ * and silently no-oping would be worse. They are told where it lives.
522
+ *
523
+ * **`stillSaved` is the deletion that did not reach disk** ([[EXT-149]]). The file rewrite can
524
+ * fail after the in-memory removal has succeeded — a checkout that is not writable, a settings
525
+ * directory that has gone — and the entry then comes back in the next session. The store now
526
+ * answers that question ({@link PersistedApprovalGrants.remove}) instead of reporting every
527
+ * removal as landed, so the notice can stop promising *it will not come back*.
333
528
  */
334
- getDenylist() {
335
- return [...this.sessionApprovals.deny, ...this.denyGrants.entries()].map(describeApprovalEntry);
529
+ liftRefusal(index) {
530
+ const held = this.refusalRecords();
531
+ const target = held[index - 1];
532
+ if (!target || !Number.isInteger(index)) {
533
+ return { outcome: 'unknown', index, count: held.length };
534
+ }
535
+ const description = describeApprovalEntry(target.entry);
536
+ if (target.origin === 'config')
537
+ return { outcome: 'configured', description };
538
+ this.denyGrants.remove(target.entry);
539
+ const removedFromFile = target.origin === 'persisted'
540
+ ? (this.getPersistedDenials()?.remove(target.entry) ?? false)
541
+ : false;
542
+ const key = renderApprovalEntryObject(target.entry);
543
+ return {
544
+ outcome: 'lifted',
545
+ description,
546
+ origin: target.origin,
547
+ // A session refusal was never in a file, so there is nothing left there — the flag is about
548
+ // the file keeping an entry the user was told had gone, and only a saved one can.
549
+ stillSaved: target.origin === 'persisted' && !removedFromFile,
550
+ // The other half of not de-duplicating config entries above: a call refused by BOTH a saved
551
+ // entry and a config line is still refused after this, and a notice that did not say so would
552
+ // report a lift the gate did not perform.
553
+ stillConfigured: this.sessionApprovals.deny.some((entry) => renderApprovalEntryObject(entry) === key),
554
+ };
336
555
  }
337
556
  /**
338
557
  * §3/§4.7.4 — **the grants themselves**, for an approvals view that shows *what* was granted,
@@ -503,6 +722,20 @@ export class GthAgentRunner {
503
722
  // remains switchable (`/approvals write`). Resolved per-command, mirroring where the shell
504
723
  // tool is actually emitted; no effect where the tool is ungated.
505
724
  this.sessionApprovals = resolveApprovals(configIn, command);
725
+ // [[EXT-106]] §4.6 — **whose words this session's human messages are.** The provenance carve-out
726
+ // reads the retained human turns as *"the user's own verbatim words"*, and on `review` and `pr`
727
+ // they are nothing of the kind: the product itself fetched the diff and the PR description and
728
+ // then framed them as a human message, and the review prompt tells the agent to EXAMINE that
729
+ // content. Material under examination is not the voice of the person who asked for the
730
+ // examination, so admitting it would make the product contradict itself about identical input.
731
+ //
732
+ // **Decided from the VERB and never from anything inside a message.** Those bytes are
733
+ // attacker-controlled, so a marker in them can be forged by the text it is meant to classify;
734
+ // out-of-band metadata is the only admissible key. `owningCommand` is the fallback because a
735
+ // command-less helper agent — the `gth pr` discovery run — must be classified by the verb it
736
+ // serves rather than by the absence of one. Both the predicate and the window's own default
737
+ // fail closed, so a driver nobody has classified floors exactly as it did before the carve-out.
738
+ this.negotiation.admitUserProvenance(commandCarriesUserProvenance(command ?? options?.owningCommand));
506
739
  // §3/§9.1 — the DECLARED lists are read-only config input, consulted through the EXT-71 rule
507
740
  // matcher (`core/approvals/matcher.ts`) and NEVER copied into the runtime stores, which hold
508
741
  // only what the escalation menu grants at run time. Both are handed to the same matcher, so
@@ -522,7 +755,18 @@ export class GthAgentRunner {
522
755
  // `assisted` mid-session with `/approvals`), and a broken profile should still fail loudly at
523
756
  // startup rather than at the moment they switch.
524
757
  const raterProfile = this.sessionApprovals.rater;
525
- this.raterModel = raterProfile ? await resolveRaterModel(raterProfile) : undefined;
758
+ this.raterModel = raterProfile
759
+ ? await resolveRaterModel(raterProfile, 'approvals.rater')
760
+ : undefined;
761
+ // [[EXT-127]] — and the same for the alignment checker, which is a second model with a second
762
+ // profile. `resolveApprovals` has already defaulted the key to the rater's profile, so the
763
+ // common case resolves the SAME name twice rather than branching here: two loads of one profile
764
+ // at startup is cheaper than a shortcut that would silently stop working the moment the two
765
+ // names differ, which is the configuration the key exists for.
766
+ const checkerProfile = this.sessionApprovals.alignmentChecker;
767
+ this.alignmentCheckerModel = checkerProfile
768
+ ? await resolveRaterModel(checkerProfile, 'approvals.alignmentChecker')
769
+ : undefined;
526
770
  // Initialize debug logging
527
771
  initDebugLogging(configIn.debugLog ?? false);
528
772
  debugLog(`Initializing GthAgentRunner with command: ${command || 'default'}`);
@@ -575,7 +819,11 @@ export class GthAgentRunner {
575
819
  // standing from the previous one and clears BOTH bounds. The turn's own messages then enter
576
820
  // §5.1's last-5 window, which is what makes "just the last two" reach the rater at all — the
577
821
  // reply that narrows what the agent proposes is worthless to the gate if only the agent hears it.
578
- this.negotiation.humanReached();
822
+ this.endNegotiation();
823
+ // [[TUI-C69]] §5.4 — and the tone hints go with it. The ids matter only while the results
824
+ // carrying them are on screen; the previous turn's are spent, and an id that outlived its turn
825
+ // could only ever mis-tone a later row.
826
+ this.clearRaterClarifications();
579
827
  this.negotiation.noteUserMessages(humanMessageTexts(messages));
580
828
  debugLog('Processing messages...');
581
829
  debugLogObject('Input Messages', messages);
@@ -667,6 +915,14 @@ export class GthAgentRunner {
667
915
  const enhancedMessage = enhanceVertexUnauthorizedMessage(originalMessage, this.config?.llm);
668
916
  throw new Error(`Agent processing failed: ${enhancedMessage}`, error instanceof Error ? { cause: error } : undefined);
669
917
  }
918
+ finally {
919
+ // [[TUI-C69]] §5.4 — the turn is over, so the argument is over. The reasoning is the same as
920
+ // in {@link processMessagesWithEvents}; it is here too so the seam is a property of *ending a
921
+ // turn* rather than of the event path, and a string-path surface that grows an `end` is not
922
+ // the one place the panel outlives its turn. Display-only, and a no-op on today's readline
923
+ // surface, which appends to scrollback and implements no `end`.
924
+ this.clearNegotiationDisplay();
925
+ }
670
926
  }
671
927
  /**
672
928
  * Accumulate a text stream into a single string. Extracted so {@link processMessages} and
@@ -868,6 +1124,166 @@ export class GthAgentRunner {
868
1124
  this.negotiation.noteProgress();
869
1125
  return decision;
870
1126
  }
1127
+ /**
1128
+ * [[EXT-29]] §5.3 / [[TUI-C69]] §5.4 — **a person was reached, so the exchange is over**: the
1129
+ * gate's own transcript is spent and the surface showing it is told, on the same event.
1130
+ *
1131
+ * Every direct `humanReached()` in this class goes through here, which is what keeps the two from
1132
+ * drifting: a new site that spent the transcript without telling the display would leave a
1133
+ * finished argument standing on screen, and at an escalation it would put the same exchange on an
1134
+ * unscrollable dialog twice — once live, once in the prompt about to render all of it.
1135
+ */
1136
+ endNegotiation() {
1137
+ this.negotiation.humanReached();
1138
+ this.clearNegotiationDisplay();
1139
+ }
1140
+ /**
1141
+ * [[TUI-C69]] §5.4 — **take the finished argument off the screen WITHOUT spending the gate's
1142
+ * transcript.**
1143
+ *
1144
+ * The two are separate on purpose. `humanReached()` clears the rounds *and* the reachability
1145
+ * bound, which is correct only when a person was actually reached; calling it merely to tidy the
1146
+ * panel would hand the agent a fresh {@link MAX_REJECTIONS_BEFORE_HUMAN} budget it had already
1147
+ * spent, turning a display concern into a way to argue indefinitely. [[EXT-108]] made an approved
1148
+ * call stop clearing the transcript for exactly this reason, so the tidy-up cannot be the thing
1149
+ * that puts it back.
1150
+ *
1151
+ * Guarded, like {@link showNegotiationRound}: a surface that throws while clearing must never
1152
+ * change what the gate decided, or become the reason a turn ends.
1153
+ */
1154
+ clearNegotiationDisplay() {
1155
+ try {
1156
+ this.negotiationDisplay?.end?.();
1157
+ }
1158
+ catch (e) {
1159
+ debugLogError('negotiation display end', e);
1160
+ }
1161
+ }
1162
+ /**
1163
+ * [[TUI-C69]] §5.4 — **tell the agent to forget the tool-call ids the rater bounced**, on every
1164
+ * event that ends a turn's display state: a new turn, and `/clear`.
1165
+ *
1166
+ * The runner decides *when* because it owns the turn boundary; the agent holds the set because it
1167
+ * owns the rendering. Fail-soft in the shape {@link resetRunStats} uses — an agent without the
1168
+ * method (a test double, a renderer-less agent) is simply skipped, and a tone hint must never be
1169
+ * the reason a turn fails to start.
1170
+ */
1171
+ clearRaterClarifications() {
1172
+ try {
1173
+ this.agent?.clearRaterClarifications?.();
1174
+ }
1175
+ catch (e) {
1176
+ debugLogError('clear rater clarifications', e);
1177
+ }
1178
+ }
1179
+ /**
1180
+ * [[TUI-C69]] §5.4 — **hand one round of the argument to the surface that is showing it**, the
1181
+ * moment the gate decided it.
1182
+ *
1183
+ * The round travels raw. Every surface lays it out from the SAME rows the escalation prompt
1184
+ * draws, at its own terminal width, so the rounds a person watches and the rounds they later rule
1185
+ * on cannot be two different renderings of one exchange — and the rater's turns are yellow in
1186
+ * both because the rows carry the voice rather than a colour.
1187
+ *
1188
+ * No-ops when no surface is watching, which is the §5.5 seam as well as this one.
1189
+ */
1190
+ showNegotiationRound(event) {
1191
+ const display = this.negotiationDisplay;
1192
+ if (!display)
1193
+ return;
1194
+ try {
1195
+ display.round(event);
1196
+ }
1197
+ catch (e) {
1198
+ // A surface that throws while drawing must never change what the gate decided.
1199
+ debugLogError('negotiation display round', e);
1200
+ }
1201
+ }
1202
+ /**
1203
+ * [[TUI-C69]] §5.4/§5.5 — **the rater agreeing is the last round of the argument, and it is held
1204
+ * on screen before it takes effect.**
1205
+ *
1206
+ * Two things, in this order, because the order is the requirement: the approving round is drawn,
1207
+ * and only then does the minimum visible interval run. A hold before the draw would be a pause
1208
+ * over nothing.
1209
+ *
1210
+ * **It is a visibility pause, not a reading window**, and it must never be relied on as an
1211
+ * opportunity to evaluate the command — nobody reads a command in 800 ms. What it buys is that
1212
+ * the approving round is on screen as its own event instead of being overwritten by the tool
1213
+ * output that follows it immediately.
1214
+ *
1215
+ * **What it is NOT, on either surface, is a guaranteed abort window — do not restore that claim
1216
+ * without building the mechanism.** Stated precisely, because the previous wording asserted a
1217
+ * mechanism that is not here and a comment like that stops the next reader checking:
1218
+ *
1219
+ * - **Event/TUI path.** An abort raised during the hold does end the run with the tool unrun, but
1220
+ * *this code is not why*. The runner never re-checks the signal after the hold — it issues the
1221
+ * approving resume regardless — and what stops the tool is that LangGraph refuses an
1222
+ * already-aborted signal downstream. True today, and true by someone else's invariant.
1223
+ * - **Plain/readline path.** Not true at all. `resolveToolInterrupts` threads no signal, and
1224
+ * `waitForEscape` is armed inside `streamFromInput` and torn down before the hold begins — the
1225
+ * hold happens *between* streams. Esc during it is not handled, so the command runs. The pause
1226
+ * still buys the visibility above, which is why it is not conditioned on the surface.
1227
+ *
1228
+ * Making the affordance real by construction — checking the signal here, and threading one into
1229
+ * the readline path so there is something to check — is deliberately left out of scope rather
1230
+ * than half-built, since a window honoured on one surface and not the other is the more dangerous
1231
+ * shape: it is what teaches the user the gesture that then silently fails.
1232
+ *
1233
+ * **Both halves are gated on a surface being wired**, so a headless `exec`/CI run neither draws
1234
+ * nor sleeps and pays nothing. See {@link negotiationDisplay}.
1235
+ */
1236
+ async showNegotiatedApproval(command, justification, verdict) {
1237
+ if (!this.negotiationDisplay)
1238
+ return;
1239
+ // **A negotiated approval is one that ANSWERS A REFUSAL THAT IS STILL STANDING** — not merely
1240
+ // one that happens later in a turn where something was refused.
1241
+ //
1242
+ // `consecutiveRejections` is exactly that question, already maintained for §5.3: it counts
1243
+ // rejections since the last approval, so it is non-zero only while the argument is unanswered
1244
+ // and `noteProgress()` puts it back to zero the moment any call gets through. Testing the
1245
+ // TRANSCRIPT instead is what made this wrong — since [[EXT-108]] an approved call deliberately
1246
+ // leaves the rounds standing, so a transcript test stays true for the whole rest of the turn,
1247
+ // and the six read-only commands an agent runs after one refusal each got a hold and a row
1248
+ // claiming the rater had agreed to them.
1249
+ //
1250
+ // Not cosmetic: it inverts §5.5. The hold exists to give the approval an argument produced its
1251
+ // own salience, and a window that opens on everything marks nothing.
1252
+ //
1253
+ // **Deliberately NOT "the transcript contains this exact command".** That reads well and is
1254
+ // wrong: the case this node exists for is a negotiation that CONVERGES, and an agent converges
1255
+ // by narrowing — `git reset --hard origin/main` becomes `git reset --soft HEAD~2`, which the
1256
+ // transcript has never held. Gating on an exact match would make the node's own canonical
1257
+ // scenario draw nothing at all. The exact match decides the LABEL below, where being wrong
1258
+ // costs a word instead of the feature.
1259
+ //
1260
+ // A first attempt rated `safe` is not a negotiated approval either: nothing was refused, so
1261
+ // there is no argument for a person to have watched, and the counter is zero.
1262
+ if (this.negotiation.counters().consecutiveRejections === 0)
1263
+ return;
1264
+ const transcript = this.negotiation.transcript();
1265
+ const rejections = transcript.length;
1266
+ // Did the rater refuse THIS command and then pass it, or pass something else? Only the first is
1267
+ // the rater agreeing; the second is it accepting a different command. Saying "Agreed" over a
1268
+ // command nobody argued about prints a false statement about the auto-rater.
1269
+ const revised = !transcript.some((round) => round.command === command);
1270
+ // The approving round sits AFTER every rejection, so the whole transcript precedes it — but it
1271
+ // is `agreed`, so it is LABELLED rather than numbered. A number here would be the very one the
1272
+ // next rejection takes: this call never joins the transcript, and the escalation prompt renders
1273
+ // that transcript, so the two views would give one number to two different commands.
1274
+ this.showNegotiationRound({
1275
+ round: {
1276
+ command,
1277
+ ...(justification ? { justification } : {}),
1278
+ outcome: verdict?.outcome ?? 'safe',
1279
+ reason: verdict?.reason ?? '',
1280
+ },
1281
+ position: rejections,
1282
+ agreed: true,
1283
+ ...(revised ? { revised: true } : {}),
1284
+ });
1285
+ await new Promise((resolve) => setTimeout(resolve, NEGOTIATED_APPROVAL_COOLDOWN_MS));
1286
+ }
871
1287
  /**
872
1288
  * [[TUI-C27]] — {@link decideToolApprovalInner} with the record closed off on EVERY exit.
873
1289
  *
@@ -954,24 +1370,30 @@ export class GthAgentRunner {
954
1370
  });
955
1371
  // (1) Deny — before everything, including `bypass`.
956
1372
  if (rule?.action === 'deny') {
957
- // [[TUI-C26]] §6 — the message names the refusal the user actually made. A deny entry now has
958
- // two possible authors: a line in `approvals.deny`, and the escalation menu's *always reject*
959
- // choice earlier in this session. Telling the model (and, through it, the user) to edit a
960
- // config file that the second kind was never written to is the same class of wrongness as
961
- // confirming a persistence that did not happen. The declared list is checked FIRST so an
962
- // entry a user wrote is described as theirs even when the menu recorded the identical one.
963
- const declared = this.sessionApprovals.deny.some((entry) => renderApprovalEntryObject(entry) === renderApprovalEntryObject(rule.entry));
1373
+ // [[TUI-C26]] §6/[[EXT-107]] — the message names the refusal the user actually made, and each
1374
+ // of the three authors is undone somewhere different: a line in `approvals.deny` is edited
1375
+ // out of a config file, a saved refusal is lifted from the project's deny file, and a session
1376
+ // one simply ends with the session. Telling the model (and, through it, the user) to edit a
1377
+ // file the refusal was never written to is the same class of wrongness as confirming a
1378
+ // persistence that did not happen. The declared list is checked FIRST so an entry a user
1379
+ // wrote is described as theirs even when the menu recorded the identical one.
964
1380
  const described = describeApprovalEntry(rule.entry);
1381
+ const key = renderApprovalEntryObject(rule.entry);
1382
+ const declared = this.sessionApprovals.deny.some((entry) => renderApprovalEntryObject(entry) === key);
1383
+ const saved = !declared &&
1384
+ (this.getPersistedDenials()?.list() ?? []).some((grant) => renderApprovalEntryObject(grant.entry) === key);
965
1385
  record.ruleMatch = { action: 'deny', entry: described };
966
- return this.stage(record, 'deny-list', {
967
- type: 'reject',
968
- message: declared
969
- ? `Refused: your deny list forbids this call (matched "${described}"). ` +
970
- 'Remove the entry from approvals.deny if you want it to run.'
1386
+ const message = declared
1387
+ ? `Refused: your deny list forbids this call (matched "${described}"). ` +
1388
+ 'Remove the entry from approvals.deny if you want it to run.'
1389
+ : saved
1390
+ ? `Refused: the user chose to always refuse this, and it was saved to this project ` +
1391
+ `(matched "${described}"). The refusal stands in new sessions too; ask the user if ` +
1392
+ 'you believe it should be lifted, which they can do with the /approvals command.'
971
1393
  : `Refused: the user chose to always refuse this earlier in this session (matched ` +
972
1394
  `"${described}"). That refusal lasts until the session ends; ask the user if you ` +
973
- 'believe it should be lifted.',
974
- });
1395
+ 'believe it should be lifted.';
1396
+ return this.stage(record, 'deny-list', { type: 'reject', message });
975
1397
  }
976
1398
  // (2) `bypass` (config or `/approvals bypass`): approve a gated shell command WITHOUT
977
1399
  // prompting or rating. Scope `once` so nothing is written to the allow-list (the bypass is
@@ -1088,7 +1510,7 @@ export class GthAgentRunner {
1088
1510
  // entry does not decide whether the banner appears. The entry has already been overruled by
1089
1511
  // the time this line is reached; letting it also silence the one way out would make the
1090
1512
  // recovery depend on a match the human cannot see from the banner.
1091
- return await this.haltOrRunAnyway(command, tripwire.verdict?.reason ?? '', record);
1513
+ return await this.haltOrRunAnyway(command, tripwire.verdict?.reason ?? '', record, subject);
1092
1514
  }
1093
1515
  // `catastrophic` — the one outcome the tripwire escalates. Fall through to the human.
1094
1516
  safetyVerdict = tripwire.verdict;
@@ -1123,11 +1545,61 @@ export class GthAgentRunner {
1123
1545
  // in the rating prompt (`buildRaterPrompt`) and nothing else.
1124
1546
  // [[EXT-29]] §5.1 — the negotiation this rating is a round of. At `assisted` the context is
1125
1547
  // empty and `negotiable` is false, so the whole call is byte-identical to what it was.
1126
- const negotiable = isNegotiatingRung(approvals.rung);
1548
+ //
1549
+ // [[EXT-106]] §3 — and it is false at `auto` too for a command §4.6's preflight floors,
1550
+ // through the SAME `isNegotiableCall` the decision below reads. Telling the rater to word a
1551
+ // rejection "for an agent that may answer it" is simply FALSE there: nothing it writes can
1552
+ // move an outcome the floor recomputes from the raw command every round. Two writers of one
1553
+ // fact is what this shares a function to avoid.
1554
+ //
1555
+ // **It also withholds the justification and the transcript from that rating**, and that
1556
+ // follows rather than being a side effect: both exist so a rating can be revised in the
1557
+ // light of an argument, and there is no argument on this path — the call goes to a person
1558
+ // on its first round instead.
1559
+ //
1560
+ // [[EXT-106]] §4.6 — **the provenance is read ONCE here, and every reader of the carve-out
1561
+ // is given that one snapshot.** `this.negotiation` is mutable and the rating below is
1562
+ // awaited, so three separate reads of it — the negotiability test before the call, the
1563
+ // decision after it, the archive after that — would be the two-writer hazard these doc
1564
+ // blocks exist to prevent, rebuilt around an await. One read fixes the input; the derivation
1565
+ // itself is then a pure function of (rung, raw command, snapshot) that each reader runs
1566
+ // through the one shared helper, exactly as both readers already recompute the preflight
1567
+ // from the raw command rather than being handed a result to trust. That is also what makes
1568
+ // the rung scope unforgeable: `carvedOpenWorldHosts` enforces `auto` itself, so this call
1569
+ // site cannot widen it by forgetting.
1570
+ //
1571
+ // **NOT `contextFor()`.** That returns no user messages at round 1 by design (§5.1), and
1572
+ // round 1 — the user asks, the agent proposes, nothing has been refused yet — is the round
1573
+ // the carve-out exists to act on. §5.1 bounds what the RATER may see; the floor is not the
1574
+ // rater.
1575
+ //
1576
+ // **And "was this call carved?" is asked of the DECISION's own reader, once.** The carve is
1577
+ // one arm of a floor with two, and only `effectivePreflightFloorFinding` resolves which arm
1578
+ // wins: a command that trips the script-env-leak arm as well is floored by that one and is
1579
+ // not carved at all, whatever the open-world arm would have said on its own. Reading
1580
+ // `carvedOpenWorldHosts` directly for the archive, the prompt or the warning is a SECOND
1581
+ // derivation of the same fact that does not know about arm precedence — so those three would
1582
+ // report a carve on a command that was floored and did go to a human. Deriving the hosts
1583
+ // from the effective finding keeps them empty whenever the floor stood, which is what makes
1584
+ // "carved" mean the same thing to every reader of it.
1585
+ const provenance = this.negotiation.retainedUserMessages();
1586
+ const effectiveFloor = effectivePreflightFloorFinding(subject.command, {
1587
+ rung: approvals.rung,
1588
+ provenance,
1589
+ });
1590
+ const carvedHosts = effectiveFloor === null
1591
+ ? carvedOpenWorldHosts(approvals.rung, subject.command, provenance)
1592
+ : [];
1593
+ const negotiable = isNegotiableCall(approvals.rung, subject.command, provenance);
1127
1594
  const justification = negotiable ? shellJustification(tool.args) : undefined;
1128
- const context = negotiable
1129
- ? this.negotiation.contextFor(justification)
1130
- : undefined;
1595
+ // [[EXT-127]] **the checker gets the justification whatever the rung said**, because it is
1596
+ // a different reader with a different framing. §5.1's gate above existed to keep the one
1597
+ // channel that can LOWER a rating out of a round-1 rating; the classifier can no longer see
1598
+ // it at any round, so that gate now protects nothing there. The checker reads it as what it
1599
+ // is — agent-authored text in the tool-result role — where nothing about it is trusted.
1600
+ // Kept as a separate read so the variable the ROUND RECORD and the negotiated-approval
1601
+ // display use is byte-for-byte what it was.
1602
+ const agentJustification = shellJustification(tool.args);
1131
1603
  // [[TUI-C27]] — attributed BEFORE the call, and ONCE. Before, because `attack` throws out
1132
1604
  // of the decision below and a record left unattributed would say a halt came from nowhere.
1133
1605
  // Once, because a second assignment on each `return` would make the first unfalsifiable:
@@ -1136,28 +1608,194 @@ export class GthAgentRunner {
1136
1608
  record.stage = 'rater';
1137
1609
  const verdict = await this.rateCommand(subject.command, {
1138
1610
  allowMatched: false,
1139
- negotiation: context,
1140
1611
  negotiable,
1612
+ // [[EXT-106]] §4.6 — the rating PROMPT has to know too. Two of its blocks assert that
1613
+ // §4.6's floor already fired and that the rater's hostname judgement is therefore not
1614
+ // what decides; on a carved command both are backwards, and on this one command the
1615
+ // rater's assessment really is the last line.
1616
+ carved: carvedHosts.length > 0,
1141
1617
  }, record);
1142
- const decision = mapVerdictToAction(subject.command, verdict, { rung: approvals.rung });
1618
+ const decision = mapVerdictToAction(subject.command, verdict, {
1619
+ rung: approvals.rung,
1620
+ provenance,
1621
+ });
1143
1622
  // [[TUI-C27]] — WHICH deterministic preflight fired, and whether it actually rewrote the
1144
1623
  // rating. The two are separate facts: a preflight only ever RAISES, and only `safe` sits
1145
1624
  // below the floor, so a finding on a `destructive` verdict is the floor AGREEING with the
1146
1625
  // rater rather than overriding it — and attributing the decision to the floor in that case
1147
1626
  // would be wrong. Recomputed from the same raw command `mapVerdictToAction` recomputes it
1148
1627
  // from, through the same one function, so the two cannot disagree.
1628
+ //
1629
+ // [[EXT-106]] §4.6 — **the PURE finding, deliberately, so a carve does not empty the
1630
+ // archive.** The decision above reads the carve-aware form; this reads what the preflights
1631
+ // found in the string, and the carve is recorded BESIDE it as its own two facts. A user
1632
+ // opening a dump of their own session most needs to find the case where an open-world
1633
+ // command ran with nobody asked, and a record nulled out by the carve is precisely the one
1634
+ // that would be missing.
1635
+ //
1636
+ // **`floorApplied` is read off the decision's own reader, not derived a second time.** It
1637
+ // answers "did the readers act on this finding?", which is `effectivePreflightFloorFinding`
1638
+ // and nothing else: computing it from the carved hosts instead would say "carved, not
1639
+ // floored" about a command whose script-env-leak arm floored it and sent it to a human. Both
1640
+ // fields therefore come from `effectiveFloor`, which is also what the decision, the
1641
+ // negotiability test and the prompt above were built from.
1149
1642
  const preflight = preflightFloorFinding(subject.command);
1150
1643
  if (preflight) {
1151
1644
  record.preflight = {
1152
1645
  ...preflight,
1153
1646
  rewroteRating: isBelowDestructiveFloor(verdict.outcome),
1647
+ floorApplied: effectiveFloor !== null,
1648
+ ...(carvedHosts.length > 0 ? { carvedHosts: [...carvedHosts] } : {}),
1154
1649
  };
1155
1650
  }
1156
- if (decision.action === 'approve') {
1651
+ // [[EXT-127]] **THE ALIGNMENT CHECK: a second model, reached only once the classifier has
1652
+ // declined.** The classifier rated the command; this asks the different question the
1653
+ // classifier can no longer see the context for — *is this what the user asked for?*
1654
+ //
1655
+ // **It is a stage AFTER `mapVerdictToAction`, never a branch inside it, and that placement
1656
+ // is the design.** The classifier's mapping stays a pure function of the rung, the raw
1657
+ // command and the outcome, so the eval target, the corpus and every unit assertion keep
1658
+ // measuring the same thing they measured before; what the check does is take that decision
1659
+ // and, on exactly the outcomes the node grants it authority over, replace it.
1660
+ //
1661
+ // **What it is allowed to reach.** Only a `destructive` decision, and only at a negotiating
1662
+ // rung. `attack` has already halted above the return below; `catastrophic` returns its own
1663
+ // escalation from the mapping and is never offered here; and both are refused a second time
1664
+ // by the tool contract itself (`alignmentApprovalRefusal`), because a limit the rest of the
1665
+ // ladder relies on must not be reachable by a call site forgetting.
1666
+ //
1667
+ // **The floored arm is included, and it has to be.** §4.6's open-world floor is one of the
1668
+ // two things an aligned approval MAY lift, and a floored command reaches this line as an
1669
+ // `escalate` rather than a `reject` — so a check keyed on `reject` alone would make the
1670
+ // single largest piece of authority this feature has unreachable. The SCRIPT-ENV-LEAK arm is
1671
+ // deliberately not included: it is a fact about the command's own text (an interpreter
1672
+ // expanding a secret into a script), nothing about who asked for it speaks to it, and the
1673
+ // node grants no authority over it.
1674
+ let action = decision.action;
1675
+ let alignment;
1676
+ const alignmentReachable = isNegotiatingRung(approvals.rung) &&
1677
+ decision.verdict?.outcome === 'destructive' &&
1678
+ (decision.action === 'reject' ||
1679
+ (decision.action === 'escalate' && effectiveFloor?.kind === 'open-world'));
1680
+ if (alignmentReachable) {
1681
+ alignment = await this.checkAlignment({
1682
+ command: subject.command,
1683
+ outcome: 'destructive',
1684
+ reason: decision.verdict?.reason ?? '',
1685
+ ...(agentJustification ? { justification: agentJustification } : {}),
1686
+ }, provenance, record);
1687
+ // The three tools ARE the three actions, which is why there is no fourth.
1688
+ //
1689
+ // **A check that never happened changes nothing**, which is a stronger contract than "it
1690
+ // fails closed" and is the one that matters here: the classifier's action stands, so a
1691
+ // missing or broken checker model leaves `auto` behaving exactly as it did before this
1692
+ // feature existed rather than quietly turning every negotiation into an interruption. See
1693
+ // {@link ALIGNMENT_FAIL_CLOSED}.
1694
+ if (!isAlignmentFailClosed(alignment)) {
1695
+ action =
1696
+ alignment.kind === 'approve'
1697
+ ? 'approve'
1698
+ : alignment.kind === 'suggest'
1699
+ ? 'reject'
1700
+ : 'escalate';
1701
+ }
1702
+ else {
1703
+ // Not recorded on the round either: a check that did not happen is not one the next
1704
+ // round should replay as its own earlier turn.
1705
+ alignment = undefined;
1706
+ }
1707
+ }
1708
+ if (action === 'approve') {
1709
+ // [[EXT-106]] §4.6 — **a carved command that RUNS is announced.** The carve-out removes
1710
+ // the confirmation dialog; it must never remove the visibility, or an open-world fetch
1711
+ // reaches the network with the user told nothing at all. Visible for the same reason the
1712
+ // hardline refusal above is: an event the user never sees reads as the agent quietly
1713
+ // deciding things on their behalf.
1714
+ //
1715
+ // **The trigger is carved AND approved AND not lifted by the alignment check.** This
1716
+ // sentence tells the user *"the auto-rater found nothing wrong with it"*, and the residual
1717
+ // risk it covers is exactly the one case the rater saw nothing in — so it is true of a
1718
+ // carved command the classifier itself cleared, and false of one the classifier rated
1719
+ // `destructive` and the check below lifted. [[EXT-127]] made that second path reachable,
1720
+ // so this branch now states the case it was always describing rather than assuming it.
1721
+ // The lifted path is announced ONCE, by the merged arm of `alignmentApprovalNotice`, which
1722
+ // names the same host this line would have.
1723
+ const alignmentLifted = alignment?.kind === 'approve';
1724
+ if (carvedHosts.length > 0 && !alignmentLifted) {
1725
+ this.statusUpdate(StatusLevel.WARNING, `\n⚠ Ran a command that reaches ${carvedHosts.join(', ')} without asking you, ` +
1726
+ 'because your own message named that host and approvals is set to ' +
1727
+ // §10 rule 4 — the resolved rung is rendered in its DISPLAY spelling wherever the
1728
+ // mode is stated, never in the §9.1 identifier. This branch is reachable only at
1729
+ // that rung, so the label is read from the map rather than spelled here: a table
1730
+ // nobody has to remember to update is what keeps the two from drifting.
1731
+ `${APPROVAL_RUNG_LABELS.auto}. The auto-rater found nothing wrong with it. ` +
1732
+ 'Check the host is the one you meant.');
1733
+ }
1734
+ // [[EXT-127]] — **and an announcement whenever the ALIGNMENT CHECK is what let the
1735
+ // command run**, which is the other way a command now runs at `auto` with nobody asked.
1736
+ // The two notices are separate wherever the two causes are, because they are two
1737
+ // different claims about why nothing interrupted: the one above says the user typed the
1738
+ // host, this one says a second model read their messages and concluded the command
1739
+ // matches what they asked for. A user auditing their own session must be able to tell
1740
+ // those apart, and a shared sentence would let the weaker of the two stand in for the
1741
+ // stronger.
1742
+ //
1743
+ // **Where BOTH applied, one merged notice says both**, which is why the hosts are handed
1744
+ // to the renderer. `reachesNetwork` cannot carry them: it reads which FLOOR stood, and a
1745
+ // carved command is precisely one where none did — so a merged line keyed on it would drop
1746
+ // the host guidance on the one path where the user's own message authorised the fetch.
1747
+ //
1748
+ // **Both arms of it, not only the floored one.** A plain `destructive` lifted by the
1749
+ // checker is the COMMON case and reaches the screen through nothing else:
1750
+ // `showNegotiatedApproval` below gates on §5.3's `consecutiveRejections`, which is zero on
1751
+ // round 1 — the round most of these approvals happen on — so that path returns without
1752
+ // drawing. Announcing the rare floored arm and staying silent on the common one would put
1753
+ // the weaker guarantee on screen and leave the stronger one invisible.
1754
+ //
1755
+ // Rendered by the alignment module's own one renderer rather than spelled here, so the
1756
+ // two arms cannot come to describe one event two ways.
1757
+ if (alignmentLifted) {
1758
+ this.statusUpdate(StatusLevel.WARNING, alignmentApprovalNotice({
1759
+ command: subject.command,
1760
+ rungLabel: APPROVAL_RUNG_LABELS.auto,
1761
+ reachesNetwork: effectiveFloor?.kind === 'open-world',
1762
+ carvedHosts,
1763
+ }));
1764
+ }
1765
+ // [[TUI-C69]] §5.4/§5.5 — **the round that ENDS a negotiation is a round too**, and it
1766
+ // is the one §5.5 holds on screen. What makes an approval *negotiated* is that an
1767
+ // argument is STILL UNANSWERED when it arrives — `showNegotiatedApproval` gates on
1768
+ // §5.3's `consecutiveRejections` — and **not** that this exact command is on the
1769
+ // transcript. Read that method's docblock before changing anything here; the two
1770
+ // statements below are the ones people get wrong.
1771
+ //
1772
+ // **What is and is not held.** A first attempt rated `safe` is not held: nothing was
1773
+ // refused, so the counter is zero and there is no argument anyone could have watched.
1774
+ // But a command that merely follows someone else's argument **is** held and **is**
1775
+ // drawn — labelled `Accepted:` rather than `Agreed:`, because the rater never refused
1776
+ // that particular command. That is deliberate. The unit of the feature is the ARGUMENT,
1777
+ // and whichever call gets through is the one that ends it.
1778
+ //
1779
+ // **The exact-command rule was rejected on measurement, not taste.** An agent converges
1780
+ // by NARROWING, so the node's own canonical scenario is approved on a command the
1781
+ // transcript has never held (`git reset --hard origin/main` → `git reset --soft
1782
+ // HEAD~2`). Gating on a literal match would make that case draw nothing at all, firing
1783
+ // only when the agent FAILS to narrow — the inverse of what §5.5 is for. The match still
1784
+ // decides the LABEL below, where being wrong costs a word instead of the feature.
1785
+ //
1786
+ // **Read BEFORE `decideToolApproval`'s wrapper calls `noteProgress()` — this ordering is
1787
+ // REQUIRED, not defensive.** That wrapper resets §5.3's consecutive counter on every
1788
+ // approve, and this call site sits inside the decision it wraps, so the reset lands just
1789
+ // after the `approve` returned below. The counter it zeroes is the exact one the gate
1790
+ // reads: run it first and `showNegotiatedApproval` returns at that gate without drawing
1791
+ // or sleeping, so the hold never fires on any path — no row, no error, every surface.
1792
+ // The negotiation specs pin it (moving the reset above this call reds the §5.4/§5.5
1793
+ // cells), which is the only reason a silent, total failure is catchable here.
1794
+ await this.showNegotiatedApproval(subject.command, justification, decision.verdict);
1157
1795
  // Scope `once`: rater approvals are NEVER persisted to the allow-list.
1158
1796
  return { type: 'approve', scope: 'once' };
1159
1797
  }
1160
- if (decision.action === 'halt') {
1798
+ if (action === 'halt') {
1161
1799
  // §4.2 — not a rejection the model can respond to. It ends the agent loop.
1162
1800
  //
1163
1801
  // **`neg-04d`: a negotiation already in flight ends here too**, mid-way and without a
@@ -1170,37 +1808,107 @@ export class GthAgentRunner {
1170
1808
  // sees this state again — which is not the same as it being unobservable**, and the
1171
1809
  // difference decides whether the line is pinned. `neg-04d` asserts both halves through
1172
1810
  // the spec harness's private-state cast: the cleared transcript, and `sinceHuman` back at
1173
- // zero. That second one is what distinguishes this call from `noteProgress()`, which
1174
- // clears the transcript and the consecutive count and deliberately leaves the
1811
+ // zero. Since [[EXT-108]] both halves distinguish this call from `noteProgress()`, which
1812
+ // resets the consecutive count alone and deliberately leaves the rounds and the
1175
1813
  // reachability bound standing.
1176
- this.negotiation.humanReached();
1814
+ this.endNegotiation();
1177
1815
  // §6.1 — the banner, when an interactive surface wired one, and the halt otherwise. It
1178
1816
  // sits AFTER the reset above on purpose: a human is reached either way (that is what the
1179
1817
  // banner is), so the negotiation ends here whichever answer comes back, and neither
1180
1818
  // answer leaves a transcript behind for a later turn to argue from.
1181
- return await this.haltOrRunAnyway(subject.command, decision.verdict?.reason ?? '', record);
1819
+ return await this.haltOrRunAnyway(subject.command, decision.verdict?.reason ?? '', record, subject);
1182
1820
  }
1183
- if (decision.action === 'reject') {
1184
- // §5 — `destructive` at `auto`. The round is recorded first, so the attempt being ruled
1185
- // on is itself on the transcript the human sees (§5.6).
1186
- const outcome = this.negotiation.recordRejection({
1187
- command: subject.command,
1188
- ...(justification ? { justification } : {}),
1189
- outcome: decision.verdict?.outcome ?? 'destructive',
1190
- reason: decision.verdict?.reason ?? '',
1821
+ // §5 the attempt just ruled on, as the transcript records it.
1822
+ //
1823
+ // **ONE builder for both of the paths that record a round**, because it is one fact: an
1824
+ // attempt was made, the gate refused to let it run, and this is what each party said about
1825
+ // it. Two literal copies would be two writers of the transcript's own shape, and the copy
1826
+ // that got forgotten would be the escalating one — which is exactly the round a person is
1827
+ // being asked to rule on.
1828
+ //
1829
+ // [[EXT-127]] — the checker's own decision rides on the round, so the NEXT round's check
1830
+ // can replay it as its own turn. Absent when no check was made, which is what keeps an
1831
+ // `assisted` rejection and a floored one out of the checker's replayed history.
1832
+ const negotiationRound = () => ({
1833
+ command: subject.command,
1834
+ ...(justification ? { justification } : {}),
1835
+ outcome: decision.verdict?.outcome ?? 'destructive',
1836
+ reason: decision.verdict?.reason ?? '',
1837
+ ...(alignment ? { alignment } : {}),
1838
+ });
1839
+ /**
1840
+ * [[TUI-C69]] §5.4 — record the round and put it on the screen, in that order.
1841
+ *
1842
+ * The position is read AFTER `recordRejection`, so the count carries this round: the k-th
1843
+ * rejection sits at position k-1, which numbers it `Round k` on screen — the number the
1844
+ * escalation transcript would give the same round.
1845
+ *
1846
+ * **The round reaches the screen HERE, as it happens**, not only at the escalation this
1847
+ * argument may never reach. Emitted for the escalating round too: the exchange is rendered
1848
+ * as it happens, and the round that spends a bound is part of it. The prompt's own
1849
+ * transcript is then the summary a person rules on, which is a different job from watching
1850
+ * the argument run.
1851
+ */
1852
+ const recordAndShow = (round) => {
1853
+ const verdict = this.negotiation.recordRejection(round);
1854
+ this.showNegotiationRound({
1855
+ round,
1856
+ position: this.negotiation.counters().rejectionsSinceHuman - 1,
1191
1857
  });
1858
+ return verdict;
1859
+ };
1860
+ // [[EXT-127]] — **an escalation the CHECKER decided is a round, and is recorded as one.**
1861
+ //
1862
+ // `action` is `escalate` here, so the `reject` block below is skipped entirely and nothing
1863
+ // else on this path would record anything. Two things were lost with it, and both are the
1864
+ // reject path's stated reason for recording first: the attempt being ruled on was absent
1865
+ // from the transcript the human is shown (§5.6), and the checker's own decision — the
1866
+ // thing that ended the argument — was carried nowhere, so neither the next round, the
1867
+ // escalation prompt's payload nor the archive could say what it decided or why.
1868
+ //
1869
+ // **Only a check that actually ran and actually escalated.** A fail-closed check has
1870
+ // already been erased to `undefined` above, deliberately — a check that did not happen is
1871
+ // not a round the next one should replay as its own turn — and a floored `escalate` the
1872
+ // checker never lifted is the classifier's decision, not the checker's.
1873
+ //
1874
+ // **The verdict `recordRejection` returns is deliberately ignored**, which is the one way
1875
+ // this differs from the reject path. That return answers *"may another round be served?"*,
1876
+ // and the checker has just ruled that a person decides; letting a spare bound turn its
1877
+ // escalation back into another agent round would reverse the decision this line exists to
1878
+ // record.
1879
+ if (alignment?.kind === 'escalate')
1880
+ recordAndShow(negotiationRound());
1881
+ if (action === 'reject') {
1882
+ const outcome = recordAndShow(negotiationRound());
1192
1883
  if (outcome === 'reject') {
1193
- // §7 — the refusal PLUS the moves: re-call with a justification (the tool argument
1194
- // exists for this), call a different command, or ask the user. Rendered through the
1195
- // one builder the human's own "no" uses, differing only in who refused, so the model
1196
- // never meets two shapes of the same event.
1884
+ // [[TUI-C69]] §5.4name the call the gate is about to refuse BACK TO THE AGENT, so
1885
+ // both surfaces tone its result row as a clarification request rather than as a failed
1886
+ // tool. Only this branch: an escalation, a human's "no", a deny entry and the §8
1887
+ // floor's refusal are all refusals rather than rounds of an argument.
1888
+ if (tool.id)
1889
+ this.agent?.noteRaterClarification?.(tool.id);
1890
+ // §7 — the refusal PLUS the moves the model actually has: re-call with a
1891
+ // justification (the tool argument exists for this), or call a different command.
1892
+ // Rendered through the one builder the human's own "no" uses, differing only in who
1893
+ // refused, so the model never meets two shapes of the same event.
1894
+ // [[EXT-127]] — **the checker's requested change is APPENDED to the rater's rejection,
1895
+ // never substituted for it.** The two say different things and the agent needs both:
1896
+ // the rater says what is wrong with the command, the checker says what would make it
1897
+ // match what the user asked for. Substituting would also silently change who the
1898
+ // rejection is attributed to on every surface that renders it, which is a display
1899
+ // decision this node deliberately does not make.
1900
+ const rejection = buildRejectionMessage({
1901
+ source: 'rater',
1902
+ toolName: tool.name,
1903
+ verdict: decision.verdict,
1904
+ });
1197
1905
  return {
1198
1906
  type: 'reject',
1199
- message: buildRejectionMessage({
1200
- source: 'rater',
1201
- toolName: tool.name,
1202
- verdict: decision.verdict,
1203
- }),
1907
+ message: alignment?.kind === 'suggest' && alignment.reason.trim().length > 0
1908
+ ? `${rejection}\n\nThe alignment check also asked for a change: ${alignment.reason.trim()}${alignment.suggestedCommand
1909
+ ? `\nIt suggested: ${alignment.suggestedCommand.trim()}`
1910
+ : ''}`
1911
+ : rejection,
1204
1912
  };
1205
1913
  }
1206
1914
  // A bound is spent — the agent and the rater cannot agree, and that is a human's call.
@@ -1247,21 +1955,38 @@ export class GthAgentRunner {
1247
1955
  // recorder having failed, which is the opposite of what happened.
1248
1956
  record.stage ??= 'unrated-rung';
1249
1957
  const negotiationRounds = this.negotiation.transcript();
1250
- // §5.3 — **the count the human is given, and it is NOT the transcript's length.** An approved
1251
- // call clears the transcript, so the rounds above are the attempts since the last approval
1252
- // while the fact the reader is weighing is how hard the agent pushed since the last person:
1253
- // the agent that stashed between refusals had five refused attempts and three surviving
1254
- // rounds. Read HERE, one line before `humanReached()` spends it after, it is zero.
1958
+ // §5.3 — **the count the human is given: how hard the agent pushed since the last person.**
1959
+ // [[EXT-108]] brought it into agreement with the transcript's length, because an approved call
1960
+ // now erases no rounds and reaching a person clears both together. It is still passed rather
1961
+ // than left to the renderer's fallback: a screen too small for every round prints a slice, and
1962
+ // this is the number the heading over that slice has to carry. Read HERE, one line before
1963
+ // `humanReached()` spends it — after, it is zero.
1255
1964
  const negotiationAttempts = this.negotiation.counters().rejectionsSinceHuman;
1256
1965
  // Reaching a person ends the negotiation (§5.3) and is the ONE thing that clears the
1257
1966
  // reachability bound: an escalation the human is about to answer is exactly the event that
1258
1967
  // bound exists to make happen, so it is spent here rather than accumulated across it.
1259
- this.negotiation.humanReached();
1968
+ this.endNegotiation();
1260
1969
  if (!this.toolApprovalCallback) {
1261
1970
  // §6.2 — no one to ask. Exit non-zero with everything a person needs, rather than handing
1262
1971
  // the model a rejection it would just work around. The transcript goes into the message
1263
1972
  // because that message is the only thing anyone sees on this path.
1264
- throw new NonInteractiveEscalationError(command ?? tool.name, safetyVerdict?.outcome, safetyVerdict?.reason, escalatedBy, renderNegotiationTranscript(negotiationRounds, negotiationAttempts) ?? undefined);
1973
+ //
1974
+ // [[EXT-106]] §4 — including the `approvals.allow` entry that would let this run, derived
1975
+ // HERE because only this scope knows the SUBJECT.
1976
+ //
1977
+ // [[EXT-115]] — **each kind through its own derivation, and both through the SAME ones the
1978
+ // escalation menu stores.** A shell command resolves to its normalized self; a tool or MCP
1979
+ // call resolves to the tool's identity plus the host it named, which is what §4.7.4 says a
1980
+ // grant for one records. Deriving a `shell` entry from a tool name — the shape this used to
1981
+ // be unable to avoid — would be a pasteable line that `matchEntry` refuses outright on the
1982
+ // type alone. `toolGrantEntry` answers `null` for a call whose MCP server could not be
1983
+ // attributed, which the ternary below already turns into the general form: an entry naming
1984
+ // the unresolved sentinel would be written and then dropped by the grammar's own validator.
1985
+ const allowEntry = subject.kind === 'shell' ? shellApprovalEntryFor(subject.command) : toolGrantEntry(subject);
1986
+ throw new NonInteractiveEscalationError(command ?? tool.name, safetyVerdict?.outcome, safetyVerdict?.reason, escalatedBy, renderNegotiationTranscript(negotiationRounds, negotiationAttempts) ?? undefined, allowEntry ? renderApprovalEntryObject(allowEntry) : undefined,
1987
+ // [[EXT-115]] — the discriminator the whole decision above ran on, so the message names
1988
+ // what it gated instead of calling a `write_file` or an MCP call a `Command`.
1989
+ subject);
1265
1990
  }
1266
1991
  // §4.2 — **a `catastrophic` approval is NEVER sticky.** "The human may approve this one
1267
1992
  // invocation, and only this one": no always-allow, and no session-scoped allow either. The
@@ -1328,16 +2053,37 @@ export class GthAgentRunner {
1328
2053
  // they said" are two different questions, and collapsing them into one field is what makes a
1329
2054
  // dump unable to tell a rater escalation from a declared one.
1330
2055
  record.humanAnswer = decision.type === 'approve' ? 'approve' : 'reject';
2056
+ // [[EXT-150]] — **what the answer landed as**, which is not what the answer asked for. It starts
2057
+ // at `once` and only a record that was actually made moves it, so the three paths that store
2058
+ // nothing report the one-shot answer they really are: an approve with no grant on offer (a
2059
+ // `catastrophic` verdict, a command that does not statically resolve, a call nothing can
2060
+ // attribute), a reject carrying no scope, and a reject at a scope for a call with no deny entry.
2061
+ let lifetime = 'once';
1331
2062
  // Record the human's scoped grant so the same call stops re-prompting.
1332
2063
  if (decision.type === 'approve' && grant) {
1333
- this.recordApproval(grant, decision.scope ?? 'once');
2064
+ lifetime = this.recordApproval(grant, decision.scope ?? 'once');
1334
2065
  }
1335
2066
  // §6 — and the mirror: *always reject* records the refusal, so the next identical call is
1336
- // refused by rule at step (1) without reaching a person. Scoped `session` and nothing else,
1337
- // because that is the only lifetime the store has (see {@link ToolRejectScope}).
1338
- if (decision.type === 'reject' && decision.scope === 'session' && denyEntry) {
1339
- this.recordDenial(denyEntry);
2067
+ // refused by rule at step (1) without reaching a person. [[EXT-107]] at the scope the surface
2068
+ // asked for, which for every escalation menu's `[d]` is `always`; the lifetime that LANDS is
2069
+ // `recordDenial`'s to decide and to report.
2070
+ if (decision.type === 'reject' && decision.scope !== undefined && denyEntry) {
2071
+ lifetime = this.recordDenial(denyEntry, decision.scope);
1340
2072
  }
2073
+ return this.reportOutcome(pending, decision, lifetime);
2074
+ }
2075
+ /**
2076
+ * [[EXT-150]] — tell the surface what its own answer landed as, and hand the decision straight
2077
+ * back.
2078
+ *
2079
+ * A one-liner returning the decision, exactly as {@link stage} is, and for the identical reason:
2080
+ * the report happens ON the return that carries the decision rather than on the line above it, so
2081
+ * no early return can later be inserted between the two and leave a surface waiting for an answer
2082
+ * that never comes. There is one return out of {@link decideToolApprovalInner} after the callback
2083
+ * is awaited, and this is it.
2084
+ */
2085
+ reportOutcome(pending, decision, lifetime) {
2086
+ this.approvalOutcomeCallback?.({ pending, decision: decision.type, lifetime });
1341
2087
  return decision;
1342
2088
  }
1343
2089
  /**
@@ -1362,8 +2108,8 @@ export class GthAgentRunner {
1362
2108
  const approvals = this.sessionApprovals;
1363
2109
  const verdict = await rateShellCommand(command, this.config, {
1364
2110
  home: env?.HOME,
1365
- negotiation: opts.negotiation,
1366
2111
  negotiable: opts.negotiable,
2112
+ carved: opts.carved,
1367
2113
  // [[TUI-C27]] — the sink fires BEFORE the model is invoked, with the prompt that is about to
1368
2114
  // be sent, so the record carries what the rater was SHOWN rather than a later re-render of
1369
2115
  // it. Assigning it here (rather than pushing a finished record afterwards) is what makes a
@@ -1404,8 +2150,61 @@ export class GthAgentRunner {
1404
2150
  (this.raterTimeouts > 1 ? ` — ${this.raterTimeouts} times this session` : '') +
1405
2151
  '. Raise approvals.raterTimeoutMs if the rater is a local model.');
1406
2152
  }
2153
+ // [[EXT-82]] — the RATE, not the call. EXT-66's notice above explains ONE occurrence and says
2154
+ // nothing about a session in which the rater never answers at all; this one says that, once,
2155
+ // and the tracker is what makes it a rate rather than a latch. The inputs come from the call's
2156
+ // own capture rather than from the verdict's wording: the rater is itself instructed to say it
2157
+ // could not assess a command, so a reason-prefix test would count a model that obeyed as a gate
2158
+ // that failed.
2159
+ const rating = record.rating;
2160
+ if (rating) {
2161
+ const signal = this.raterHealth.record({
2162
+ failClosed: rating.failClosed,
2163
+ failure: rating.providerError,
2164
+ model: rating.model,
2165
+ // §3.2 — an allow match already decided this call, so a failed tripwire rating did not make
2166
+ // a verdict default and must not be counted as though it had.
2167
+ countsTowardRate: !opts.allowMatched,
2168
+ });
2169
+ if (signal)
2170
+ this.statusUpdate(StatusLevel.WARNING, signal);
2171
+ }
1407
2172
  return verdict;
1408
2173
  }
2174
+ /**
2175
+ * [[EXT-127]] — **one alignment check**, with the `user` role fed from the settled provenance
2176
+ * channel and nothing else.
2177
+ *
2178
+ * **`provenance` is `ShellNegotiationState.retainedUserMessages()`, read ONCE by the caller and
2179
+ * handed down**, exactly as §4.6's floor reads it. Three things are true of it and only of it: it
2180
+ * is EMPTY until `admitUserProvenance` positively established that this session's human turns are
2181
+ * the user's own words (so `review` and `pr`, which fold a fetched diff into a human message,
2182
+ * contribute nothing); it is not `noteUserMessages`' raw store, which answers a different question
2183
+ * for a different reader; and it is not `humanMessageTexts`, the unfiltered upstream. Reading any
2184
+ * of the other three here would make the one place this design says provenance is structural the
2185
+ * one place it is not.
2186
+ *
2187
+ * The prior rounds come from the transcript's own alignment decisions, so the checker meets its
2188
+ * earlier turns as its own turns rather than as a quoted summary of them.
2189
+ */
2190
+ async checkAlignment(subject, provenance, record) {
2191
+ const approvals = this.sessionApprovals;
2192
+ return await runAlignmentCheck(subject, this.config, {
2193
+ // The checker's own model when a profile resolved one; `undefined` falls back to the session
2194
+ // model, exactly as the classifier's does.
2195
+ model: this.alignmentCheckerModel,
2196
+ userMessages: provenance,
2197
+ priorRounds: this.negotiation.alignmentRounds(),
2198
+ home: env?.HOME,
2199
+ // ONE budget for ONE gate decision. There is deliberately no second timeout: `raterTimeoutMs`
2200
+ // is what the user owns, and a check is one call.
2201
+ timeoutMs: approvals.raterTimeoutMs,
2202
+ ...(approvals.alignmentChecker ? { profile: approvals.alignmentChecker } : {}),
2203
+ onCapture: (capture) => {
2204
+ record.alignment = capture;
2205
+ },
2206
+ });
2207
+ }
1409
2208
  /**
1410
2209
  * EXT-58 (§4.3/§4.4) — the built-in tools already granted at the session's CURRENT rung, as
1411
2210
  * names plus one-line locally-authored descriptions, for the rater prompt.
@@ -1458,7 +2257,22 @@ export class GthAgentRunner {
1458
2257
  const approvals = this.sessionApprovals;
1459
2258
  const persisted = approvals.rung === 'bypass' ? null : this.getPersistedGrants();
1460
2259
  return {
1461
- deny: [...approvals.deny, ...this.denyGrants.entries()],
2260
+ // [[EXT-107]] — three sources of refusal, and the persisted one is read at EVERY rung,
2261
+ // `bypass` included. That is not an oversight of the allow side's guard, it is the opposite
2262
+ // of it: deny is resolved at step (1) of `decideToolApprovalInner`, *before* the `bypass`
2263
+ // return, so a saved refusal that switched itself off at `bypass` would be a promise the file
2264
+ // stops keeping the moment someone relaxes the gate — the one direction this design never
2265
+ // fails in. Reading it there is also side-effect-free: the deny store runs no migration, so
2266
+ // its load never writes (see `PersistedApprovalGrantsOptions.legacyPrefixMigration`).
2267
+ //
2268
+ // Concatenation order decides nothing about deny-vs-allow — `resolveApprovalRules` consults
2269
+ // the whole deny list before any allow entry — so a command matching a saved allow AND a
2270
+ // saved deny is refused, whichever file was written first.
2271
+ deny: [
2272
+ ...approvals.deny,
2273
+ ...this.denyGrants.entries(),
2274
+ ...(this.getPersistedDenials()?.entries() ?? []),
2275
+ ],
1462
2276
  escalate: approvals.escalate,
1463
2277
  allow: [...approvals.allow, ...this.sessionGrants.entries(), ...(persisted?.entries() ?? [])],
1464
2278
  };
@@ -1482,6 +2296,9 @@ export class GthAgentRunner {
1482
2296
  const filePath = getGslothConfigWritePath(SHELL_ALLOWLIST_FILE);
1483
2297
  this.persistedGrants = new PersistedApprovalGrants(filePath, {
1484
2298
  onNotice: (notice) => this.statusUpdate(notice.level, notice.message),
2299
+ // [[EXT-143]] — the word a load-failure notice uses for what this file holds. The store is
2300
+ // list-agnostic and stays so; only the message needs to know, and only the caller can say.
2301
+ holds: 'approvals',
1485
2302
  });
1486
2303
  }
1487
2304
  catch (e) {
@@ -1491,6 +2308,50 @@ export class GthAgentRunner {
1491
2308
  }
1492
2309
  return this.persistedGrants;
1493
2310
  }
2311
+ /**
2312
+ * [[EXT-107]] — lazily load (once per instance) the persisted **refusal** store, the mirror of
2313
+ * {@link getPersistedGrants}. Returns null when it cannot be loaded at all, in which case an
2314
+ * `always` refusal degrades to `session`.
2315
+ *
2316
+ * **No `bypass` guard, and no v1 migration**, and the two are the same decision. The allow side
2317
+ * skips the file at `bypass` so a session with the gate switched off neither reads nor *rewrites*
2318
+ * the project's grants; the only thing that rewrites on load is the v1 `prefixes` migration, and
2319
+ * turning it off here makes this store's load a pure read. That is what lets it be consulted at
2320
+ * every rung — which it must be, because a refusal is resolved before the `bypass` return and a
2321
+ * saved refusal that lapsed when the gate was relaxed would be worthless.
2322
+ *
2323
+ * It is also loaded by the `/approvals` DISPLAY, unlike its allow-side twin. The rule there — a
2324
+ * display must not create the store in order to show it — is a rule about writing, and this load
2325
+ * cannot write. The reason to break the symmetry is that the display is the escape hatch: a saved
2326
+ * refusal a user cannot see is one they cannot lift, and a fresh session has made no gated call
2327
+ * yet.
2328
+ */
2329
+ getPersistedDenials() {
2330
+ if (this.persistedDenialsLoaded)
2331
+ return this.persistedDenials;
2332
+ this.persistedDenialsLoaded = true;
2333
+ try {
2334
+ const filePath = getGslothConfigWritePath(SHELL_DENYLIST_FILE);
2335
+ this.persistedDenials = new PersistedApprovalGrants(filePath, {
2336
+ onNotice: (notice) => this.statusUpdate(notice.level, notice.message),
2337
+ legacyPrefixMigration: false,
2338
+ // [[EXT-143]] — the mirror of the allow store's word, and the reason the store takes one at
2339
+ // all: a broken deny file loses refusals, and a message that said "approvals" would name
2340
+ // the wrong loss in the one place the user has to act on it.
2341
+ holds: 'refusals',
2342
+ });
2343
+ }
2344
+ catch (e) {
2345
+ // Path/IO failure → behave as no persisted refusals. This one is NOT safe in the way the
2346
+ // allow side's failure is: it loses refusals rather than approvals, so nothing here refuses
2347
+ // any more and the call falls to whatever else covers it — a prompt at most rungs, and no
2348
+ // prompt at all at `bypass` or under a matching allow entry, where it simply runs. It still
2349
+ // degrades rather than ending the run, and [[EXT-143]]'s notice is what makes it visible.
2350
+ debugLogError('Loading persisted shell refusals', e);
2351
+ this.persistedDenials = null;
2352
+ }
2353
+ return this.persistedDenials;
2354
+ }
1494
2355
  /**
1495
2356
  * §3.1/§4.7.4/§6 — **the grant a sticky choice would write for this call**, or `undefined` when
1496
2357
  * none is on offer. The one place that question is answered, so the menu's *this is what will be
@@ -1540,9 +2401,8 @@ export class GthAgentRunner {
1540
2401
  if (this.sessionApprovals.rung === 'bypass')
1541
2402
  return undefined;
1542
2403
  if (subject.kind === 'shell') {
1543
- if (classifyCommand(subject.command, normalizeCommand) === null)
1544
- return undefined;
1545
- return { entry: shellGrantEntry(subject.command) };
2404
+ const entry = shellApprovalEntryFor(subject.command);
2405
+ return entry ? { entry } : undefined;
1546
2406
  }
1547
2407
  if (subject.name === SHELL_TOOL_NAME)
1548
2408
  return undefined;
@@ -1585,7 +2445,7 @@ export class GthAgentRunner {
1585
2445
  * - **`run_shell_command` arriving as a TOOL subject gets a tool entry** — a shell call whose
1586
2446
  * `command` argument cannot even be read. On the allow side that entry would auto-approve every
1587
2447
  * future unreadable shell call, which is why it is excluded there; as a refusal it stops the
1588
- * shell tool for the session, and the dialog says so in the words the entry is written in.
2448
+ * shell tool outright, and the dialog says so in the words the entry is written in.
1589
2449
  *
1590
2450
  * The one genuine exclusion is an **MCP call whose server could not be attributed**
1591
2451
  * ({@link toolGrantEntry} returns `null`): the grammar's `server` cannot be the empty string, so
@@ -1601,19 +2461,51 @@ export class GthAgentRunner {
1601
2461
  return toolGrantEntry(subject) ?? undefined;
1602
2462
  }
1603
2463
  /**
1604
- * §6 — record the menu's *always reject* choice, for the life of this runner instance.
2464
+ * §6/[[EXT-107]] — record the menu's *always reject* choice at the given scope. `once` remembers
2465
+ * nothing; `session` holds the refusal for the life of this runner instance; `always` additionally
2466
+ * writes it to the project's deny file, so it is still in force after a restart.
2467
+ *
2468
+ * It lands in the same lists `approvals.deny` entries are matched from ({@link approvalRuleLists}
2469
+ * concatenates the three), so a refusal the human made at the prompt and one they wrote in their
2470
+ * config are one list to the matcher.
2471
+ *
2472
+ * **The recorded scope is derived from whether the entry REACHED THE FILE** ([[EXT-149]]) — what
2473
+ * {@link PersistedApprovalGrants.add} returns — and not from whether the store was allowed to try.
2474
+ * Those differ on a read-only checkout, where the file is simply absent, so the load did not fail,
2475
+ * `canPersist()` is true, and every write throws: asking permission stamped `always` on an answer
2476
+ * that reached no disk, and nothing told the user. **Do not describe that gap's cost as "one
2477
+ * re-prompt next session":** the entry is not on disk, so next session it applies to nothing and
2478
+ * the call is left to the rest of the gate — another rule may refuse it, it may be prompted for,
2479
+ * or it may run without asking under `bypass` or a matching saved allow.
1605
2480
  *
1606
- * It lands in the same store `approvals.deny` entries are matched from ({@link approvalRuleLists}
1607
- * concatenates the two), so a refusal the human made at the prompt and one they wrote in their
1608
- * config are one list to the matcher and one list to `/approvals`.
2481
+ * **Two records, not one patched afterwards.** {@link ApprovalGrantStore.add} holds the very
2482
+ * object it is handed whenever the grant carries no annotation snapshot which every refusal
2483
+ * does, since {@link denyEntryFor} builds an entry and never a snapshot — so stamping one object
2484
+ * `always` and correcting it after the write would reach inside whatever {@link denyGrants}
2485
+ * already holds and whatever the display renders from it. The persisted store is handed the
2486
+ * `always` record it will hold if the write lands (and takes back if it does not); the session
2487
+ * store is handed its own record, stamped from the answer.
1609
2488
  *
1610
- * **Session-lifetime, and there is nothing else to choose.** There is no persisted deny file;
1611
- * whether there should be is a question about a file users live with, not about this prompt. What
1612
- * the surfaces must not do is say otherwise — a confirmation promising a persistence that did not
1613
- * happen is §6's *offered and then refused* with the evidence hidden.
2489
+ * The persisted store is still told even when it cannot write, because telling it is what reports
2490
+ * the refused or failed write to the user, and it declines to hold what it did not write.
2491
+ *
2492
+ * **It RETURNS the lifetime it recorded** ([[EXT-150]]) the same value it stamps the record with,
2493
+ * handed back rather than left for a caller to re-derive. The surface that asked has to say what
2494
+ * happened, and re-deriving it there would put the "did the write land" question in two places
2495
+ * that could come to disagree, which is the whole failure this and [[EXT-149]] are about.
1614
2496
  */
1615
- recordDenial(entry) {
1616
- this.denyGrants.add({ entry, grantedAt: new Date().toISOString(), scope: 'session' });
2497
+ recordDenial(entry, scope) {
2498
+ if (scope === 'once')
2499
+ return 'once';
2500
+ const persisted = scope === 'always' ? this.getPersistedDenials() : null;
2501
+ const grantedAt = new Date().toISOString();
2502
+ const saved = persisted?.add({ entry, grantedAt, scope: 'always' }) ?? false;
2503
+ const landed = saved ? 'always' : 'session';
2504
+ // Both stores, exactly as `recordApproval` writes both: the in-memory copy is what keeps the
2505
+ // refusal in force for this run even when the file cannot be written, and the display
2506
+ // de-duplicates by entry identity.
2507
+ this.denyGrants.add({ entry, grantedAt, scope: landed });
2508
+ return landed;
1617
2509
  }
1618
2510
  /**
1619
2511
  * §3.1/§6 — record a human-granted approval at the given scope. `once` remembers nothing.
@@ -1622,20 +2514,34 @@ export class GthAgentRunner {
1622
2514
  *
1623
2515
  * What is recorded was decided by {@link stickyGrantFor} and shown to the human before they
1624
2516
  * answered; this only stamps it with when and at what scope.
2517
+ *
2518
+ * **The stamped scope says whether this grant REACHED THE FILE**, the mirror of
2519
+ * {@link recordDenial} and derived the same way ([[EXT-149]]): an `always` whose store is absent,
2520
+ * whose file could not be read and so must not be rewritten ([[EXT-144]]), or whose write threw on
2521
+ * a checkout nothing can write, is recorded as the `session` grant it actually is — and the store
2522
+ * reports the last of those rather than swallowing it.
2523
+ *
2524
+ * Two records rather than one patched afterwards, for the reason argued in {@link recordDenial}:
2525
+ * {@link ApprovalGrantStore.add} holds the object it is handed for a grant with no annotation
2526
+ * snapshot — every shell grant — so a scope corrected after the write would be corrected inside
2527
+ * {@link sessionGrants}.
2528
+ *
2529
+ * The store is still loaded only for `always`, unchanged: a display must not create the file in
2530
+ * order to show it, and a `session` grant has no business opening it.
2531
+ *
2532
+ * **It RETURNS the lifetime it recorded** ([[EXT-150]]), the mirror of {@link recordDenial} and for
2533
+ * the same reason: the surface's confirmation is written from this value rather than from the key
2534
+ * the human pressed.
1625
2535
  */
1626
2536
  recordApproval(grant, scope) {
1627
2537
  if (scope === 'once')
1628
- return;
1629
- const grantScope = scope;
1630
- const record = {
1631
- ...grant,
1632
- grantedAt: new Date().toISOString(),
1633
- scope: grantScope,
1634
- };
1635
- this.sessionGrants.add(record);
1636
- if (scope === 'always') {
1637
- this.getPersistedGrants()?.add(record);
1638
- }
2538
+ return 'once';
2539
+ const persisted = scope === 'always' ? this.getPersistedGrants() : null;
2540
+ const grantedAt = new Date().toISOString();
2541
+ const saved = persisted?.add({ ...grant, grantedAt, scope: 'always' }) ?? false;
2542
+ const grantScope = saved ? 'always' : 'session';
2543
+ this.sessionGrants.add({ ...grant, grantedAt, scope: grantScope });
2544
+ return grantScope;
1639
2545
  }
1640
2546
  /**
1641
2547
  * §4.7.4 — **drop a tool grant the tool has since weakened out from under, with a notice naming
@@ -1729,12 +2635,32 @@ export class GthAgentRunner {
1729
2635
  // standing from the previous one and clears BOTH bounds. The turn's own messages then enter
1730
2636
  // §5.1's last-5 window, which is what makes "just the last two" reach the rater at all — the
1731
2637
  // reply that narrows what the agent proposes is worthless to the gate if only the agent hears it.
1732
- this.negotiation.humanReached();
2638
+ this.endNegotiation();
2639
+ // [[TUI-C69]] §5.4 — and the tone hints go with it. The ids matter only while the results
2640
+ // carrying them are on screen; the previous turn's are spent, and an id that outlived its turn
2641
+ // could only ever mis-tone a later row.
2642
+ this.clearRaterClarifications();
1733
2643
  this.negotiation.noteUserMessages(humanMessageTexts(messages));
1734
2644
  debugLog('Processing messages (event stream)...');
1735
2645
  debugLogObject('Input Messages', messages);
1736
- yield* this.agent.streamWithEvents(messages, this.runConfig, signal);
1737
- yield* this.resolveToolInterruptsWithEvents(signal);
2646
+ try {
2647
+ yield* this.agent.streamWithEvents(messages, this.runConfig, signal);
2648
+ yield* this.resolveToolInterruptsWithEvents(signal);
2649
+ }
2650
+ finally {
2651
+ // [[TUI-C69]] §5.4 — **the turn is over, so the argument is over.** Until this existed the
2652
+ // panel was cleared only by the NEXT turn's `endNegotiation`, so a negotiation that CONVERGED
2653
+ // — the case this node exists to make visible — left its rounds pinned in the non-scrolling
2654
+ // dock across exactly the idle period in which the user is trying to type into a prompt those
2655
+ // rows have pushed off the screen. An escalation cleared itself; success did not.
2656
+ //
2657
+ // Display-only: see {@link clearNegotiationDisplay}. The gate's transcript belongs to the
2658
+ // negotiation, not to the screen, and the next turn spends it on the human being reached.
2659
+ //
2660
+ // In `finally` because an abort and a thrown stream end the turn just as much as a return
2661
+ // does, and those are the paths where rows left standing are least likely to be noticed.
2662
+ this.clearNegotiationDisplay();
2663
+ }
1738
2664
  }
1739
2665
  /**
1740
2666
  * Event-stream counterpart to {@link resolveToolInterrupts}: after a streamed run ends,
@@ -1831,6 +2757,11 @@ export class GthAgentRunner {
1831
2757
  // last-5 window is conversation context; leaving it behind a `/clear` would quote the user's
1832
2758
  // previous conversation into a rating made after they asked for it to be forgotten.
1833
2759
  this.negotiation.clear();
2760
+ this.clearNegotiationDisplay();
2761
+ // [[TUI-C69]] §5.4 — the noted tool-call ids are state from before the `/clear` too, and the
2762
+ // sentence above is the whole argument for dropping them: they would decide how rows are drawn
2763
+ // in a conversation the user has just asked to start fresh.
2764
+ this.clearRaterClarifications();
1834
2765
  this.runConfig = getNewRunnableConfig();
1835
2766
  debugLogObject('Reset Runnable Config', this.runConfig);
1836
2767
  }