@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.
- package/LICENSE +6 -6
- package/dist/config/loader.d.ts +13 -0
- package/dist/config/loader.js +55 -11
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +10 -0
- package/dist/config/schema.js +46 -24
- package/dist/config/schema.js.map +1 -1
- package/dist/config/shell-policy.d.ts +142 -1
- package/dist/config/shell-policy.js +114 -7
- package/dist/config/shell-policy.js.map +1 -1
- package/dist/config/types.d.ts +19 -0
- package/dist/config/types.js.map +1 -1
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +10 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +31 -0
- package/dist/core/GthAbstractAgent.js +159 -4
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +314 -18
- package/dist/core/GthAgentRunner.js +1032 -101
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/approvals/approvalRequest.d.ts +247 -0
- package/dist/core/approvals/approvalRequest.js +315 -0
- package/dist/core/approvals/approvalRequest.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +165 -9
- package/dist/core/approvals/grants.js +702 -57
- package/dist/core/approvals/grants.js.map +1 -1
- package/dist/core/plainToolIndication.d.ts +11 -1
- package/dist/core/plainToolIndication.js +25 -10
- package/dist/core/plainToolIndication.js.map +1 -1
- package/dist/core/shell/abstention.d.ts +1 -1
- package/dist/core/shell/abstention.js +65 -11
- package/dist/core/shell/abstention.js.map +1 -1
- package/dist/core/shell/alignment.d.ts +491 -0
- package/dist/core/shell/alignment.js +687 -0
- package/dist/core/shell/alignment.js.map +1 -0
- package/dist/core/shell/approvalCapture.d.ts +53 -41
- package/dist/core/shell/approvalCapture.js +0 -42
- package/dist/core/shell/approvalCapture.js.map +1 -1
- package/dist/core/shell/approvalStop.d.ts +121 -3
- package/dist/core/shell/approvalStop.js +153 -90
- package/dist/core/shell/approvalStop.js.map +1 -1
- package/dist/core/shell/hardline.d.ts +26 -4
- package/dist/core/shell/hardline.js +321 -16
- package/dist/core/shell/hardline.js.map +1 -1
- package/dist/core/shell/negotiation.d.ts +352 -63
- package/dist/core/shell/negotiation.js +295 -123
- package/dist/core/shell/negotiation.js.map +1 -1
- package/dist/core/shell/openWorld.d.ts +135 -0
- package/dist/core/shell/openWorld.js +572 -35
- package/dist/core/shell/openWorld.js.map +1 -1
- package/dist/core/shell/provenance.d.ts +91 -0
- package/dist/core/shell/provenance.js +136 -0
- package/dist/core/shell/provenance.js.map +1 -0
- package/dist/core/shell/rater.d.ts +394 -163
- package/dist/core/shell/rater.js +536 -238
- package/dist/core/shell/rater.js.map +1 -1
- package/dist/core/shell/raterHealth.d.ts +101 -0
- package/dist/core/shell/raterHealth.js +121 -0
- package/dist/core/shell/raterHealth.js.map +1 -0
- package/dist/core/shell/raterModel.d.ts +17 -8
- package/dist/core/shell/raterModel.js +11 -8
- package/dist/core/shell/raterModel.js.map +1 -1
- package/dist/core/shell/rejection.d.ts +20 -5
- package/dist/core/shell/rejection.js +18 -4
- package/dist/core/shell/rejection.js.map +1 -1
- package/dist/core/toolDisplay.d.ts +42 -0
- package/dist/core/toolDisplay.js +31 -0
- package/dist/core/toolDisplay.js.map +1 -1
- package/dist/core/types.d.ts +134 -16
- package/dist/core/types.js.map +1 -1
- package/dist/providers/configurationPassthrough.d.ts +36 -6
- package/dist/providers/configurationPassthrough.js +40 -28
- package/dist/providers/configurationPassthrough.js.map +1 -1
- package/dist/providers/openrouter.js +36 -6
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/vertexai.js +21 -0
- package/dist/providers/vertexai.js.map +1 -1
- package/dist/utils/binaryOutputUtils.js +103 -21
- package/dist/utils/binaryOutputUtils.js.map +1 -1
- package/dist/utils/fileUtils.d.ts +7 -1
- package/dist/utils/fileUtils.js +17 -3
- package/dist/utils/fileUtils.js.map +1 -1
- package/dist/utils/globalConfigUtils.d.ts +5 -2
- package/dist/utils/globalConfigUtils.js +14 -3
- package/dist/utils/globalConfigUtils.js.map +1 -1
- package/dist/utils/vertexaiUtils.js +61 -3
- package/dist/utils/vertexaiUtils.js.map +1 -1
- package/package.json +4 -4
- 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
|
|
73
|
-
*
|
|
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 —
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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
|
-
|
|
335
|
-
|
|
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
|
|
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.
|
|
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
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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, {
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
1174
|
-
//
|
|
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.
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
// §
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1884
|
+
// [[TUI-C69]] §5.4 — name 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:
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
// the
|
|
1254
|
-
//
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1337
|
-
//
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1544
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
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
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
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
|
-
|
|
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
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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.
|
|
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
|
-
|
|
1737
|
-
|
|
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
|
}
|