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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/config/schema.d.ts +48 -48
  2. package/dist/config/schema.js +55 -29
  3. package/dist/config/schema.js.map +1 -1
  4. package/dist/config/shell-policy.d.ts +241 -26
  5. package/dist/config/shell-policy.js +353 -52
  6. package/dist/config/shell-policy.js.map +1 -1
  7. package/dist/config/tool-descriptions.d.ts +78 -32
  8. package/dist/config/tool-descriptions.js +72 -29
  9. package/dist/config/tool-descriptions.js.map +1 -1
  10. package/dist/config/types.d.ts +9 -4
  11. package/dist/config/types.js.map +1 -1
  12. package/dist/core/GthAbstractAgent.d.ts +13 -5
  13. package/dist/core/GthAbstractAgent.js +42 -13
  14. package/dist/core/GthAbstractAgent.js.map +1 -1
  15. package/dist/core/GthAgentRunner.d.ts +203 -9
  16. package/dist/core/GthAgentRunner.js +646 -39
  17. package/dist/core/GthAgentRunner.js.map +1 -1
  18. package/dist/core/GthLangChainAgent.js +58 -17
  19. package/dist/core/GthLangChainAgent.js.map +1 -1
  20. package/dist/core/reasoningBlocks.d.ts +60 -0
  21. package/dist/core/reasoningBlocks.js +98 -0
  22. package/dist/core/reasoningBlocks.js.map +1 -0
  23. package/dist/core/refusal.js +6 -2
  24. package/dist/core/refusal.js.map +1 -1
  25. package/dist/core/shell/approvalCapture.d.ts +271 -0
  26. package/dist/core/shell/approvalCapture.js +108 -0
  27. package/dist/core/shell/approvalCapture.js.map +1 -0
  28. package/dist/core/shell/approvalStop.d.ts +16 -7
  29. package/dist/core/shell/approvalStop.js +18 -7
  30. package/dist/core/shell/approvalStop.js.map +1 -1
  31. package/dist/core/shell/escalationSeverity.d.ts +141 -0
  32. package/dist/core/shell/escalationSeverity.js +89 -0
  33. package/dist/core/shell/escalationSeverity.js.map +1 -0
  34. package/dist/core/shell/framing.d.ts +190 -0
  35. package/dist/core/shell/framing.js +633 -0
  36. package/dist/core/shell/framing.js.map +1 -0
  37. package/dist/core/shell/hardline.d.ts +81 -0
  38. package/dist/core/shell/hardline.js +607 -0
  39. package/dist/core/shell/hardline.js.map +1 -0
  40. package/dist/core/shell/negotiation.d.ts +249 -0
  41. package/dist/core/shell/negotiation.js +355 -0
  42. package/dist/core/shell/negotiation.js.map +1 -0
  43. package/dist/core/shell/normalize.d.ts +5 -5
  44. package/dist/core/shell/normalize.js +5 -5
  45. package/dist/core/shell/rater.d.ts +353 -18
  46. package/dist/core/shell/rater.js +556 -39
  47. package/dist/core/shell/rater.js.map +1 -1
  48. package/dist/core/shell/rejection.d.ts +1 -1
  49. package/dist/core/types.d.ts +97 -4
  50. package/dist/providers/geminiThinking.d.ts +52 -0
  51. package/dist/providers/geminiThinking.js +72 -0
  52. package/dist/providers/geminiThinking.js.map +1 -0
  53. package/dist/providers/google-genai.js +4 -1
  54. package/dist/providers/google-genai.js.map +1 -1
  55. package/dist/providers/vertexai.js +4 -1
  56. package/dist/providers/vertexai.js.map +1 -1
  57. package/dist/runtime/askStructured.d.ts +45 -3
  58. package/dist/runtime/askStructured.js +46 -5
  59. package/dist/runtime/askStructured.js.map +1 -1
  60. package/dist/runtime/conversation.d.ts +2 -1
  61. package/dist/runtime/conversation.js +2 -1
  62. package/dist/runtime/conversation.js.map +1 -1
  63. package/dist/runtime/singleShot.d.ts +3 -2
  64. package/dist/runtime/singleShot.js +3 -2
  65. package/dist/runtime/singleShot.js.map +1 -1
  66. package/dist/utils/debugDump.d.ts +19 -0
  67. package/dist/utils/debugDump.js +11 -0
  68. package/dist/utils/debugDump.js.map +1 -1
  69. package/dist/utils/displayWidth.d.ts +23 -0
  70. package/dist/utils/displayWidth.js +61 -6
  71. package/dist/utils/displayWidth.js.map +1 -1
  72. package/dist/utils/systemPromptNotes.d.ts +12 -1
  73. package/dist/utils/systemPromptNotes.js +24 -2
  74. package/dist/utils/systemPromptNotes.js.map +1 -1
  75. package/package.json +1 -1
  76. package/schema/gsloth-config.schema.json +48 -48
@@ -1,12 +1,17 @@
1
- import { DEFAULT_APPROVAL_RUNG, describeGrantedBuiltInTools, isRatedRung, resolveApprovals, resolveShellApprovalGate, SHELL_TOOL_NAME, TOOL_ANNOTATION_HINTS, } from '#src/config.js';
1
+ import { DEFAULT_APPROVAL_RUNG, describeGrantedBuiltInTools, isNegotiatingRung, isRatedRung, isToolGatedAtRung, resolveApprovals, resolveGatedToolNames, resolveShellApprovalGate, SHELL_TOOL_NAME, TOOL_ANNOTATION_HINTS, } from '#src/config.js';
2
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';
5
5
  import { renderApprovalEntryObject } from '#src/config/schema.js';
6
6
  import { classifyCommand } from '#src/core/shell/arity.js';
7
+ import { describeAbstention } from '#src/core/shell/abstention.js';
7
8
  import { normalizeCommand } from '#src/core/shell/normalize.js';
8
9
  import { ApprovalStopError, AttackHaltError, NonInteractiveEscalationError, } from '#src/core/shell/approvalStop.js';
9
- import { applyDestructiveFloor, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
10
+ import { applyDestructiveFloor, isBelowDestructiveFloor, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, preflightFloorFinding, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
11
+ import { ApprovalCaptureLog, } from '#src/core/shell/approvalCapture.js';
12
+ import { buildHardlineRefusal, checkHardline } from '#src/core/shell/hardline.js';
13
+ import { renderNegotiationTranscript, ShellNegotiationState } from '#src/core/shell/negotiation.js';
14
+ import { buildRejectionMessage } from '#src/core/shell/rejection.js';
10
15
  import { describeApprovalEntry, resolveApprovalRules, } from '#src/core/approvals/matcher.js';
11
16
  import { createEffectiveToolAnnotationSource, trustedAnnotationHints, } from '#src/core/approvals/annotations.js';
12
17
  import { approvalSubjectForToolName } from '#src/core/approvals/mcpSubjects.js';
@@ -37,6 +42,80 @@ function copyApprovalEntry(entry) {
37
42
  return { ...entry };
38
43
  return { ...entry, pattern: { ...entry.pattern } };
39
44
  }
45
+ /**
46
+ * [[EXT-29]] §5.1 — the `justification` argument of a `run_shell_command` call, when the model
47
+ * supplied a usable one.
48
+ *
49
+ * Read defensively for the same reason `command` is: these are model-authored arguments arriving
50
+ * through a schema the graph validated but that this method does not re-validate, so a non-string
51
+ * or a whitespace-only value is *absent* rather than a second spelling of empty.
52
+ *
53
+ * **What the trim buys is the RECORDED ROUND, not the prompt.** The rating prompt is already safe
54
+ * without it — `buildNegotiationContextBlock` drops a blank justification before it renders a fence,
55
+ * and `renderNegotiationTranscript` drops one before it renders a line. What only this can do is
56
+ * keep the round itself honest at the point it is written: a round carrying a whitespace-only
57
+ * justification asserts that the agent argued something it did not, to everything that later reads
58
+ * the transcript rather than a rendering of it. Both downstream guards then stay defence in depth
59
+ * instead of being the only thing between a blank string and that claim.
60
+ */
61
+ function shellJustification(args) {
62
+ const value = args?.justification;
63
+ if (typeof value !== 'string')
64
+ return undefined;
65
+ return value.trim().length === 0 ? undefined : value;
66
+ }
67
+ /**
68
+ * [[EXT-29]] §5.1 — the text of the human messages in a turn's input, for the rater's last-5 window.
69
+ *
70
+ * Structural and fail-soft, like `runStats`'s accumulator: the runner is handed `BaseMessage`s from
71
+ * several surfaces (readline, TUI, ACP, AG-UI) and a multimodal turn's `content` is an array of
72
+ * blocks rather than a string. Only the text is taken — §4.3 admits no file contents, no tool
73
+ * output and no fetched pages, and an image block is none of the three.
74
+ */
75
+ function humanMessageTexts(messages) {
76
+ const texts = [];
77
+ for (const message of messages) {
78
+ try {
79
+ const type = message?.getType?.();
80
+ if (type !== 'human')
81
+ continue;
82
+ const content = message.content;
83
+ if (typeof content === 'string') {
84
+ texts.push(content);
85
+ continue;
86
+ }
87
+ if (!Array.isArray(content))
88
+ continue;
89
+ const parts = content
90
+ .filter((block) => typeof block === 'object' &&
91
+ block !== null &&
92
+ block.type === 'text' &&
93
+ typeof block.text === 'string')
94
+ .map((block) => block.text);
95
+ if (parts.length > 0)
96
+ texts.push(parts.join('\n'));
97
+ }
98
+ catch {
99
+ /* fail-soft: an odd message shape just means that message contributes nothing */
100
+ }
101
+ }
102
+ return texts;
103
+ }
104
+ /**
105
+ * GS2-81 — the page carrying WHICH commands honor `agent.backend`, pointed at rather than
106
+ * enumerated in the notice itself.
107
+ *
108
+ * The list belongs in exactly one place. A copy of it inside a runtime string is a second source of
109
+ * truth with nothing pinning it: the first draft of this notice already disagreed with the docs
110
+ * table written in the same commit (it omitted `workflow` agent steps), and no test could tell,
111
+ * because a sentence is not a claim a test can check. The docs table is checkable, and
112
+ * `agentBackendScope.spec.ts` pins that this URL's anchor still resolves to a real heading in it.
113
+ *
114
+ * **A GitHub blob URL, matching the other user-facing runtime doc links in this repo** (the
115
+ * approvals-protection pointer in `config/shell-policy.ts` and the 2.0 migration pointer in
116
+ * `config/schema.ts`) — a running CLI's user has no checkout for a relative path to resolve in.
117
+ */
118
+ export const AGENT_BACKEND_SCOPE_DOCS_URL = 'https://github.com/pukeko-robotics/gaunt-sloth/blob/main/docs/configuration/profiles.md#which-commands-honour-it';
40
119
  /**
41
120
  * Agent simplifies interaction with LLM and reduces it to calling a few methods
42
121
  * {@link GthAgentRunner#init} and {@link GthAgentRunner#processMessages}.
@@ -55,6 +134,14 @@ export class GthAgentRunner {
55
134
  * for non-interactive entrypoints (a scripted `exec` run with no TTY to prompt on).
56
135
  */
57
136
  toolApprovalCallback = null;
137
+ /**
138
+ * [[TUI-C68]] §6.1 — consumer hook invoked when the rater rates a command an `attack`, so an
139
+ * interactive surface can show the red banner before the run ends. Set via
140
+ * {@link setAttackHaltCallback}; **when unset the runner halts immediately**, which is the
141
+ * behaviour every surface had before a banner existed. A surface that forgets to wire it
142
+ * therefore keeps the halt rather than losing it.
143
+ */
144
+ attackHaltCallback = null;
58
145
  /** The command the runner was initialized for; selects which `devTools` config applies. */
59
146
  command = undefined;
60
147
  /**
@@ -67,9 +154,11 @@ export class GthAgentRunner {
67
154
  /**
68
155
  * CFG-27 — the runtime, session-scoped approvals posture, seeded at {@link init} from
69
156
  * {@link resolveApprovals} and thereafter switchable for the session by `/approvals <rung>`.
70
- * The shell tool stays gated (in `interruptOn`) at EVERY rung, so this is consulted at the top
71
- * of {@link decideToolApproval} and a config that pre-selects `bypass` remains switchable back
72
- * mid-session. Never persisted.
157
+ * **This field, not the interrupt wiring, is where the rung lives.** The backends wire the
158
+ * interrupt rung-independently, so every tool any rung could gate arrives at the top of
159
+ * {@link decideToolApproval} and is judged against the rung recorded here — which is what makes
160
+ * `/approvals manual` take effect mid-session, and what keeps a config that pre-selects
161
+ * `bypass` switchable back. Never persisted.
73
162
  *
74
163
  * It does NOT disable the hardline floor — catastrophic commands are still refused at exec time
75
164
  * in `GthDevToolkit.executeCommand` under every rung.
@@ -113,15 +202,38 @@ export class GthAgentRunner {
113
202
  */
114
203
  persistedGrants = null;
115
204
  persistedGrantsLoaded = false;
205
+ /**
206
+ * [[EXT-29]] §5 — the state of the agent↔rater negotiation at `auto`: the transcript, §5.3's
207
+ * consecutive-rejection counter and the reachability bound. Instance-scoped for the same reason
208
+ * the grant stores are — a concurrent ACP / AG-UI session must not inherit another's argument.
209
+ */
210
+ negotiation = new ShellNegotiationState();
211
+ /**
212
+ * [[TUI-C27]] — the diagnostic record of every gated decision this session made, for
213
+ * `/debug-dump`. Instance-scoped for the same reason the negotiation and the grant stores are: a
214
+ * concurrent ACP / AG-UI session must not inherit another's approvals history, and a dump taken
215
+ * in one must not describe the other.
216
+ */
217
+ approvalCaptures = new ApprovalCaptureLog();
218
+ /**
219
+ * GS2-81 — whether the caller supplied a backend factory. When it did NOT, this runner is
220
+ * hard-wired to the lean default below, so a config asking for `agent.backend: 'deep'` cannot be
221
+ * honored no matter what it says. Recorded here (rather than inferred later) because by then the
222
+ * fallback has already collapsed both cases into one function.
223
+ */
224
+ backendFactorySupplied;
116
225
  /**
117
226
  * @param agentFactory Produces the {@link GthAgentInterface} the runner drives.
118
227
  * Defaults to the lean {@link GthLangChainAgent} (core). `@gaunt-sloth/agent`
119
228
  * passes a factory returning a deep `GthDeepAgent` so the same runner can drive a
120
229
  * `createDeepAgent` graph without core depending on deepagents.
230
+ * **Omitting it opts the caller out of `agent.backend`** — see {@link init}, which says so out
231
+ * loud rather than letting the key be dropped in silence.
121
232
  */
122
233
  constructor(statusUpdate, resolvers, agentFactory) {
123
234
  this.statusUpdate = statusUpdate;
124
235
  this.resolvers = resolvers;
236
+ this.backendFactorySupplied = agentFactory !== undefined;
125
237
  this.agentFactory =
126
238
  agentFactory ?? ((status, agentResolvers) => new GthLangChainAgent(status, agentResolvers));
127
239
  }
@@ -133,6 +245,58 @@ export class GthAgentRunner {
133
245
  setToolApprovalCallback(callback) {
134
246
  this.toolApprovalCallback = callback;
135
247
  }
248
+ /**
249
+ * [[TUI-C68]] §6.1 — register the handler that shows the **attack banner**, the one way a human
250
+ * gets past an `attack` verdict. Pass `null` to clear.
251
+ *
252
+ * Separate from {@link setToolApprovalCallback} because it is a separate question with an
253
+ * inverted default: an absent approval callback means *this session has nobody to ask*, and an
254
+ * absent one here means *end the run*. Wiring it is what an interactive surface opts into; every
255
+ * other surface keeps the halt (see {@link attackHaltCallback}).
256
+ */
257
+ setAttackHaltCallback(callback) {
258
+ this.attackHaltCallback = callback;
259
+ }
260
+ /**
261
+ * §6.1 — **the single seam between an `attack` verdict and the end of the run.** Both rating
262
+ * paths — the §3.2 allow-match tripwire and the ordinary rater decision — go through here, so the
263
+ * banner cannot be present on one and missing on the other, which is the shape of bug that leaves
264
+ * a halt answerable in some sessions and not others with nothing on screen to tell them apart.
265
+ *
266
+ * It returns a decision for the one answer that grants and throws for everything else:
267
+ *
268
+ * - **no callback → throw**, immediately and unchanged. §6.2's rule is that a run with nobody to
269
+ * ask never blocks and never times out into a grant; the way that is guaranteed is that waiting
270
+ * is something only a wired surface can cause.
271
+ * - **`run-anyway` → approve, scope `once`.** Exactly one command runs. `once` is not a default
272
+ * restated: it is what keeps §6.1's three "never"s true. Returning here is also returning from
273
+ * *before* the block that records a sticky grant, so no allow-list entry and no session grant
274
+ * can be written on this path — the next identical call is rated again and reaches this banner
275
+ * again. Nothing here touches the rung, and nothing disables the rater, the escalation or the
276
+ * halt for anything else.
277
+ * - **anything else → throw.** `stop`, and equally a value a surface invents or forgets to
278
+ * return: the grant is one exact answer and everything else is a refusal.
279
+ */
280
+ async haltOrRunAnyway(command, reason,
281
+ /** [[TUI-C27]] — this decision's record; the banner's answer is a HUMAN's answer. */
282
+ record) {
283
+ if (this.attackHaltCallback) {
284
+ const answer = await this.attackHaltCallback({ command, reason });
285
+ // [[TUI-C27]] — **recorded here, or a run-anyway is indistinguishable from a `safe`
286
+ // rating.** The banner returns an ordinary approval, so without this line the archive would
287
+ // show `approve` at the `rater` stage with nobody named — i.e. the rater appearing to have
288
+ // approved a command it called an attack. That is precisely the misattribution this node
289
+ // exists to remove, on the branch where it costs the most.
290
+ record.humanAnswer = answer === 'run-anyway' ? 'approve' : 'reject';
291
+ if (answer === 'run-anyway')
292
+ return { type: 'approve', scope: 'once' };
293
+ }
294
+ else {
295
+ // §6.2 — no surface wired the banner, so nobody was asked and the run ends.
296
+ record.humanAnswer = 'no-human';
297
+ }
298
+ throw new AttackHaltError(command, reason);
299
+ }
136
300
  /**
137
301
  * CFG-27 — switch the session-scoped rung (`/approvals <rung>`). Idempotent; returns the NEW
138
302
  * rung so the caller can render a notice. Session-scoped only — nothing is written to config,
@@ -146,6 +310,17 @@ export class GthAgentRunner {
146
310
  getSessionApprovals() {
147
311
  return this.sessionApprovals;
148
312
  }
313
+ /**
314
+ * [[TUI-C27]] — every gated decision this session made, oldest first, for the `/debug-dump`
315
+ * archive.
316
+ *
317
+ * Threaded by each surface into `writeDebugDump`, exactly as `agent.lastModelRequest` is: the
318
+ * writer redacts it with the same pass it applies to every other artifact, and a surface that
319
+ * does not thread it simply omits the file.
320
+ */
321
+ getApprovalCaptures() {
322
+ return this.approvalCaptures.snapshot();
323
+ }
149
324
  /**
150
325
  * CFG-26 — the allow-list sizes for the `/approvals` display: how many command prefixes the
151
326
  * human has trusted this session, and how many are persisted in the project file.
@@ -327,7 +502,7 @@ export class GthAgentRunner {
327
502
  * and we'd better have an instance by that moment, for the case things will go wrong,
328
503
  * so we can wrap init into try-catch and then call {@link #cleanup} within finally.
329
504
  */
330
- async init(command, configIn, checkpointSaver) {
505
+ async init(command, configIn, checkpointSaver, options) {
331
506
  this.config = configIn;
332
507
  this.command = command;
333
508
  // GS2-48 — register the effective config with the crash handler so an uncaughtException /
@@ -359,7 +534,7 @@ export class GthAgentRunner {
359
534
  //
360
535
  // Loaded whenever a profile is NAMED, without a second "will the rater actually run?" gate:
361
536
  // naming a rater profile at an unrated rung is a config the user can hold (they may switch to
362
- // `auto-safe` mid-session with `/approvals`), and a broken profile should still fail loudly at
537
+ // `assisted` mid-session with `/approvals`), and a broken profile should still fail loudly at
363
538
  // startup rather than at the moment they switch.
364
539
  const raterProfile = this.sessionApprovals.rater;
365
540
  this.raterModel = raterProfile ? await resolveRaterModel(raterProfile) : undefined;
@@ -368,12 +543,37 @@ export class GthAgentRunner {
368
543
  debugLog(`Initializing GthAgentRunner with command: ${command || 'default'}`);
369
544
  this.runConfig = getNewRunnableConfig();
370
545
  debugLogObject('Runnable Config', this.runConfig);
546
+ this.warnIfBackendCannotBeHonored(configIn, command ?? options?.owningCommand);
371
547
  this.agent = this.agentFactory(this.statusUpdate, this.resolvers);
372
548
  // Initialize the agent
373
549
  debugLog('Initializing agent...');
374
550
  await this.agent.init(command, configIn, checkpointSaver);
375
551
  debugLog('Agent initialization complete');
376
552
  }
553
+ /**
554
+ * GS2-81 — `agent.backend` is a COMMAND-SCOPED key, and this is where a command opts out of it.
555
+ *
556
+ * A caller that hands the runner no factory gets the lean {@link GthLangChainAgent}, whatever the
557
+ * config asked for: `agent.backend: 'deep'` is then accepted by the schema, resolved into the
558
+ * config, and dropped on the floor. `gth review` and `gth pr` are in exactly that position — the
559
+ * review module builds its runner without one, and `@gaunt-sloth/review` does not depend on
560
+ * `@gaunt-sloth/agent`, so the deep backend is out of its reach — as is the `gth pr` change-
561
+ * requirements discovery agent.
562
+ *
563
+ * The check lives HERE, on the `agentFactory ?? lean` fallback itself, rather than in a list of
564
+ * verbs that cannot honor the key: a list is a second source of truth that goes stale the first
565
+ * time someone adds a command, whereas anything that reaches this fallback is by construction a
566
+ * run the key cannot reach. `lean` and the unset default need no warning — that IS what runs.
567
+ */
568
+ warnIfBackendCannotBeHonored(config, command) {
569
+ if (this.backendFactorySupplied || config.agent?.backend !== 'deep') {
570
+ return;
571
+ }
572
+ const scope = command ? `the ${command} command` : 'this run';
573
+ this.statusUpdate(StatusLevel.WARNING, `Config sets agent.backend: deep, but ${scope} always runs the lean backend, so the ` +
574
+ 'setting has no effect here. Which commands honor it: ' +
575
+ AGENT_BACKEND_SCOPE_DOCS_URL);
576
+ }
377
577
  /**
378
578
  * processMessages deals with both streaming and non-streaming approaches.
379
579
  */
@@ -385,6 +585,12 @@ export class GthAgentRunner {
385
585
  this.resetRunStats();
386
586
  // GS2-48 — record this turn's transcript tail for the crash handler.
387
587
  updateCrashContext({ transcriptTail: messages.slice(-CRASH_TRANSCRIPT_TAIL_MESSAGES) });
588
+ // [[EXT-29]] §5 — a new user turn is the human being reached, so it ends any negotiation still
589
+ // standing from the previous one and clears BOTH bounds. The turn's own messages then enter
590
+ // §5.1's last-5 window, which is what makes "just the last two" reach the rater at all — the
591
+ // reply that narrows what the agent proposes is worthless to the gate if only the agent hears it.
592
+ this.negotiation.humanReached();
593
+ this.negotiation.noteUserMessages(humanMessageTexts(messages));
388
594
  debugLog('Processing messages...');
389
595
  debugLogObject('Input Messages', messages);
390
596
  try {
@@ -599,19 +805,19 @@ export class GthAgentRunner {
599
805
  * prohibition that catches something unresolvable errs in the direction that costs nothing.
600
806
  * 2. **`bypass`** — the gate is off for this session; approve at scope `once`.
601
807
  * 3. **escalate** (§3.2) — a declared entry always asks the human, whatever the rung would have
602
- * done, **including outranking the automatic grants of `read-only` and `write`** and any allow
808
+ * done, **including outranking the automatic grants of `manual` and `write`** and any allow
603
809
  * entry that also matched. It goes straight to the human with **no rating call**, and it never
604
- * enters the `full-auto` negotiation. It is **inert at `bypass`**, which is why it sits below
810
+ * enters the `auto` negotiation. It is **inert at `bypass`**, which is why it sits below
605
811
  * the rung check: the rung chosen for this session wins, and a stop that must survive `bypass`
606
812
  * is a deny entry and only that.
607
813
  * 4. **allow** (§3, §3.2) — a declared entry or a grant the human made at an earlier prompt this
608
814
  * session (or persisted), matched against the whole normalized command and only when that
609
815
  * command statically resolves. An allow match settles the human's part: no prompt. Whether the rater
610
816
  * still reviews the call is the entry's own `rate` (§3.2) — honored at the rater rungs and
611
- * inert at the deterministic ones, so no entry can smuggle a model call into `read-only` or
817
+ * inert at the deterministic ones, so no entry can smuggle a model call into `manual` or
612
818
  * `write` — and a rated allow match is a TRIPWIRE, not a re-adjudication
613
819
  * ({@link mapAllowMatchedVerdictToAction}).
614
- * 5. **auto-rater** (`auto-safe` / `full-auto` only) — `safe` approves, `destructive` and
820
+ * 5. **auto-rater** (`assisted` / `auto` only) — `safe` approves, `destructive` and
615
821
  * `catastrophic` escalate, and `attack` HALTS the run ({@link AttackHaltError}). The other
616
822
  * three rungs consult no model at all. A command whose target the gate cannot statically
617
823
  * resolve is rated **exactly like any other** ([[EXT-81]]), with a neutral note in the rating
@@ -636,11 +842,100 @@ export class GthAgentRunner {
636
842
  * Hardline catastrophic commands remain refused at exec time regardless of any approval here
637
843
  * (defense in depth in `GthDevToolkit.executeCommand`), so an allow-listed `rm -rf /` still
638
844
  * cannot run.
845
+ *
846
+ * **Step 0 is the rung.** The backends wire the interrupt over every tool ANY rung could gate,
847
+ * because the graph is built once and `/approvals <rung>` moves the rung under it for the rest of
848
+ * the session. So a call arriving here has not yet been judged against the rung in force: this is
849
+ * where that happens, on `sessionApprovals.rung`, which a mid-session switch has already updated.
850
+ * A call the live rung does not gate is approved on the spot — no rule matching, no rating, no
851
+ * prompt — which is what keeps `assisted`, `auto` and `bypass` behaving exactly as they did
852
+ * when the interrupt held the shell alone. It sits ABOVE the deny check for the same reason: an
853
+ * ungated call never reached this method at all before, so a deny entry could not fire on one, and
854
+ * a security fix for two rungs is not the place to change that. (The shell is gated at every rung
855
+ * whenever the shell gate is on, so §2.5's rule that the deny list survives `bypass` is untouched.)
639
856
  */
640
857
  async decideToolApproval(tool) {
858
+ // [[TUI-C27]] — the record is opened (and already in the log) BEFORE the decision runs, and
859
+ // filled in as it goes. Assembling it at the end would lose the calls most worth keeping: an
860
+ // `attack` verdict throws `AttackHaltError` out of the decision, so a halted run would carry no
861
+ // record of the rating that halted it — and an approval, which relays nothing to anyone, is
862
+ // exactly the branch that used to leave no trace at all.
863
+ const record = this.approvalCaptures.begin({
864
+ at: new Date().toISOString(),
865
+ tool: tool.name,
866
+ ...(typeof tool.args?.command === 'string' ? { command: tool.args.command } : {}),
867
+ rung: this.sessionApprovals.rung,
868
+ budget: this.negotiation.counters(),
869
+ });
870
+ const decision = await this.recordedDecision(tool, record);
871
+ // [[EXT-29]] §5.3 — **the reset, at the one site that sees every approval.** "A successful
872
+ // intervening tool call — the agent going away to gather information and returning better
873
+ // informed — resets [the count], because that is progress, not ping-pong." Every way a call can
874
+ // be let through arrives here: the rung not gating it, an allow entry, the §3.2 tripwire, a
875
+ // `safe` rating, the human saying yes. Wrapping is what makes that exhaustive — an approval
876
+ // added below cannot forget to reset, and the alternative (a call at each of the six `approve`
877
+ // returns) is a §5.3 hole that is invisible the day it opens.
878
+ //
879
+ // Nothing else resets: a `reject` (the negotiation's own rounds, and the §8 floor's refusal)
880
+ // must not, or the bound it is counted against could never be reached.
881
+ if (decision.type === 'approve')
882
+ this.negotiation.noteProgress();
883
+ return decision;
884
+ }
885
+ /**
886
+ * [[TUI-C27]] — {@link decideToolApprovalInner} with the record closed off on EVERY exit.
887
+ *
888
+ * The final action is written here rather than at each of the decision's many returns, because
889
+ * "what became of the call" is one fact with one source: what this method returns or throws. A
890
+ * per-return assignment is a list that a new branch joins without noticing, and the branch that
891
+ * would be forgotten is the one that ends the run.
892
+ */
893
+ async recordedDecision(tool, record) {
894
+ try {
895
+ const decision = await this.decideToolApprovalInner(tool, record);
896
+ record.action = decision.type === 'approve' ? 'approve' : 'reject';
897
+ if (decision.type === 'approve' && decision.scope)
898
+ record.scope = decision.scope;
899
+ return decision;
900
+ }
901
+ catch (error) {
902
+ if (error instanceof AttackHaltError) {
903
+ record.action = 'halt';
904
+ }
905
+ else if (error instanceof NonInteractiveEscalationError) {
906
+ // §6.2 — there was nobody to ask, so the escalation ended the run instead of reaching one.
907
+ record.action = 'escalate';
908
+ record.humanAnswer = 'no-human';
909
+ }
910
+ else {
911
+ record.action = 'error';
912
+ }
913
+ record.error = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
914
+ throw error;
915
+ }
916
+ }
917
+ /** The decision itself; {@link decideToolApproval} wraps it with §5.3's reset. */
918
+ async decideToolApprovalInner(tool, record) {
641
919
  const command = typeof tool.args?.command === 'string' ? tool.args.command : null;
642
920
  const isShellCommand = tool.name === SHELL_TOOL_NAME && command !== null;
643
921
  const approvals = this.sessionApprovals;
922
+ // (0) Does the rung IN FORCE gate this tool at all? Same shared predicate the backends built the
923
+ // interrupt from, asked about this one call, so the wiring and the decision cannot disagree.
924
+ // Scope `once`, so nothing is written to any allow-list: this is not a grant, it is the absence
925
+ // of a gate.
926
+ const { gateShell } = resolveShellApprovalGate(this.config ?? undefined, this.command);
927
+ if (!isToolGatedAtRung({ toolName: tool.name, rung: approvals.rung, gateShell })) {
928
+ return this.stage(record, 'not-gated', { type: 'approve', scope: 'once' });
929
+ }
930
+ // [[TUI-C27]] — [[EXT-81]]'s surviving observable: what the gate's own parser made of the
931
+ // command, recorded whether or not a rating follows. Computed once here rather than at the
932
+ // rating site, so a call the floor or a list settles still says whether the command was one the
933
+ // parser could resolve.
934
+ if (isShellCommand && command !== null) {
935
+ const defect = describeAbstention(command);
936
+ if (defect)
937
+ record.parserUnresolved = defect;
938
+ }
644
939
  // ONE subject and ONE annotation source per decision, shared by the rule matcher and the
645
940
  // §4.7.3 floor below. Building a second source for the floor would let a `hint` entry and the
646
941
  // floor read different effective values for the same call — the two-derivations-disagreeing
@@ -673,50 +968,138 @@ export class GthAgentRunner {
673
968
  });
674
969
  // (1) Deny — before everything, including `bypass`.
675
970
  if (rule?.action === 'deny') {
676
- return {
971
+ // [[TUI-C26]] §6 — the message names the refusal the user actually made. A deny entry now has
972
+ // two possible authors: a line in `approvals.deny`, and the escalation menu's *always reject*
973
+ // choice earlier in this session. Telling the model (and, through it, the user) to edit a
974
+ // config file that the second kind was never written to is the same class of wrongness as
975
+ // confirming a persistence that did not happen. The declared list is checked FIRST so an
976
+ // entry a user wrote is described as theirs even when the menu recorded the identical one.
977
+ const declared = this.sessionApprovals.deny.some((entry) => renderApprovalEntryObject(entry) === renderApprovalEntryObject(rule.entry));
978
+ const described = describeApprovalEntry(rule.entry);
979
+ record.ruleMatch = { action: 'deny', entry: described };
980
+ return this.stage(record, 'deny-list', {
677
981
  type: 'reject',
678
- message: `Refused: your deny list forbids this call (matched "${describeApprovalEntry(rule.entry)}"). ` +
679
- 'Remove the entry from approvals.deny if you want it to run.',
680
- };
982
+ message: declared
983
+ ? `Refused: your deny list forbids this call (matched "${described}"). ` +
984
+ 'Remove the entry from approvals.deny if you want it to run.'
985
+ : `Refused: the user chose to always refuse this earlier in this session (matched ` +
986
+ `"${described}"). That refusal lasts until the session ends; ask the user if you ` +
987
+ 'believe it should be lifted.',
988
+ });
681
989
  }
682
990
  // (2) `bypass` (config or `/approvals bypass`): approve a gated shell command WITHOUT
683
991
  // prompting or rating. Scope `once` so nothing is written to the allow-list (the bypass is
684
992
  // intentionally ephemeral and reversible). The hardline floor is NOT bypassed here — it is
685
993
  // enforced at exec time in GthDevToolkit.executeCommand regardless of this decision.
686
994
  if (isShellCommand && approvals.rung === 'bypass') {
687
- return { type: 'approve', scope: 'once' };
995
+ return this.stage(record, 'bypass', { type: 'approve', scope: 'once' });
996
+ }
997
+ // (2b) [[EXT-29]] §4.2/§8 — **the hardline floor, consulted BEFORE anything opens.** "If the
998
+ // deterministic floor matches, the command is refused at execution regardless of rating, rung,
999
+ // or approval — so it MUST NOT be negotiated and SHOULD NOT be escalated."
1000
+ //
1001
+ // **This is a second call site, not the exec-time one, and the promise is different.** The
1002
+ // toolkit's check guarantees such a command never RUNS; it does nothing about what happens on
1003
+ // the way there, so without this line `auto` spends three rating calls and a human dialog
1004
+ // arguing about a fork bomb that was never going to run — and "asking a human to approve
1005
+ // something that is then refused anyway teaches them their answer does not count, which is
1006
+ // worse than a flat refusal". The exec-time check stays exactly where it is: it is the
1007
+ // guarantee, this is the courtesy of not wasting a decision on it.
1008
+ //
1009
+ // **Scoped to the rated rungs, which is where §4.2 speaks.** `bypass` has already returned
1010
+ // above, and at `manual`/`write` the human is the gate rather than a second opinion on a
1011
+ // rating; neither is a path §5 or §4.2's table governs. It sits above the allow branch because
1012
+ // the floor is unappealable — an allow entry cannot buy past it — and below the deny check
1013
+ // because a deny match refuses the same call for the user's own reason.
1014
+ //
1015
+ // **`checkHardline` is asked, not `catastrophic`.** They are different predicates and the
1016
+ // difference is measured: EXT-60 recorded `chown -R /` as missing from the floor, so "the floor
1017
+ // matched" and "the rater said catastrophic" name overlapping, non-identical sets. A
1018
+ // `catastrophic` rating still escalates (§4.2 settled that deliberately); only a floor match
1019
+ // refuses here.
1020
+ //
1021
+ // **A floor match refuses; it never halts, whichever of §8's two subsets matched.** The floor is
1022
+ // a lexical test with no notion of direction or role, so it fires on ordinary work — the deploy
1023
+ // authenticated by an identity file, the fetch that writes a credential INBOUND — and ending the
1024
+ // session on one leaves a restart as the only recovery. The run-ending halt stays where a model
1025
+ // has actually said the command is an attack (the rating path below): a floor match is a
1026
+ // model-free assertion, and the model-free consequence is the floor's own refusal, reached
1027
+ // earlier here than at exec and without spending a prompt on it.
1028
+ //
1029
+ // **Both rated rungs, deliberately.** `assisted` gets the refusal without a prompt for the same
1030
+ // reason `auto` gets it without a round: §4.2 is a statement about the command, not about who
1031
+ // was going to be asked about it.
1032
+ if (isShellCommand && command !== null && isRatedRung(approvals.rung)) {
1033
+ const floor = checkHardline(command);
1034
+ if (floor) {
1035
+ // [[TUI-C27]] — **the archive names the matched pattern; the refusal below does not.**
1036
+ // §8.1 ("the floor is never advertised") and [[CFG-31]] bind USER-FACING rung copy —
1037
+ // text inviting someone to feel safe — and the resolution taken here is that a diagnostic
1038
+ // archive a user opens about their own session is not that surface: "a floor matched"
1039
+ // without saying which rule leaves nobody able to act on it. `buildHardlineRefusal` is
1040
+ // untouched and still carries only the description.
1041
+ record.hardline = { description: floor.description, pattern: floor.pattern };
1042
+ const refusal = buildHardlineRefusal(command, floor);
1043
+ // Visible, because the exec-time refusal is: a refusal the user never sees reads as the
1044
+ // agent quietly deciding not to do what it was asked.
1045
+ this.statusUpdate(StatusLevel.WARNING, `\n⛔ ${refusal}`);
1046
+ // §7's moves are deliberately absent — see `buildHardlineRefusal`. Neither bound moves
1047
+ // either: this refusal opens no round, so counting it would walk an unappealable refusal
1048
+ // toward the human escalation §4.2 says it must not reach. It follows that a model spamming
1049
+ // a floor-matching command is bounded only by the tool-loop guard and `recursionLimit`,
1050
+ // which is the right place for "the model did something unproductive" to end and the wrong
1051
+ // place for a fork bomb to acquire an audience.
1052
+ return this.stage(record, 'hardline-floor', { type: 'reject', message: refusal });
1053
+ }
688
1054
  }
689
1055
  // (3) Escalate — §3.2 sends it straight to the human with no rating call, outranking any allow
690
1056
  // entry that also matched.
691
1057
  //
692
1058
  // The `bypass` term is deliberate and not redundant with the early return above. That return
693
1059
  // only covers a SHELL call, so without this term a non-shell subject would still carry an
694
- // escalate match into the prompt at `bypass` unreachable while `run_shell_command` is the
695
- // only gated tool, but §2.5's rule is about the rung, not about which tool asked. Make the
696
- // invariant true rather than incidentally true, so [[EXT-30]] widening the gate cannot quietly
697
- // break it.
1060
+ // escalate match into the prompt at `bypass`, and §2.5's rule is about the rung, not about which
1061
+ // tool asked. Non-shell subjects do reach this line the deterministic rungs gate the write
1062
+ // built-ins, MCP and custom tools so the term is doing work rather than guarding a hypothesis.
698
1063
  const escalatedBy = rule?.action === 'escalate' && approvals.rung !== 'bypass'
699
1064
  ? describeApprovalEntry(rule.entry)
700
1065
  : undefined;
1066
+ if (escalatedBy) {
1067
+ record.ruleMatch = { action: 'escalate', entry: escalatedBy };
1068
+ record.stage = 'escalate-entry';
1069
+ }
701
1070
  // (4) Approve from the allow list without prompting. It ALWAYS wins over the rater — a
702
1071
  // human-trusted call shouldn't pay for an LLM call on every variant — but never over escalate.
703
1072
  const allowlistApplies = approvals.rung !== 'bypass' && escalatedBy === undefined;
704
1073
  let safetyVerdict;
705
1074
  if (allowlistApplies && rule?.action === 'allow') {
1075
+ record.ruleMatch = {
1076
+ action: 'allow',
1077
+ entry: describeApprovalEntry(rule.entry),
1078
+ rate: rule.rate === true,
1079
+ };
706
1080
  // §3.2 — `rate` is honored at the rater rungs and INERT at the deterministic ones, so an
707
- // entry can never smuggle a model call into `read-only` or `write`. A tool subject is not
1081
+ // entry can never smuggle a model call into `manual` or `write`. A tool subject is not
708
1082
  // rated either: the rater's first implementation covers the shell only (§4.3, [[EXT-30]]).
709
1083
  if (!rule.rate || !isRatedRung(approvals.rung) || !isShellCommand || command === null) {
710
- return { type: 'approve', scope: 'session' };
1084
+ return this.stage(record, 'allow-list', { type: 'approve', scope: 'session' });
711
1085
  }
712
- const verdict = await this.rateCommand(command, { allowMatched: true });
1086
+ // Attributed once, before the call, for the reason the rater path below is: the tripwire's
1087
+ // own `attack` arm throws, and a second writer on the return would make this one unfalsifiable.
1088
+ record.stage = 'allow-tripwire';
1089
+ const verdict = await this.rateCommand(command, { allowMatched: true }, record);
713
1090
  const tripwire = mapAllowMatchedVerdictToAction(verdict);
714
- if (tripwire.action === 'approve')
1091
+ if (tripwire.action === 'approve') {
715
1092
  return { type: 'approve', scope: 'session' };
1093
+ }
716
1094
  if (tripwire.action === 'halt') {
717
1095
  // §3.2/§4.2 — `attack` halts exactly as it would have without the match. A standing human
718
1096
  // grant answers "may this run"; it does not answer "is this command's structure hostile".
719
- throw new AttackHaltError(command, tripwire.verdict?.reason ?? '');
1097
+ //
1098
+ // §6.1 — and it halts through the SAME seam as the rater's own path below, so an allow
1099
+ // entry does not decide whether the banner appears. The entry has already been overruled by
1100
+ // the time this line is reached; letting it also silence the one way out would make the
1101
+ // recovery depend on a match the human cannot see from the banner.
1102
+ return await this.haltOrRunAnyway(command, tripwire.verdict?.reason ?? '', record);
720
1103
  }
721
1104
  // `catastrophic` — the one outcome the tripwire escalates. Fall through to the human.
722
1105
  safetyVerdict = tripwire.verdict;
@@ -749,15 +1132,90 @@ export class GthAgentRunner {
749
1132
  // how to rewrite it, and the rewrite it named turned `cd src && ls` into a no-op plus a
750
1133
  // listing of the wrong directory, both exit 0. The parser's finding is now a neutral note
751
1134
  // in the rating prompt (`buildRaterPrompt`) and nothing else.
752
- const verdict = await this.rateCommand(subject.command, { allowMatched: false });
1135
+ // [[EXT-29]] §5.1 the negotiation this rating is a round of. At `assisted` the context is
1136
+ // empty and `negotiable` is false, so the whole call is byte-identical to what it was.
1137
+ const negotiable = isNegotiatingRung(approvals.rung);
1138
+ const justification = negotiable ? shellJustification(tool.args) : undefined;
1139
+ const context = negotiable
1140
+ ? this.negotiation.contextFor(justification)
1141
+ : undefined;
1142
+ // [[TUI-C27]] — attributed BEFORE the call, and ONCE. Before, because `attack` throws out
1143
+ // of the decision below and a record left unattributed would say a halt came from nowhere.
1144
+ // Once, because a second assignment on each `return` would make the first unfalsifiable:
1145
+ // deleting either would leave the other still writing 'rater', and a fact with two writers
1146
+ // is one no test can pin.
1147
+ record.stage = 'rater';
1148
+ const verdict = await this.rateCommand(subject.command, {
1149
+ allowMatched: false,
1150
+ negotiation: context,
1151
+ negotiable,
1152
+ }, record);
753
1153
  const decision = mapVerdictToAction(subject.command, verdict, { rung: approvals.rung });
1154
+ // [[TUI-C27]] — WHICH deterministic preflight fired, and whether it actually rewrote the
1155
+ // rating. The two are separate facts: a preflight only ever RAISES, and only `safe` sits
1156
+ // below the floor, so a finding on a `destructive` verdict is the floor AGREEING with the
1157
+ // rater rather than overriding it — and attributing the decision to the floor in that case
1158
+ // would be wrong. Recomputed from the same raw command `mapVerdictToAction` recomputes it
1159
+ // from, through the same one function, so the two cannot disagree.
1160
+ const preflight = preflightFloorFinding(subject.command);
1161
+ if (preflight) {
1162
+ record.preflight = {
1163
+ ...preflight,
1164
+ rewroteRating: isBelowDestructiveFloor(verdict.outcome),
1165
+ };
1166
+ }
754
1167
  if (decision.action === 'approve') {
755
1168
  // Scope `once`: rater approvals are NEVER persisted to the allow-list.
756
1169
  return { type: 'approve', scope: 'once' };
757
1170
  }
758
1171
  if (decision.action === 'halt') {
759
1172
  // §4.2 — not a rejection the model can respond to. It ends the agent loop.
760
- throw new AttackHaltError(subject.command, decision.verdict?.reason ?? '');
1173
+ //
1174
+ // **`neg-04d`: a negotiation already in flight ends here too**, mid-way and without a
1175
+ // further round. `attack` is exempt from the whole mechanism (§5.1), so the counter, the
1176
+ // transcript and the loop all stop together rather than the argument continuing around a
1177
+ // halt that only ended one call.
1178
+ //
1179
+ // The reset itself is defence in depth: the throw ends `processMessages`, and a later
1180
+ // turn would clear the negotiation on its own first line anyway. **No PRODUCTION reader
1181
+ // sees this state again — which is not the same as it being unobservable**, and the
1182
+ // difference decides whether the line is pinned. `neg-04d` asserts both halves through
1183
+ // the spec harness's private-state cast: the cleared transcript, and `sinceHuman` back at
1184
+ // zero. That second one is what distinguishes this call from `noteProgress()`, which
1185
+ // clears the transcript and the consecutive count and deliberately leaves the
1186
+ // reachability bound standing.
1187
+ this.negotiation.humanReached();
1188
+ // §6.1 — the banner, when an interactive surface wired one, and the halt otherwise. It
1189
+ // sits AFTER the reset above on purpose: a human is reached either way (that is what the
1190
+ // banner is), so the negotiation ends here whichever answer comes back, and neither
1191
+ // answer leaves a transcript behind for a later turn to argue from.
1192
+ return await this.haltOrRunAnyway(subject.command, decision.verdict?.reason ?? '', record);
1193
+ }
1194
+ if (decision.action === 'reject') {
1195
+ // §5 — `destructive` at `auto`. The round is recorded first, so the attempt being ruled
1196
+ // on is itself on the transcript the human sees (§5.6).
1197
+ const outcome = this.negotiation.recordRejection({
1198
+ command: subject.command,
1199
+ ...(justification ? { justification } : {}),
1200
+ outcome: decision.verdict?.outcome ?? 'destructive',
1201
+ reason: decision.verdict?.reason ?? '',
1202
+ });
1203
+ if (outcome === 'reject') {
1204
+ // §7 — the refusal PLUS the moves: re-call with a justification (the tool argument
1205
+ // exists for this), call a different command, or ask the user. Rendered through the
1206
+ // one builder the human's own "no" uses, differing only in who refused, so the model
1207
+ // never meets two shapes of the same event.
1208
+ return {
1209
+ type: 'reject',
1210
+ message: buildRejectionMessage({
1211
+ source: 'rater',
1212
+ toolName: tool.name,
1213
+ verdict: decision.verdict,
1214
+ }),
1215
+ };
1216
+ }
1217
+ // A bound is spent — the agent and the rater cannot agree, and that is a human's call.
1218
+ // Falls through to the escalation below, carrying this last round's verdict.
761
1219
  }
762
1220
  // Escalate: carry the verdict (the honest one — see mapVerdictToAction) to the human.
763
1221
  safetyVerdict = decision.verdict;
@@ -782,13 +1240,33 @@ export class GthAgentRunner {
782
1240
  //
783
1241
  // Reached only when no allow entry claimed the call: §4.6's fourth bullet makes an allow
784
1242
  // match lift this floor, and step (4) above has already returned in that case.
785
- safetyVerdict = applyDestructiveFloor(safetyVerdict, openWorldToolFloorReason(effective));
1243
+ const toolFloor = openWorldToolFloorReason(effective);
1244
+ if (toolFloor !== null)
1245
+ record.stage = 'tool-open-world-floor';
1246
+ safetyVerdict = applyDestructiveFloor(safetyVerdict, toolFloor);
786
1247
  }
787
1248
  }
1249
+ // [[EXT-29]] §6 — **the human is shown the whole negotiation, not the last attempt.** Snapshot
1250
+ // it BEFORE the state is cleared, because "that the agent proposed the same command three times
1251
+ // unchanged, against two rejections that each told it what to fix, is itself the most important
1252
+ // thing on the screen". Empty for every escalation that had no negotiation — `catastrophic`
1253
+ // (which §4.2 gives no rounds at all), a declared escalate entry, an unrated rung, a tool
1254
+ // subject — so nothing renders a heading over an argument that never happened.
1255
+ // [[TUI-C27]] — everything that reaches a person has an attribution by now EXCEPT the plainest
1256
+ // case of all: a deterministic rung, no rule matched, no rating made. That is a decision the
1257
+ // rung itself made, so it is named rather than left blank — a record with no stage reads as the
1258
+ // recorder having failed, which is the opposite of what happened.
1259
+ record.stage ??= 'unrated-rung';
1260
+ const negotiationRounds = this.negotiation.transcript();
1261
+ // Reaching a person ends the negotiation (§5.3) and is the ONE thing that clears the
1262
+ // reachability bound: an escalation the human is about to answer is exactly the event that
1263
+ // bound exists to make happen, so it is spent here rather than accumulated across it.
1264
+ this.negotiation.humanReached();
788
1265
  if (!this.toolApprovalCallback) {
789
1266
  // §6.2 — no one to ask. Exit non-zero with everything a person needs, rather than handing
790
- // the model a rejection it would just work around.
791
- throw new NonInteractiveEscalationError(command ?? tool.name, safetyVerdict?.outcome, safetyVerdict?.reason, escalatedBy);
1267
+ // the model a rejection it would just work around. The transcript goes into the message
1268
+ // because that message is the only thing anyone sees on this path.
1269
+ throw new NonInteractiveEscalationError(command ?? tool.name, safetyVerdict?.outcome, safetyVerdict?.reason, escalatedBy, renderNegotiationTranscript(negotiationRounds) ?? undefined);
792
1270
  }
793
1271
  // §4.2 — **a `catastrophic` approval is NEVER sticky.** "The human may approve this one
794
1272
  // invocation, and only this one": no always-allow, and no session-scoped allow either. The
@@ -811,23 +1289,61 @@ export class GthAgentRunner {
811
1289
  // two ways. For a tool call this is where "the stored thing is the tool, not the arguments"
812
1290
  // becomes visible: it names the tool, its server and the host bound, and nothing else.
813
1291
  const grantSummary = grant ? describeApprovalEntry(grant.entry) : undefined;
814
- // Surface the rater's verdict, the escalate entry that fired as provenance (§3.2), and what a
815
- // sticky choice would store (§6) without mutating the original interrupt object the caller
816
- // holds.
817
- const pending = safetyVerdict || escalatedBy || grantPreview
1292
+ // [[TUI-C26]] §6 — the deny half, computed SEPARATELY rather than read off the grant. The two
1293
+ // are available under different conditions and `grant === undefined` is the wrong test for
1294
+ // both: a command that does not statically resolve, and every `catastrophic` verdict, have no
1295
+ // grant on offer and a perfectly good deny entry.
1296
+ const denyEntry = this.denyEntryFor(subject);
1297
+ const denyPreview = denyEntry ? renderApprovalEntryObject(denyEntry) : undefined;
1298
+ const denySummary = denyEntry ? describeApprovalEntry(denyEntry) : undefined;
1299
+ // Surface the rater's verdict, the escalate entry that fired as provenance (§3.2), and what
1300
+ // each sticky choice would store (§6) — without mutating the original interrupt object the
1301
+ // caller holds.
1302
+ //
1303
+ // **`denyPreview` belongs in this condition, and leaving it out is a silent hole rather than a
1304
+ // tidiness question.** The most ordinary prompt in the system — a deterministic rung, no
1305
+ // rating, no escalate entry, no negotiation, a command that does not statically resolve — has
1306
+ // none of the other four, so without this term the interrupt would pass through unchanged and
1307
+ // the *always reject* control would vanish from exactly the case it exists for.
1308
+ const pending = safetyVerdict || escalatedBy || grantPreview || denyPreview || negotiationRounds.length > 0
818
1309
  ? {
819
1310
  ...tool,
820
1311
  ...(safetyVerdict ? { safetyVerdict } : {}),
821
1312
  ...(escalatedBy ? { escalatedBy } : {}),
822
1313
  ...(grantPreview ? { grantPreview } : {}),
823
1314
  ...(grantSummary ? { grantSummary } : {}),
1315
+ ...(denyPreview ? { denyPreview } : {}),
1316
+ ...(denySummary ? { denySummary } : {}),
1317
+ ...(negotiationRounds.length > 0 ? { negotiationRounds } : {}),
824
1318
  }
825
1319
  : tool;
826
1320
  const decision = await this.toolApprovalCallback(pending);
1321
+ // [[TUI-C27]] — a person was reached and answered. The STAGE stays whatever decided to ask
1322
+ // them (a rating, an escalate entry, an unrated rung): "who decided to interrupt" and "what
1323
+ // they said" are two different questions, and collapsing them into one field is what makes a
1324
+ // dump unable to tell a rater escalation from a declared one.
1325
+ record.humanAnswer = decision.type === 'approve' ? 'approve' : 'reject';
827
1326
  // Record the human's scoped grant so the same call stops re-prompting.
828
1327
  if (decision.type === 'approve' && grant) {
829
1328
  this.recordApproval(grant, decision.scope ?? 'once');
830
1329
  }
1330
+ // §6 — and the mirror: *always reject* records the refusal, so the next identical call is
1331
+ // refused by rule at step (1) without reaching a person. Scoped `session` and nothing else,
1332
+ // because that is the only lifetime the store has (see {@link ToolRejectScope}).
1333
+ if (decision.type === 'reject' && decision.scope === 'session' && denyEntry) {
1334
+ this.recordDenial(denyEntry);
1335
+ }
1336
+ return decision;
1337
+ }
1338
+ /**
1339
+ * [[TUI-C27]] — attribute the deciding stage and hand the decision straight back.
1340
+ *
1341
+ * A one-liner so a stage can be recorded ON the `return` that carries it rather than on the line
1342
+ * above: two statements let an early return be added between them, and the record would then name
1343
+ * a stage that did not decide.
1344
+ */
1345
+ stage(record, stage, decision) {
1346
+ record.stage = stage;
831
1347
  return decision;
832
1348
  }
833
1349
  /**
@@ -835,10 +1351,22 @@ export class GthAgentRunner {
835
1351
  * rated allow match) and the ordinary rater path cannot drift apart in WHAT they hand the rater —
836
1352
  * only in what they do with the answer.
837
1353
  */
838
- async rateCommand(command, opts) {
1354
+ async rateCommand(command, opts,
1355
+ /** [[TUI-C27]] — the decision's record; the rating attaches itself to it at the send site. */
1356
+ record) {
839
1357
  const approvals = this.sessionApprovals;
840
1358
  const verdict = await rateShellCommand(command, this.config, {
841
1359
  home: env?.HOME,
1360
+ negotiation: opts.negotiation,
1361
+ negotiable: opts.negotiable,
1362
+ // [[TUI-C27]] — the sink fires BEFORE the model is invoked, with the prompt that is about to
1363
+ // be sent, so the record carries what the rater was SHOWN rather than a later re-render of
1364
+ // it. Assigning it here (rather than pushing a finished record afterwards) is what makes a
1365
+ // hung, timed-out or halting call still leave the question behind.
1366
+ onCapture: (capture) => {
1367
+ record.rating = capture;
1368
+ },
1369
+ raterProfile: approvals.rater,
842
1370
  // The profile's model when one is configured; undefined lets rateShellCommand use the
843
1371
  // session model. `init` throws rather than leaving this undefined for a NAMED profile, so
844
1372
  // a configured profile can never silently degrade to the session model here.
@@ -850,7 +1378,7 @@ export class GthAgentRunner {
850
1378
  grantedTools: this.getGrantedBuiltInTools(),
851
1379
  // EXT-66 — the user-owned budget for ONE rating call, `undefined` when unset so
852
1380
  // rateShellCommand applies RATER_DEFAULT_TIMEOUT_MS. 30s is a hosted-model number and a
853
- // local rater is knowably slower; without this a local `full-auto` session drifts toward
1381
+ // local rater is knowably slower; without this a local `auto` session drifts toward
854
1382
  // escalating everything, which is the failure the rung exists to prevent.
855
1383
  timeoutMs: approvals.raterTimeoutMs,
856
1384
  });
@@ -891,10 +1419,23 @@ export class GthAgentRunner {
891
1419
  const registered = this.agent?.getRegisteredToolNames?.() ?? [];
892
1420
  if (registered.length === 0)
893
1421
  return [];
894
- // The gated set is resolved from the SAME shared policy both backends wire their interrupt
895
- // from, so "granted" here means exactly what it means at tool-registration time (§4.5).
1422
+ // The LIVE gated set, from the SAME shared policy `decideToolApproval` decides on and the
1423
+ // backends derive their interrupt from, so "granted" here means exactly what it means at
1424
+ // tool-registration time (§4.5) and at the gate.
1425
+ //
1426
+ // EXT-80 makes this non-drift property load-bearing rather than incidental. At `manual` the
1427
+ // write built-ins are gated, so they are NOT granted, and a summary still offering `write_file`
1428
+ // there would tell the model a tool is free while the gate stops and asks for it — the rater
1429
+ // suggesting the one thing guaranteed to interrupt the user. It is computed per rating from
1430
+ // `sessionApprovals.rung`, so it follows a mid-session `/approvals` change — unlike the
1431
+ // interrupt set, which is fixed when the graph is built and is rung-independent for exactly
1432
+ // that reason.
896
1433
  const { gateShell } = resolveShellApprovalGate(this.config ?? undefined, this.command);
897
- const gatedTools = gateShell ? [SHELL_TOOL_NAME] : [];
1434
+ const gatedTools = resolveGatedToolNames({
1435
+ rung: this.sessionApprovals.rung,
1436
+ gateShell,
1437
+ boundToolNames: registered,
1438
+ });
898
1439
  return describeGrantedBuiltInTools(registered, this.sessionApprovals.rung, gatedTools);
899
1440
  }
900
1441
  /**
@@ -1013,6 +1554,62 @@ export class GthAgentRunner {
1013
1554
  // later one against it, and the store copies it so the record is private to this grant.
1014
1555
  return { entry, annotations: effective };
1015
1556
  }
1557
+ /**
1558
+ * [[TUI-C26]] §6 — **the entry the escalation menu's *always reject* choice would record**, or
1559
+ * `undefined` when the grammar cannot hold one. The deny mirror of {@link stickyGrantFor}, and a
1560
+ * separate function rather than a flag on it, because the two answer different questions.
1561
+ *
1562
+ * **Nearly every reason an allow entry is withheld does not apply here.** §3 has one rule for
1563
+ * this and it runs the other way — *undecidable is a non-match on the allow side and a match on
1564
+ * the deny side* — so:
1565
+ *
1566
+ * - **A command that does not statically resolve gets an entry.** `stickyGrantFor` refuses one
1567
+ * because no allow entry of any matcher would ever match it, making the entry inert; a deny
1568
+ * entry for the same command is matched against the whole normalized command *and* every
1569
+ * segment a shell would run, so it is the opposite of inert.
1570
+ * - **A `catastrophic` verdict changes nothing.** §4.2 withdraws the sticky grants there; it says
1571
+ * nothing about refusals, and refusing more is never the direction that needs withdrawing.
1572
+ * - **`bypass` changes nothing either**, and that is a positive statement rather than a gap. Deny
1573
+ * is resolved at step (1) of {@link decideToolApprovalInner}, *before* the `bypass` return, so
1574
+ * a recorded refusal is in force at every rung — which is why this does not copy the allow
1575
+ * side's `bypass` guard.
1576
+ * - **A call naming several hosts gets the host-less entry.** On the allow side that would show a
1577
+ * bound the grant does not have; here the entry covers every host of that tool, which is
1578
+ * broader than the call and safe in the direction breadth is safe. The menu shows exactly that
1579
+ * entry, so the breadth is on screen rather than inferred.
1580
+ * - **`run_shell_command` arriving as a TOOL subject gets a tool entry** — a shell call whose
1581
+ * `command` argument cannot even be read. On the allow side that entry would auto-approve every
1582
+ * future unreadable shell call, which is why it is excluded there; as a refusal it stops the
1583
+ * shell tool for the session, and the dialog says so in the words the entry is written in.
1584
+ *
1585
+ * The one genuine exclusion is an **MCP call whose server could not be attributed**
1586
+ * ({@link toolGrantEntry} returns `null`): the grammar's `server` cannot be the empty string, so
1587
+ * the entry would be dropped by its own validator and the human would be told a refusal had been
1588
+ * recorded when none was. A shell command that normalizes to nothing is excluded for the same
1589
+ * reason — an empty `pattern` is not a legal entry.
1590
+ */
1591
+ denyEntryFor(subject) {
1592
+ if (subject.kind === 'shell') {
1593
+ const entry = shellGrantEntry(subject.command);
1594
+ return entry.pattern.length > 0 ? entry : undefined;
1595
+ }
1596
+ return toolGrantEntry(subject) ?? undefined;
1597
+ }
1598
+ /**
1599
+ * §6 — record the menu's *always reject* choice, for the life of this runner instance.
1600
+ *
1601
+ * It lands in the same store `approvals.deny` entries are matched from ({@link approvalRuleLists}
1602
+ * concatenates the two), so a refusal the human made at the prompt and one they wrote in their
1603
+ * config are one list to the matcher and one list to `/approvals`.
1604
+ *
1605
+ * **Session-lifetime, and there is nothing else to choose.** There is no persisted deny file;
1606
+ * whether there should be is a question about a file users live with, not about this prompt. What
1607
+ * the surfaces must not do is say otherwise — a confirmation promising a persistence that did not
1608
+ * happen is §6's *offered and then refused* with the evidence hidden.
1609
+ */
1610
+ recordDenial(entry) {
1611
+ this.denyGrants.add({ entry, grantedAt: new Date().toISOString(), scope: 'session' });
1612
+ }
1016
1613
  /**
1017
1614
  * §3.1/§6 — record a human-granted approval at the given scope. `once` remembers nothing.
1018
1615
  * `session` adds the entry to the in-memory store; `always` additionally persists it (falling
@@ -1123,6 +1720,12 @@ export class GthAgentRunner {
1123
1720
  this.resetRunStats();
1124
1721
  // GS2-48 — record this turn's transcript tail for the crash handler.
1125
1722
  updateCrashContext({ transcriptTail: messages.slice(-CRASH_TRANSCRIPT_TAIL_MESSAGES) });
1723
+ // [[EXT-29]] §5 — a new user turn is the human being reached, so it ends any negotiation still
1724
+ // standing from the previous one and clears BOTH bounds. The turn's own messages then enter
1725
+ // §5.1's last-5 window, which is what makes "just the last two" reach the rater at all — the
1726
+ // reply that narrows what the agent proposes is worthless to the gate if only the agent hears it.
1727
+ this.negotiation.humanReached();
1728
+ this.negotiation.noteUserMessages(humanMessageTexts(messages));
1126
1729
  debugLog('Processing messages (event stream)...');
1127
1730
  debugLogObject('Input Messages', messages);
1128
1731
  yield* this.agent.streamWithEvents(messages, this.runConfig, signal);
@@ -1219,6 +1822,10 @@ export class GthAgentRunner {
1219
1822
  * of any checkpointer-specific delete API, mirroring how `init()` mints the initial config.
1220
1823
  */
1221
1824
  resetThread() {
1825
+ // [[EXT-29]] §5.1 — the negotiation goes with the thread, user messages included. The rater's
1826
+ // last-5 window is conversation context; leaving it behind a `/clear` would quote the user's
1827
+ // previous conversation into a rating made after they asked for it to be forgotten.
1828
+ this.negotiation.clear();
1222
1829
  this.runConfig = getNewRunnableConfig();
1223
1830
  debugLogObject('Reset Runnable Config', this.runConfig);
1224
1831
  }