@gaunt-sloth/core 2.0.0-alpha.23 → 2.0.0-alpha.25
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/README.md +43 -18
- package/dist/config/colour.d.ts +38 -0
- package/dist/config/colour.js +36 -0
- package/dist/config/colour.js.map +1 -0
- package/dist/config/defaults.d.ts +6 -7
- package/dist/config/defaults.js +4 -8
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/loader.d.ts +50 -3
- package/dist/config/loader.js +345 -29
- package/dist/config/loader.js.map +1 -1
- package/dist/config/mouse.d.ts +50 -0
- package/dist/config/mouse.js +44 -0
- package/dist/config/mouse.js.map +1 -0
- package/dist/config/profiles.d.ts +68 -0
- package/dist/config/profiles.js +93 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/config/schema.d.ts +2441 -116
- package/dist/config/schema.js +930 -19
- package/dist/config/schema.js.map +1 -1
- package/dist/config/shell-policy.d.ts +466 -116
- package/dist/config/shell-policy.js +273 -62
- package/dist/config/shell-policy.js.map +1 -1
- package/dist/config/tool-descriptions.d.ts +165 -0
- package/dist/config/tool-descriptions.js +229 -0
- package/dist/config/tool-descriptions.js.map +1 -0
- package/dist/config/types.d.ts +139 -6
- package/dist/config/types.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +85 -2
- package/dist/core/GthAbstractAgent.js +194 -1
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +341 -58
- package/dist/core/GthAgentRunner.js +857 -149
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +99 -0
- package/dist/core/GthLangChainAgent.js +388 -3
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/approvals/annotations.d.ts +122 -0
- package/dist/core/approvals/annotations.js +137 -0
- package/dist/core/approvals/annotations.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +216 -0
- package/dist/core/approvals/grants.js +469 -0
- package/dist/core/approvals/grants.js.map +1 -0
- package/dist/core/approvals/matcher.d.ts +202 -0
- package/dist/core/approvals/matcher.js +267 -0
- package/dist/core/approvals/matcher.js.map +1 -0
- package/dist/core/approvals/mcpSubjects.d.ts +40 -0
- package/dist/core/approvals/mcpSubjects.js +99 -0
- package/dist/core/approvals/mcpSubjects.js.map +1 -0
- package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
- package/dist/core/approvals/toolAnnotationSources.js +277 -0
- package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
- package/dist/core/approvals/toolHost.d.ts +46 -0
- package/dist/core/approvals/toolHost.js +108 -0
- package/dist/core/approvals/toolHost.js.map +1 -0
- package/dist/core/debugCapture.d.ts +16 -0
- package/dist/core/debugCapture.js.map +1 -1
- package/dist/core/launchBanner.d.ts +120 -0
- package/dist/core/launchBanner.js +399 -0
- package/dist/core/launchBanner.js.map +1 -0
- package/dist/core/plainToolIndication.js +61 -35
- package/dist/core/plainToolIndication.js.map +1 -1
- package/dist/core/refusal.d.ts +53 -0
- package/dist/core/refusal.js +129 -0
- package/dist/core/refusal.js.map +1 -0
- package/dist/core/shell/approvalStop.d.ts +75 -0
- package/dist/core/shell/approvalStop.js +108 -0
- package/dist/core/shell/approvalStop.js.map +1 -0
- package/dist/core/shell/arity.d.ts +6 -0
- package/dist/core/shell/arity.js +20 -6
- package/dist/core/shell/arity.js.map +1 -1
- package/dist/core/shell/denylist.d.ts +11 -0
- package/dist/core/shell/denylist.js +37 -0
- package/dist/core/shell/denylist.js.map +1 -0
- package/dist/core/shell/normalize.d.ts +41 -1
- package/dist/core/shell/normalize.js +58 -4
- package/dist/core/shell/normalize.js.map +1 -1
- package/dist/core/shell/openWorld.d.ts +138 -0
- package/dist/core/shell/openWorld.js +523 -0
- package/dist/core/shell/openWorld.js.map +1 -0
- package/dist/core/shell/rater.d.ts +560 -0
- package/dist/core/shell/rater.js +895 -0
- package/dist/core/shell/rater.js.map +1 -0
- package/dist/core/shell/raterModel.d.ts +41 -0
- package/dist/core/shell/raterModel.js +51 -0
- package/dist/core/shell/raterModel.js.map +1 -0
- package/dist/core/shell/rejection.d.ts +66 -0
- package/dist/core/shell/rejection.js +38 -0
- package/dist/core/shell/rejection.js.map +1 -0
- package/dist/core/toolCallRepair/grammar.d.ts +41 -0
- package/dist/core/toolCallRepair/grammar.js +116 -0
- package/dist/core/toolCallRepair/grammar.js.map +1 -0
- package/dist/core/toolCallRepair/index.d.ts +2 -0
- package/dist/core/toolCallRepair/index.js +7 -0
- package/dist/core/toolCallRepair/index.js.map +1 -0
- package/dist/core/toolCallRepair/payload.d.ts +36 -0
- package/dist/core/toolCallRepair/payload.js +341 -0
- package/dist/core/toolCallRepair/payload.js.map +1 -0
- package/dist/core/toolCallRepair/promote.d.ts +45 -0
- package/dist/core/toolCallRepair/promote.js +90 -0
- package/dist/core/toolCallRepair/promote.js.map +1 -0
- package/dist/core/toolDisplay.d.ts +14 -1
- package/dist/core/toolDisplay.js +69 -12
- package/dist/core/toolDisplay.js.map +1 -1
- package/dist/core/toolOutputChannel.d.ts +33 -6
- package/dist/core/toolOutputChannel.js +61 -8
- package/dist/core/toolOutputChannel.js.map +1 -1
- package/dist/core/types.d.ts +71 -9
- package/dist/core/types.js.map +1 -1
- package/dist/providers/geminiSchemaSanitizer.d.ts +6 -3
- package/dist/providers/geminiSchemaSanitizer.js +152 -6
- package/dist/providers/geminiSchemaSanitizer.js.map +1 -1
- package/dist/providers/modelDiscovery.d.ts +18 -4
- package/dist/providers/modelDiscovery.js +67 -17
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/openai.js +34 -0
- package/dist/providers/openai.js.map +1 -1
- package/dist/runtime/askStructured.d.ts +8 -7
- package/dist/runtime/askStructured.js +8 -7
- package/dist/runtime/askStructured.js.map +1 -1
- package/dist/runtime/conversation.js +89 -76
- package/dist/runtime/conversation.js.map +1 -1
- package/dist/runtime/singleShot.js +71 -60
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/utils/ProgressIndicator.d.ts +21 -0
- package/dist/utils/ProgressIndicator.js +30 -3
- package/dist/utils/ProgressIndicator.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +30 -0
- package/dist/utils/consoleUtils.js +27 -2
- package/dist/utils/consoleUtils.js.map +1 -1
- package/dist/utils/crashHandler.d.ts +87 -0
- package/dist/utils/crashHandler.js +128 -0
- package/dist/utils/crashHandler.js.map +1 -0
- package/dist/utils/debugDump.d.ts +58 -0
- package/dist/utils/debugDump.js +149 -15
- package/dist/utils/debugDump.js.map +1 -1
- package/dist/utils/llmUtils.d.ts +22 -8
- package/dist/utils/llmUtils.js +48 -8
- package/dist/utils/llmUtils.js.map +1 -1
- package/dist/utils/redactSecrets.js +68 -20
- package/dist/utils/redactSecrets.js.map +1 -1
- package/dist/utils/systemUtils.d.ts +17 -0
- package/dist/utils/systemUtils.js +17 -0
- package/dist/utils/systemUtils.js.map +1 -1
- package/package.json +5 -2
- package/schema/gsloth-config.schema.json +1484 -466
- package/dist/core/shell/allowlist.d.ts +0 -75
- package/dist/core/shell/allowlist.js +0 -187
- package/dist/core/shell/allowlist.js.map +0 -1
- package/dist/core/shell/judge.d.ts +0 -161
- package/dist/core/shell/judge.js +0 -261
- package/dist/core/shell/judge.js.map +0 -1
package/dist/config/schema.js
CHANGED
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
* is additive and legitimately differs (no deprecated fields).
|
|
36
36
|
*/
|
|
37
37
|
import { z } from 'zod';
|
|
38
|
+
// `constants.ts` is a plain, import-free string module, so this does NOT compromise the purity
|
|
39
|
+
// this file depends on (it feeds `z.toJSONSchema` and must stay cwd/fs-independent).
|
|
40
|
+
import { GSLOTH_DIR, GSLOTH_SETTINGS_DIR } from '#src/constants.js';
|
|
38
41
|
const filesystemSchema = z.union([z.array(z.string()), z.enum(['all', 'read', 'none'])]);
|
|
39
42
|
/**
|
|
40
43
|
* TLS trust for outbound HTTPS (MCP servers over `http` transport, and — because the mechanism is
|
|
@@ -84,31 +87,412 @@ const binaryFormatConfigSchema = z.object({
|
|
|
84
87
|
mimeTypes: z.record(z.string(), z.string()).optional(),
|
|
85
88
|
});
|
|
86
89
|
const binaryFormatsSchema = z.union([z.literal(false), z.array(binaryFormatConfigSchema)]);
|
|
87
|
-
|
|
90
|
+
/**
|
|
91
|
+
* CFG-27 — the five rungs of the approvals ladder, as the schema sees them. Kept as a literal
|
|
92
|
+
* tuple here (rather than imported from `shell-policy.ts`) so the schema module stays the single
|
|
93
|
+
* pre-parse source of truth for what the config channel accepts; `APPROVAL_RUNGS` in
|
|
94
|
+
* `shell-policy.ts` is the runtime twin and `configSchema.spec.ts` pins the two together.
|
|
95
|
+
*/
|
|
96
|
+
const APPROVAL_RUNG_VALUES = ['read-only', 'write', 'auto-safe', 'full-auto', 'bypass'];
|
|
97
|
+
/**
|
|
98
|
+
* EXT-71 §3.1 — the **subject** axis of a rule entry, and only that: `shell` is a command, `tool`
|
|
99
|
+
* a built-in or custom in-process tool, `mcpTool` a server's tool. The hand-written twin is
|
|
100
|
+
* `ApprovalEntryType` in `shell-policy.ts`. What holds the two together is
|
|
101
|
+
* `approvalEntrySchema.spec.ts`, where a list of `ApprovalEntry`-typed literals is parsed by this
|
|
102
|
+
* schema: a value either side stops accepting fails there. That is a weaker pin than a direct
|
|
103
|
+
* equality assertion — it catches a narrowing, not a widening on one side alone.
|
|
104
|
+
*/
|
|
105
|
+
export const APPROVAL_ENTRY_TYPES = ['shell', 'tool', 'mcpTool'];
|
|
106
|
+
/**
|
|
107
|
+
* EXT-71 §3.1 — the **comparison** axis of a rule entry, and only that. `exact`/`glob`/`regexp`
|
|
108
|
+
* take a string pattern; `hint` takes an object over the annotation names and is valid on tool
|
|
109
|
+
* subjects only (on `shell` it is a config error — see {@link shellEntrySchema}).
|
|
110
|
+
*/
|
|
111
|
+
export const APPROVAL_ENTRY_MATCHERS = ['exact', 'glob', 'regexp', 'hint'];
|
|
112
|
+
/**
|
|
113
|
+
* EXT-71 §3.1 / §4.7 — the four MCP `ToolAnnotations` booleans a `hint` pattern may name, and the
|
|
114
|
+
* names a user may list in `approvals.mcp.*.trustAnnotations` (§4.7.1). This is the whole
|
|
115
|
+
* vocabulary: an unknown name is a config error, never an ignored key, because a hint pattern that
|
|
116
|
+
* quietly drops a constraint matches MORE than its author wrote, and a trust list that quietly
|
|
117
|
+
* drops one reads as working while believing something else.
|
|
118
|
+
*
|
|
119
|
+
* **The runtime twin `TOOL_ANNOTATION_HINTS` in `shell-policy.ts` is a deliberate duplicate, and
|
|
120
|
+
* the reason is layering, not oversight.** Neither file may import the other. This module must stay
|
|
121
|
+
* pure and cwd/fs-independent because it feeds `z.toJSONSchema()` (see the header), and importing
|
|
122
|
+
* `shell-policy.ts` would pull `core/types.js` and the whole runtime policy surface into it;
|
|
123
|
+
* importing this module from there would in turn pull zod into every module that only wanted a
|
|
124
|
+
* policy type. So the vocabulary is written once per layer on purpose — do not "simplify" it by
|
|
125
|
+
* making one import the other. The equality assertion in `mcpApprovalsBlock.spec.ts` is what fails
|
|
126
|
+
* when they drift.
|
|
127
|
+
*/
|
|
128
|
+
export const HINT_ANNOTATION_KEYS = [
|
|
129
|
+
'readOnlyHint',
|
|
130
|
+
'destructiveHint',
|
|
131
|
+
'idempotentHint',
|
|
132
|
+
'openWorldHint',
|
|
133
|
+
];
|
|
134
|
+
/**
|
|
135
|
+
* EXT-71 §3.1 — the length cap on a `regexp` pattern, enforced when the config LOADS.
|
|
136
|
+
*
|
|
137
|
+
* 200 characters. The longest pattern the spec itself writes is under 40, and every rule entry
|
|
138
|
+
* names one command or tool shape rather than a grammar, so 200 is an order of magnitude of
|
|
139
|
+
* headroom over real use while still bounding what the matcher can ever be handed. The cap is a
|
|
140
|
+
* cheap load-time bound, NOT a backtracking defence — a short pattern can backtrack
|
|
141
|
+
* catastrophically too, and the run-time match budget is the separate backstop for that. What the
|
|
142
|
+
* cap buys is that a pattern nobody could have read and reviewed cannot be smuggled past load.
|
|
143
|
+
*/
|
|
144
|
+
export const APPROVAL_REGEXP_MAX_LENGTH = 200;
|
|
145
|
+
/**
|
|
146
|
+
* EXT-71 §3.1 — a `hint` pattern: an object over {@link HINT_ANNOTATION_KEYS} mapping each named
|
|
147
|
+
* annotation to the boolean it must effectively hold. All named hints must match (AND within the
|
|
148
|
+
* entry); hints not named are unconstrained; `false` is the spelling of negation.
|
|
149
|
+
*
|
|
150
|
+
* Strict, and non-empty: an empty object or an unknown name is a hard config error and **never a
|
|
151
|
+
* match-everything**. `minProperties` is attached as metadata rather than being left to the
|
|
152
|
+
* refinement alone so the constraint survives into the emitted JSON Schema (zod drops refinements
|
|
153
|
+
* there), which is what the hosted schema channels and editor validation actually read.
|
|
154
|
+
*/
|
|
155
|
+
const hintPatternSchema = z
|
|
156
|
+
.strictObject({
|
|
157
|
+
readOnlyHint: z.boolean().optional(),
|
|
158
|
+
destructiveHint: z.boolean().optional(),
|
|
159
|
+
idempotentHint: z.boolean().optional(),
|
|
160
|
+
openWorldHint: z.boolean().optional(),
|
|
161
|
+
})
|
|
162
|
+
.meta({ minProperties: 1 })
|
|
163
|
+
.refine((pattern) => Object.keys(pattern).length > 0, {
|
|
164
|
+
message: 'a hint pattern must name at least one of ' +
|
|
165
|
+
HINT_ANNOTATION_KEYS.join(', ') +
|
|
166
|
+
' — an empty object is a config error, never a match-everything',
|
|
167
|
+
});
|
|
168
|
+
/**
|
|
169
|
+
* EXT-71 §3.1 — a `regexp` pattern: capped at {@link APPROVAL_REGEXP_MAX_LENGTH} and required to
|
|
170
|
+
* COMPILE when the config loads, never when it first runs. Both failures name the offending
|
|
171
|
+
* pattern in the message: an entry the user cannot trace back to the line they wrote is
|
|
172
|
+
* indistinguishable from a bug.
|
|
173
|
+
*
|
|
174
|
+
* `.max()` is kept alongside the refinement so the cap emits as `maxLength` in the JSON Schema
|
|
175
|
+
* (refinements do not survive `z.toJSONSchema`); the refinement is what produces the message that
|
|
176
|
+
* quotes the pattern.
|
|
177
|
+
*/
|
|
178
|
+
const regexpPatternSchema = z
|
|
179
|
+
.string()
|
|
180
|
+
.max(APPROVAL_REGEXP_MAX_LENGTH)
|
|
181
|
+
.superRefine((pattern, ctx) => {
|
|
182
|
+
if (pattern.length > APPROVAL_REGEXP_MAX_LENGTH) {
|
|
183
|
+
ctx.addIssue({
|
|
184
|
+
code: 'custom',
|
|
185
|
+
message: `regexp pattern ${JSON.stringify(pattern)} is ${pattern.length} characters, over the ` +
|
|
186
|
+
`${APPROVAL_REGEXP_MAX_LENGTH}-character cap for an approvals rule pattern`,
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
new RegExp(pattern);
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
ctx.addIssue({
|
|
195
|
+
code: 'custom',
|
|
196
|
+
message: `regexp pattern ${JSON.stringify(pattern)} does not compile: ` +
|
|
197
|
+
`${e instanceof Error ? e.message : String(e)}`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
/** `rate` (§3.2) — optional on EVERY entry type, and the only optional field they all share. */
|
|
202
|
+
const rateField = { rate: z.boolean().optional() };
|
|
203
|
+
/**
|
|
204
|
+
* `host` (§4.7.4) — optional on TOOL subjects only, exact-match. Forbidden on `shell`, where the
|
|
205
|
+
* host is already inside the command string; the shell arms are strict objects, so writing it
|
|
206
|
+
* there is an unrecognized-key error.
|
|
207
|
+
*/
|
|
208
|
+
const hostField = { host: z.string().min(1).optional() };
|
|
209
|
+
/**
|
|
210
|
+
* EXT-71 §3.1 — a `shell` entry. `matcher` deliberately omits `hint`: a command carries no tool
|
|
211
|
+
* annotations, so `{"type":"shell","matcher":"hint"}` is a discriminator error naming the three
|
|
212
|
+
* matchers a command actually supports. Neither `server` nor `host` exists here.
|
|
213
|
+
*/
|
|
214
|
+
const shellEntrySchema = z.discriminatedUnion('matcher', [
|
|
215
|
+
z.strictObject({
|
|
216
|
+
type: z.literal('shell'),
|
|
217
|
+
matcher: z.literal('exact'),
|
|
218
|
+
pattern: z.string(),
|
|
219
|
+
...rateField,
|
|
220
|
+
}),
|
|
221
|
+
z.strictObject({
|
|
222
|
+
type: z.literal('shell'),
|
|
223
|
+
matcher: z.literal('glob'),
|
|
224
|
+
pattern: z.string(),
|
|
225
|
+
...rateField,
|
|
226
|
+
}),
|
|
227
|
+
z.strictObject({
|
|
228
|
+
type: z.literal('shell'),
|
|
229
|
+
matcher: z.literal('regexp'),
|
|
230
|
+
pattern: regexpPatternSchema,
|
|
231
|
+
...rateField,
|
|
232
|
+
}),
|
|
233
|
+
]);
|
|
234
|
+
/** EXT-71 §3.1 — a `tool` entry: a built-in or custom in-process tool, matched on its name. */
|
|
235
|
+
const toolEntrySchema = z.discriminatedUnion('matcher', [
|
|
236
|
+
z.strictObject({
|
|
237
|
+
type: z.literal('tool'),
|
|
238
|
+
matcher: z.literal('exact'),
|
|
239
|
+
pattern: z.string(),
|
|
240
|
+
...hostField,
|
|
241
|
+
...rateField,
|
|
242
|
+
}),
|
|
243
|
+
z.strictObject({
|
|
244
|
+
type: z.literal('tool'),
|
|
245
|
+
matcher: z.literal('glob'),
|
|
246
|
+
pattern: z.string(),
|
|
247
|
+
...hostField,
|
|
248
|
+
...rateField,
|
|
249
|
+
}),
|
|
250
|
+
z.strictObject({
|
|
251
|
+
type: z.literal('tool'),
|
|
252
|
+
matcher: z.literal('regexp'),
|
|
253
|
+
pattern: regexpPatternSchema,
|
|
254
|
+
...hostField,
|
|
255
|
+
...rateField,
|
|
256
|
+
}),
|
|
257
|
+
z.strictObject({
|
|
258
|
+
type: z.literal('tool'),
|
|
259
|
+
matcher: z.literal('hint'),
|
|
260
|
+
pattern: hintPatternSchema,
|
|
261
|
+
...hostField,
|
|
262
|
+
...rateField,
|
|
263
|
+
}),
|
|
264
|
+
]);
|
|
265
|
+
/**
|
|
266
|
+
* EXT-71 §3.1 — an `mcpTool` entry. `server` is **required** here and exists nowhere else: it is
|
|
267
|
+
* the user's own key in `mcpServers` (§4.7.5), the only stable, unique, user-authored identity a
|
|
268
|
+
* server has. The literal `*` is reserved to mean every server, which is why a configured server
|
|
269
|
+
* may not be named `*` ({@link findApprovalsGrammarIssues}).
|
|
270
|
+
*/
|
|
271
|
+
const mcpToolEntrySchema = z.discriminatedUnion('matcher', [
|
|
272
|
+
z.strictObject({
|
|
273
|
+
type: z.literal('mcpTool'),
|
|
274
|
+
matcher: z.literal('exact'),
|
|
275
|
+
server: z.string().min(1),
|
|
276
|
+
pattern: z.string(),
|
|
277
|
+
...hostField,
|
|
278
|
+
...rateField,
|
|
279
|
+
}),
|
|
280
|
+
z.strictObject({
|
|
281
|
+
type: z.literal('mcpTool'),
|
|
282
|
+
matcher: z.literal('glob'),
|
|
283
|
+
server: z.string().min(1),
|
|
284
|
+
pattern: z.string(),
|
|
285
|
+
...hostField,
|
|
286
|
+
...rateField,
|
|
287
|
+
}),
|
|
288
|
+
z.strictObject({
|
|
289
|
+
type: z.literal('mcpTool'),
|
|
290
|
+
matcher: z.literal('regexp'),
|
|
291
|
+
server: z.string().min(1),
|
|
292
|
+
pattern: regexpPatternSchema,
|
|
293
|
+
...hostField,
|
|
294
|
+
...rateField,
|
|
295
|
+
}),
|
|
296
|
+
z.strictObject({
|
|
297
|
+
type: z.literal('mcpTool'),
|
|
298
|
+
matcher: z.literal('hint'),
|
|
299
|
+
server: z.string().min(1),
|
|
300
|
+
pattern: hintPatternSchema,
|
|
301
|
+
...hostField,
|
|
302
|
+
...rateField,
|
|
303
|
+
}),
|
|
304
|
+
]);
|
|
305
|
+
/**
|
|
306
|
+
* EXT-71 §3.1 — **one** entry in `allow`, `deny` or `escalate`. All three lists take the same
|
|
307
|
+
* shape, so there is one schema and the list a rule sits in decides only what a match DOES.
|
|
308
|
+
*
|
|
309
|
+
* `type`, `matcher` and `pattern` are required on every arm: no field is inferred, and no entry
|
|
310
|
+
* reads two ways. Every arm is a strict object, so any field the grammar does not define — a typo,
|
|
311
|
+
* a `server` on a `shell` entry, a `host` on a `shell` entry — is an unrecognized-key error rather
|
|
312
|
+
* than a silently-ignored key that would widen what the entry matches.
|
|
313
|
+
*
|
|
314
|
+
* The `id` is what makes the emitted JSON Schema hoist this union into `$defs` and reference it,
|
|
315
|
+
* instead of inlining all eleven arms into each of the twenty-four places a rule list appears
|
|
316
|
+
* (three lists × the root plus seven commands). That is the difference between a schema an editor loads
|
|
317
|
+
* and one it chokes on, and `$ref` is the standard spelling every JSON Schema consumer already
|
|
318
|
+
* understands.
|
|
319
|
+
*/
|
|
320
|
+
export const approvalEntrySchema = z
|
|
321
|
+
.discriminatedUnion('type', [shellEntrySchema, toolEntrySchema, mcpToolEntrySchema])
|
|
322
|
+
.meta({ id: 'ApprovalEntry' });
|
|
323
|
+
/** The three rule lists, keyed as they appear under `approvals` (§3, §9). */
|
|
324
|
+
const APPROVAL_LIST_KEYS = ['allow', 'deny', 'escalate'];
|
|
325
|
+
/**
|
|
326
|
+
* EXT-70 §4.7.1/§9.1 — `trustAnnotations`: **the hints believed from one server**, as a LIST of
|
|
327
|
+
* {@link HINT_ANNOTATION_KEYS} names and never a boolean, because trusting `readOnlyHint` while
|
|
328
|
+
* distrusting `openWorldHint` is a coherent position and the common one.
|
|
329
|
+
*
|
|
330
|
+
* An unknown name is a hard config error rather than an ignored member: a list that quietly drops
|
|
331
|
+
* a name the user believed they wrote trusts LESS than they asked in one direction and reads as a
|
|
332
|
+
* working config in the other, and a trust list nobody can verify from its own error output is
|
|
333
|
+
* worse than none. The custom message quotes the offending value, since the path alone gives an
|
|
334
|
+
* index and the user needs the word they mistyped.
|
|
335
|
+
*/
|
|
336
|
+
const trustAnnotationsSchema = z.array(z.enum(HINT_ANNOTATION_KEYS, {
|
|
337
|
+
error: (issue) => `${JSON.stringify(issue.input)} is not an MCP tool annotation. trustAnnotations names the ` +
|
|
338
|
+
`hints believed from a server, and the whole vocabulary is ` +
|
|
339
|
+
`${HINT_ANNOTATION_KEYS.join(', ')}.`,
|
|
340
|
+
}));
|
|
341
|
+
/**
|
|
342
|
+
* EXT-70 §4.7/§9 — one server's entry under `approvals.mcp.servers`, and the shape `defaults`
|
|
343
|
+
* takes. **Strict**: an unrecognized key is an error, so a hint list misspelt as a whole key
|
|
344
|
+
* (`trustAnnotation`, `trust`) fails loudly instead of silently trusting nothing while reading as
|
|
345
|
+
* though it trusted something.
|
|
346
|
+
*/
|
|
347
|
+
const mcpServerApprovalsSchema = z.strictObject({
|
|
348
|
+
trustAnnotations: trustAnnotationsSchema.optional(),
|
|
349
|
+
});
|
|
350
|
+
/**
|
|
351
|
+
* EXT-70 §4.7/§9 — the `approvals.mcp` block. `defaults` covers servers not named under `servers`;
|
|
352
|
+
* `servers` is keyed by the user's own `mcpServers` config key (§4.7.5) and is deliberately NOT
|
|
353
|
+
* checked against `mcpServers`, so policy may be written before the server it describes.
|
|
354
|
+
*
|
|
355
|
+
* Strict, and it stays strict: `expose` (§4.7.6) belongs to [[EXT-73]] and is not accepted here
|
|
356
|
+
* until that node adds it deliberately. A permissive block would accept `expose` today, do nothing
|
|
357
|
+
* with it, and leave a user believing their tools were filtered.
|
|
358
|
+
*/
|
|
359
|
+
const mcpApprovalsSchema = z.strictObject({
|
|
360
|
+
defaults: mcpServerApprovalsSchema.optional(),
|
|
361
|
+
servers: z.record(z.string().min(1), mcpServerApprovalsSchema).optional(),
|
|
362
|
+
});
|
|
363
|
+
/**
|
|
364
|
+
* EXT-71 §3.1 — render an entry in the object form the user would write in a config file, with the
|
|
365
|
+
* fields in grammar order (`type`, `server`, `matcher`, `pattern`, then the optional bounds).
|
|
366
|
+
*
|
|
367
|
+
* The ONE place that spelling is produced, because it is shown in two very different moments that
|
|
368
|
+
* must agree: the load-time error that tells a user what to write instead of their bare string, and
|
|
369
|
+
* the escalation menu's *this is what will be stored* line (§6). A grant the menu describes one way
|
|
370
|
+
* and stores another is exactly the drift this design cannot afford.
|
|
371
|
+
*/
|
|
372
|
+
export function renderApprovalEntryObject(entry) {
|
|
373
|
+
const fields = [['type', entry.type]];
|
|
374
|
+
if (entry.server !== undefined)
|
|
375
|
+
fields.push(['server', entry.server]);
|
|
376
|
+
fields.push(['matcher', entry.matcher], ['pattern', entry.pattern]);
|
|
377
|
+
if (entry.host !== undefined)
|
|
378
|
+
fields.push(['host', entry.host]);
|
|
379
|
+
if (entry.rate !== undefined)
|
|
380
|
+
fields.push(['rate', entry.rate]);
|
|
381
|
+
const rendered = fields.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`);
|
|
382
|
+
return `{ ${rendered.join(', ')} }`;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* EXT-71 §2.3/§9.1 — render the object form of a bare string found in a rule list, so the
|
|
386
|
+
* migration error shows the user the entry they should have written *for their own string*
|
|
387
|
+
* rather than a generic example.
|
|
388
|
+
*/
|
|
389
|
+
export function renderApprovalEntryForString(pattern) {
|
|
390
|
+
return renderApprovalEntryObject({ type: 'shell', matcher: 'exact', pattern });
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* CFG-27 — the `approvals` value: **either the rung name on its own, or an object when the extras
|
|
394
|
+
* are needed** (spec §9). There are no other approvals keys.
|
|
395
|
+
*
|
|
396
|
+
* ```jsonc
|
|
397
|
+
* { "approvals": "auto-safe" }
|
|
398
|
+
* ```
|
|
399
|
+
* ```jsonc
|
|
400
|
+
* { "approvals": {
|
|
401
|
+
* "mode": "auto-safe",
|
|
402
|
+
* "rater": "safety-rater", // identity profile the rater runs under
|
|
403
|
+
* "allow": [ { "type": "shell", "matcher": "exact", "pattern": "npm test" } ],
|
|
404
|
+
* "deny": [ { "type": "shell", "matcher": "glob", "pattern": "npm publish*" } ],
|
|
405
|
+
* "escalate": [ { "type": "shell", "matcher": "exact", "pattern": "terraform apply" } ]
|
|
406
|
+
* } }
|
|
407
|
+
* ```
|
|
408
|
+
*
|
|
409
|
+
* - **The scalar form is exactly sugar for `{ "mode": <value> }`** (§9.1). The union exists so the
|
|
410
|
+
* extras have a home when they are needed, not so there are two ways to say the same thing.
|
|
411
|
+
* - `rater` is a **bare identity-profile name**, not an object (strict resolution, GS2-62: a name
|
|
412
|
+
* that does not resolve is a hard config error, never a silent fallback).
|
|
413
|
+
* - `raterTimeoutMs` (EXT-66) is the wall-clock budget for ONE rating call, defaulting to
|
|
414
|
+
* `RATER_DEFAULT_TIMEOUT_MS` (30s) at the read site. **It exists because 30s is a hosted-model
|
|
415
|
+
* number and a local model is knowably slower**: measured 2026-07-31, `gemma4:12b` over Ollama
|
|
416
|
+
* answered a 23-case corpus in 6.0s–114.7s, and at the fixed limit 3 of 18 calls in one run and
|
|
417
|
+
* 9 of 17 in the next were cut off — so a local `full-auto` session degraded toward escalating
|
|
418
|
+
* everything, which is the opposite of what the rung is for. Deliberately a number the user owns
|
|
419
|
+
* rather than a provider→timeout table: a table is a guess about someone else's hardware, and
|
|
420
|
+
* the failure it causes is silent.
|
|
421
|
+
* - `allow`/`deny`/`escalate` are **read-only input**: merged with the runtime stores the
|
|
422
|
+
* escalation menu writes, and never written back to config. Every entry is the §3.1 object
|
|
423
|
+
* ({@link approvalEntrySchema}); a bare string in any of the three is a hard config error whose
|
|
424
|
+
* message shows the object form of that same string ({@link findApprovalsGrammarIssues}).
|
|
425
|
+
* - `escalate` is the third list (§3, §3.2): a match always asks the human, whatever the rung
|
|
426
|
+
* would have done. It takes the same entries as the other two.
|
|
427
|
+
* - `mcp` (EXT-70 §4.7) holds the per-server relationship — which hints are believed from which
|
|
428
|
+
* server — keyed by the user's own `mcpServers` config key, with `defaults` for servers not
|
|
429
|
+
* named. Absent or empty `trustAnnotations` believes nothing external, which is also the default.
|
|
430
|
+
* - The retired `strictness` / `allowlist` / `persistAllowlist` keys and the retired `auto` / `ask`
|
|
431
|
+
* mode values are hard migration errors naming their replacement — see `RETIRED_APPROVALS_KEYS` /
|
|
432
|
+
* `RETIRED_APPROVAL_MODES` in {@link findDeprecatedConfigIssues}. So is a NON-ARRAY `escalate`,
|
|
433
|
+
* which is the retired severity threshold rather than the new list.
|
|
434
|
+
*
|
|
435
|
+
* Defaults are applied at the READ site (`resolveApprovals` in `shell-policy.ts`), not in
|
|
436
|
+
* DEFAULT_CONFIG, so the effective-config snapshot never churns (à la GS2-34/GS2-63).
|
|
437
|
+
*
|
|
438
|
+
* NOTE: "judge" is reserved for the eval grader (`gth eval --judge <profile>`) — a different
|
|
439
|
+
* concept that keeps its name.
|
|
440
|
+
*/
|
|
441
|
+
const approvalsSchema = z.union([
|
|
442
|
+
z.enum(APPROVAL_RUNG_VALUES),
|
|
443
|
+
z.object({
|
|
444
|
+
mode: z.enum(APPROVAL_RUNG_VALUES).optional(),
|
|
445
|
+
rater: z.string().optional(),
|
|
446
|
+
// EXT-71 §3.1 — the three rule lists. Same entry grammar in all three; the list decides only
|
|
447
|
+
// what a match DOES (§3: deny over escalate over allow).
|
|
448
|
+
allow: z.array(approvalEntrySchema).optional(),
|
|
449
|
+
deny: z.array(approvalEntrySchema).optional(),
|
|
450
|
+
escalate: z.array(approvalEntrySchema).optional(),
|
|
451
|
+
// EXT-66 — wall-clock budget (ms) for one rating call. See the note on the union above for
|
|
452
|
+
// why this is a user-owned number rather than a per-provider table.
|
|
453
|
+
// `.min(1)`, not `.positive()`: the latter emits `exclusiveMinimum`, a JSON-Schema draft
|
|
454
|
+
// keyword this repo avoids on principle (GS2-57 — Google GenAI rejects it outright in tool
|
|
455
|
+
// declarations). This schema is not a tool declaration, but one spelling everywhere is what
|
|
456
|
+
// stops the wrong one being copied into somewhere that is.
|
|
457
|
+
raterTimeoutMs: z.number().int().min(1).optional(),
|
|
458
|
+
// EXT-70 §4.7/§9 — the per-server MCP relationship, keyed by the user's own `mcpServers` key.
|
|
459
|
+
mcp: mcpApprovalsSchema.optional(),
|
|
460
|
+
}),
|
|
461
|
+
]);
|
|
462
|
+
/**
|
|
463
|
+
* EXT-36 — the tool-loop guard (repeated identical `(tool, args)` / no-progress detector), the
|
|
464
|
+
* orthogonal sibling of GS2-36's error budget. A boolean-or-object union: `false` disables
|
|
465
|
+
* it entirely; `true`/absent is warn-on defaults; the object form is fine-grained. `warn` (default
|
|
466
|
+
* ON) injects a control-flow-free nudge at the threshold; `halt` (default OFF, opt-in) ends the run
|
|
467
|
+
* cleanly at the threshold; `threshold` is the number of consecutive identical calls that trip it.
|
|
468
|
+
* The warn-on default is applied at the read site, so this field is intentionally absent from
|
|
469
|
+
* DEFAULT_CONFIG.
|
|
470
|
+
*/
|
|
471
|
+
const toolLoopGuardSchema = z.union([
|
|
88
472
|
z.boolean(),
|
|
89
473
|
z.object({
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
model: llmConfigSchema.optional(),
|
|
474
|
+
warn: z.boolean().optional(),
|
|
475
|
+
halt: z.boolean().optional(),
|
|
476
|
+
threshold: z.number().optional(),
|
|
94
477
|
}),
|
|
95
478
|
]);
|
|
96
479
|
/**
|
|
97
480
|
* CFG-18 — the per-tool config object carried as a value in the widened `builtInTools` registry.
|
|
98
481
|
* One permissive shape covering every tool: `command` for the fixed dev-command tools
|
|
99
|
-
* (run_tests/run_lint/run_build/run_single_test), the EXT-
|
|
100
|
-
*
|
|
101
|
-
* plain built-in tool.
|
|
482
|
+
* (run_tests/run_lint/run_build/run_single_test), the EXT-12 execution knobs for
|
|
483
|
+
* `run_shell_command` (`timeout`/`maxOutputBytes`), `fileSet` for `gth_grep` (GS2-51), and
|
|
484
|
+
* `enabled` for a plain built-in tool.
|
|
485
|
+
*
|
|
486
|
+
* CFG-26 — the APPROVAL knobs that used to live here (`allowlist`, `persistAllowlist`, `judge`,
|
|
487
|
+
* `yolo`) moved to the top-level {@link approvalsSchema}. They were fields of the object shared by
|
|
488
|
+
* EVERY built-in tool, which is why a nonsensical `gth_grep: { yolo: true }` validated. Each is now
|
|
489
|
+
* a hard migration error — see `RETIRED_SHELL_TOOL_PAIRS` in {@link findDeprecatedConfigIssues}.
|
|
102
490
|
*/
|
|
103
491
|
const builtInToolConfigSchema = z.object({
|
|
104
492
|
enabled: z.boolean().optional(),
|
|
105
493
|
command: z.string().optional(),
|
|
106
494
|
timeout: z.number().optional(),
|
|
107
495
|
maxOutputBytes: z.number().optional(),
|
|
108
|
-
allowlist: z.boolean().optional(),
|
|
109
|
-
persistAllowlist: z.boolean().optional(),
|
|
110
|
-
judge: shellJudgeSchema.optional(),
|
|
111
|
-
yolo: z.boolean().optional(),
|
|
112
496
|
// GS2-51 — `gth_grep`: which corpus to search. `gitignore` (default) respects .gitignore/.ignore
|
|
113
497
|
// and skips hidden dot-files; `all` scans everything but the noise dirs. See BuiltInToolConfig.
|
|
114
498
|
fileSet: z.enum(['gitignore', 'all']).optional(),
|
|
@@ -123,11 +507,48 @@ const builtInToolsSchema = z.union([
|
|
|
123
507
|
z.array(z.string()),
|
|
124
508
|
z.record(z.string(), z.union([z.boolean(), builtInToolConfigSchema])),
|
|
125
509
|
]);
|
|
510
|
+
/**
|
|
511
|
+
* GS2-43 — one prompt segment inside the {@link promptsSchema} object. Either a `string`
|
|
512
|
+
* (shorthand for `{ path }`) or a config object:
|
|
513
|
+
* - `path` — file to read for this segment (resolved like every prompt file: config dir /
|
|
514
|
+
* identity profile first, then relative to the project root).
|
|
515
|
+
* - `enabled: false` — drop the segment entirely (even its bundled default).
|
|
516
|
+
* - `mode` — `'replace'` (default): the file replaces the built-in segment content;
|
|
517
|
+
* `'append'`: the file content is appended after the built-in content.
|
|
518
|
+
*/
|
|
519
|
+
const promptSegmentSchema = z.union([
|
|
520
|
+
z.string(),
|
|
521
|
+
z.object({
|
|
522
|
+
path: z.string().optional(),
|
|
523
|
+
enabled: z.boolean().optional(),
|
|
524
|
+
mode: z.enum(['replace', 'append']).optional(),
|
|
525
|
+
}),
|
|
526
|
+
]);
|
|
527
|
+
/**
|
|
528
|
+
* GS2-43 — the unified `prompts` config object (CFG-18's flat-key→rich-object precedent).
|
|
529
|
+
* Replaces the removed flat `projectGuidelines` / `projectReviewInstructions` keys and makes
|
|
530
|
+
* ALL seven prompt segments retargetable through config (previously backstory/system/chat/
|
|
531
|
+
* code/exec were reachable only by placing a file in the config dir). Kept as a plain
|
|
532
|
+
* `z.object` of optional sibling keys so a future segment (e.g. GS2-44's `agents`) is a
|
|
533
|
+
* one-line addition with no collision risk.
|
|
534
|
+
*/
|
|
535
|
+
const promptsSchema = z.object({
|
|
536
|
+
backstory: promptSegmentSchema.optional(),
|
|
537
|
+
guidelines: promptSegmentSchema.optional(),
|
|
538
|
+
system: promptSegmentSchema.optional(),
|
|
539
|
+
chat: promptSegmentSchema.optional(),
|
|
540
|
+
code: promptSegmentSchema.optional(),
|
|
541
|
+
exec: promptSegmentSchema.optional(),
|
|
542
|
+
review: promptSegmentSchema.optional(),
|
|
543
|
+
});
|
|
126
544
|
const prCommandSchema = z.object({
|
|
127
545
|
contentSource: z.string().optional(),
|
|
128
546
|
requirementSource: z.string().optional(),
|
|
129
547
|
filesystem: filesystemSchema.optional(),
|
|
130
548
|
builtInTools: builtInToolsSchema.optional(),
|
|
549
|
+
// CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
|
|
550
|
+
// `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
|
|
551
|
+
approvals: approvalsSchema.optional(),
|
|
131
552
|
customTools: customToolsOrFalseSchema.optional(),
|
|
132
553
|
allowedTools: z.array(z.string()).optional(),
|
|
133
554
|
logWorkForReviewInSeconds: z.number().optional(),
|
|
@@ -139,6 +560,9 @@ const reviewCommandSchema = z.object({
|
|
|
139
560
|
requirementSource: z.string().optional(),
|
|
140
561
|
filesystem: filesystemSchema.optional(),
|
|
141
562
|
builtInTools: builtInToolsSchema.optional(),
|
|
563
|
+
// CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
|
|
564
|
+
// `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
|
|
565
|
+
approvals: approvalsSchema.optional(),
|
|
142
566
|
customTools: customToolsOrFalseSchema.optional(),
|
|
143
567
|
allowedTools: z.array(z.string()).optional(),
|
|
144
568
|
rating: ratingConfigSchema.optional(),
|
|
@@ -147,6 +571,9 @@ const reviewCommandSchema = z.object({
|
|
|
147
571
|
const askCommandSchema = z.object({
|
|
148
572
|
filesystem: filesystemSchema.optional(),
|
|
149
573
|
builtInTools: builtInToolsSchema.optional(),
|
|
574
|
+
// CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
|
|
575
|
+
// `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
|
|
576
|
+
approvals: approvalsSchema.optional(),
|
|
150
577
|
customTools: customToolsOrFalseSchema.optional(),
|
|
151
578
|
allowedTools: z.array(z.string()).optional(),
|
|
152
579
|
binaryFormats: binaryFormatsSchema.optional(),
|
|
@@ -154,6 +581,9 @@ const askCommandSchema = z.object({
|
|
|
154
581
|
const chatCommandSchema = z.object({
|
|
155
582
|
filesystem: filesystemSchema.optional(),
|
|
156
583
|
builtInTools: builtInToolsSchema.optional(),
|
|
584
|
+
// CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
|
|
585
|
+
// `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
|
|
586
|
+
approvals: approvalsSchema.optional(),
|
|
157
587
|
customTools: customToolsOrFalseSchema.optional(),
|
|
158
588
|
allowedTools: z.array(z.string()).optional(),
|
|
159
589
|
binaryFormats: binaryFormatsSchema.optional(),
|
|
@@ -161,6 +591,9 @@ const chatCommandSchema = z.object({
|
|
|
161
591
|
const codeCommandSchema = z.object({
|
|
162
592
|
filesystem: filesystemSchema.optional(),
|
|
163
593
|
builtInTools: builtInToolsSchema.optional(),
|
|
594
|
+
// CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
|
|
595
|
+
// `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
|
|
596
|
+
approvals: approvalsSchema.optional(),
|
|
164
597
|
customTools: customToolsOrFalseSchema.optional(),
|
|
165
598
|
allowedTools: z.array(z.string()).optional(),
|
|
166
599
|
binaryFormats: binaryFormatsSchema.optional(),
|
|
@@ -168,6 +601,9 @@ const codeCommandSchema = z.object({
|
|
|
168
601
|
const execCommandSchema = z.object({
|
|
169
602
|
filesystem: filesystemSchema.optional(),
|
|
170
603
|
builtInTools: builtInToolsSchema.optional(),
|
|
604
|
+
// CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
|
|
605
|
+
// `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
|
|
606
|
+
approvals: approvalsSchema.optional(),
|
|
171
607
|
customTools: customToolsOrFalseSchema.optional(),
|
|
172
608
|
allowedTools: z.array(z.string()).optional(),
|
|
173
609
|
binaryFormats: binaryFormatsSchema.optional(),
|
|
@@ -175,6 +611,9 @@ const execCommandSchema = z.object({
|
|
|
175
611
|
const apiCommandSchema = z.object({
|
|
176
612
|
filesystem: filesystemSchema.optional(),
|
|
177
613
|
builtInTools: builtInToolsSchema.optional(),
|
|
614
|
+
// CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
|
|
615
|
+
// `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
|
|
616
|
+
approvals: approvalsSchema.optional(),
|
|
178
617
|
port: z.number().optional(),
|
|
179
618
|
cors: z
|
|
180
619
|
.object({
|
|
@@ -184,6 +623,16 @@ const apiCommandSchema = z.object({
|
|
|
184
623
|
})
|
|
185
624
|
.optional(),
|
|
186
625
|
});
|
|
626
|
+
/**
|
|
627
|
+
* GS2-33 — one profile-backed subagent: a `name` the model selects it by, an optional
|
|
628
|
+
* `description`, and the named config `profile` the CHILD resolves through the GS2-1 cascade when
|
|
629
|
+
* spawned. See {@link SubagentProfileSpec}.
|
|
630
|
+
*/
|
|
631
|
+
const subagentSpecSchema = z.object({
|
|
632
|
+
name: z.string(),
|
|
633
|
+
description: z.string().optional(),
|
|
634
|
+
profile: z.string(),
|
|
635
|
+
});
|
|
187
636
|
const commandsSchema = z.object({
|
|
188
637
|
pr: prCommandSchema.optional(),
|
|
189
638
|
review: reviewCommandSchema.optional(),
|
|
@@ -209,6 +658,12 @@ export const rawGthConfigSchema = z.looseObject({
|
|
|
209
658
|
// Allow a JSON Schema reference for editor support; never read at runtime.
|
|
210
659
|
$schema: z.string().optional(),
|
|
211
660
|
llm: llmConfigSchema.optional(),
|
|
661
|
+
// GS2-41 — profile composition. A NAMED profile config may declare `extends: "<base-profile>"` to
|
|
662
|
+
// inherit another profile's config: the base profile resolves FIRST, then this profile's own
|
|
663
|
+
// fields merge on top (last-wins, same GS2-1 deep-merge as the config layers; a base may itself
|
|
664
|
+
// extend another, with a cycle guard). Consumed during load — never appears in the resolved
|
|
665
|
+
// config. See `resolveConfigExtends` in loader.ts.
|
|
666
|
+
extends: z.string().optional(),
|
|
212
667
|
// Selects the agent backend. `lean` (the default when omitted) uses the plain LangChain agent
|
|
213
668
|
// with gsloth's full toolset (no `/large_tool_results` offload). `deep` is the EXPERIMENTAL,
|
|
214
669
|
// opt-in deepagents runtime and emits a warning when selected. The ACP server is deep-only.
|
|
@@ -237,7 +692,8 @@ export const rawGthConfigSchema = z.looseObject({
|
|
|
237
692
|
requirementSource: z.string().optional(),
|
|
238
693
|
contentSourceConfig: z.record(z.string(), z.unknown()).optional(),
|
|
239
694
|
requirementSourceConfig: z.record(z.string(), z.unknown()).optional(),
|
|
240
|
-
projectGuidelines
|
|
695
|
+
// GS2-43 — the unified prompt-segment config; replaces projectGuidelines/projectReviewInstructions.
|
|
696
|
+
prompts: promptsSchema.optional(),
|
|
241
697
|
identityProfile: z.string().optional(),
|
|
242
698
|
includeCurrentDateAfterGuidelines: z.boolean().optional(),
|
|
243
699
|
organization: z
|
|
@@ -247,10 +703,15 @@ export const rawGthConfigSchema = z.looseObject({
|
|
|
247
703
|
timezone: z.string().optional(),
|
|
248
704
|
})
|
|
249
705
|
.optional(),
|
|
250
|
-
projectReviewInstructions: z.string().optional(),
|
|
251
706
|
noDefaultPrompts: z.boolean().optional(),
|
|
252
707
|
filesystem: filesystemSchema.optional(),
|
|
253
708
|
builtInTools: builtInToolsSchema.optional(),
|
|
709
|
+
// CFG-27 — the approvals ladder: a rung name, or an object carrying the rater profile and the
|
|
710
|
+
// declared allow/deny lists. Settable at the root or per command
|
|
711
|
+
// (`commands.<command>.approvals`, which per §9.1 overrides only the fields it names — the
|
|
712
|
+
// restrictive lists concatenate across scopes, `allow` replaces). Absent = `auto-safe`
|
|
713
|
+
// (`resolveApprovals` in shell-policy.ts).
|
|
714
|
+
approvals: approvalsSchema.optional(),
|
|
254
715
|
// Live tool instances / toolkits in JS configs — kept permissive.
|
|
255
716
|
tools: z.array(z.unknown()).optional(),
|
|
256
717
|
allowedTools: z.array(z.string()).optional(),
|
|
@@ -259,7 +720,15 @@ export const rawGthConfigSchema = z.looseObject({
|
|
|
259
720
|
streamOutput: z.boolean().optional(),
|
|
260
721
|
writeOutputToFile: z.union([z.boolean(), z.string()]).optional(),
|
|
261
722
|
writeBinaryOutputsToFile: z.boolean().optional(),
|
|
723
|
+
// CFG-30 — rung 3 of the colour ladder (`config/colour.ts`), NOT the final say: `FORCE_COLOR`
|
|
724
|
+
// and `NO_COLOR` outrank it, and when it is absent colour auto-detects from stdout's TTY status.
|
|
725
|
+
// MUST stay `.optional()` — absence is what distinguishes "the user chose true" from the `true`
|
|
726
|
+
// in `defaults.ts`, and the ladder collapses without that. User docs: docs/configuration/output.md
|
|
262
727
|
useColour: z.boolean().optional(),
|
|
728
|
+
// TUI-C37 — rung 2 of the mouse ladder (`config/mouse.ts`). MUST stay `.optional()` for the same
|
|
729
|
+
// reason as `useColour`: absence is what tells "the user chose true" from the `true` in
|
|
730
|
+
// `defaults.ts`, and rung 2 collapses into rung 4 without it.
|
|
731
|
+
useMouse: z.boolean().optional(),
|
|
263
732
|
streamSessionInferenceLog: z.boolean().optional(),
|
|
264
733
|
canInterruptInferenceWithEsc: z.boolean().optional(),
|
|
265
734
|
debugLog: z.boolean().optional(),
|
|
@@ -320,11 +789,21 @@ export const rawGthConfigSchema = z.looseObject({
|
|
|
320
789
|
header: z.boolean().optional(),
|
|
321
790
|
})
|
|
322
791
|
.optional(),
|
|
792
|
+
// EXT-36 — tool-loop guard (repeated identical (tool, args) / no-progress detector), the sibling
|
|
793
|
+
// of GS2-36's error budget. `false` disables; `true`/absent = warn-on defaults; object =
|
|
794
|
+
// fine-grained ({ warn, halt, threshold }). WARN (default ON) injects a control-flow-free nudge;
|
|
795
|
+
// HALT (default OFF, opt-in) ends the run cleanly at the threshold. Defaulted at the read site
|
|
796
|
+
// (warn on), not in DEFAULT_CONFIG, so the effective-config snapshot never churns.
|
|
797
|
+
toolLoopGuard: toolLoopGuardSchema.optional(),
|
|
323
798
|
// BATCH-19 — custom `gth eval` reporters. Maps a reporter NAME (as selected with
|
|
324
799
|
// `--reporter <name>`) to a MODULE PATH (relative to the project dir) whose default export is an
|
|
325
800
|
// `EvalReporterFactory` (`() => EvalReporter`). Registered through the same seam the bundled
|
|
326
801
|
// reporters use; a name here overrides a built-in of the same name.
|
|
327
802
|
reporters: z.record(z.string(), z.string()).optional(),
|
|
803
|
+
// GS2-33 — profile-backed subagents. Each entry names a subagent and the named config profile the
|
|
804
|
+
// CHILD resolves when the deep backend's `task` tool spawns it, so a subagent can run under a
|
|
805
|
+
// different model/tools/prompt than the parent. See {@link subagentSpecSchema}.
|
|
806
|
+
subagents: z.array(subagentSpecSchema).optional(),
|
|
328
807
|
});
|
|
329
808
|
/**
|
|
330
809
|
* The set of known top-level config keys, derived from the schema shape so it can
|
|
@@ -376,6 +855,9 @@ const DEPRECATED_ROOT_PAIRS = [
|
|
|
376
855
|
['requirementsProvider', 'requirementSource'],
|
|
377
856
|
['contentProviderConfig', 'contentSourceConfig'],
|
|
378
857
|
['requirementsProviderConfig', 'requirementSourceConfig'],
|
|
858
|
+
// GS2-43 — the flat prompt-path keys were folded into the `prompts` object.
|
|
859
|
+
['projectGuidelines', 'prompts.guidelines'],
|
|
860
|
+
['projectReviewInstructions', 'prompts.review'],
|
|
379
861
|
];
|
|
380
862
|
/** Deprecated → canonical key pairs inside a `commands.<name>` block (SSOT for the rejecter). */
|
|
381
863
|
const DEPRECATED_COMMAND_PAIRS = [
|
|
@@ -392,12 +874,195 @@ const DEPRECATED_COMMAND_PAIRS = [
|
|
|
392
874
|
const REMOVED_COMMAND_KEYS = [
|
|
393
875
|
['devTools', 'builtInTools'],
|
|
394
876
|
];
|
|
877
|
+
/**
|
|
878
|
+
* CFG-26 — approval knobs retired from the `builtInTools.run_shell_command` entry and moved to the
|
|
879
|
+
* top-level `approvals` block. `[retired, "how to say it now"]`. Rejected pre-parse for the same
|
|
880
|
+
* reason as {@link REMOVED_COMMAND_KEYS}: `builtInToolConfigSchema` is a strict `z.object`, so once
|
|
881
|
+
* the field is gone zod SILENTLY STRIPS it and an old config would run with its approval posture
|
|
882
|
+
* quietly ignored — the worst possible failure for a safety gate.
|
|
883
|
+
*
|
|
884
|
+
* `judge.autoApproveLow` / `judge.blockHigh` have no 1:1 successor (the rung replaced the
|
|
885
|
+
* `low/medium/high` × `destructive` conjunction), so `judge`'s message points at `approvals.mode`
|
|
886
|
+
* and names the outcome scale that took the tiers' place.
|
|
887
|
+
*/
|
|
888
|
+
const RETIRED_SHELL_TOOL_PAIRS = [
|
|
889
|
+
['yolo', '"approvals": "bypass"'],
|
|
890
|
+
[
|
|
891
|
+
'judge',
|
|
892
|
+
'"approvals": "auto-safe" (or "full-auto"), optionally with "approvals.rater" naming an ' +
|
|
893
|
+
'identity profile — the low/medium/high tiers became the ' +
|
|
894
|
+
'safe/destructive/catastrophic/attack outcomes, and autoApproveLow/blockHigh are replaced ' +
|
|
895
|
+
'by the rung you choose',
|
|
896
|
+
],
|
|
897
|
+
['allowlist', '"approvals.allow" (a declared list of rule entries)'],
|
|
898
|
+
[
|
|
899
|
+
'persistAllowlist',
|
|
900
|
+
'nothing — persistence is a per-decision choice at the approval prompt (approve forgets, ' +
|
|
901
|
+
'always approve persists)',
|
|
902
|
+
],
|
|
903
|
+
];
|
|
904
|
+
/** The `builtInTools` entry the retired approval knobs used to hang off. */
|
|
905
|
+
const SHELL_TOOL_REGISTRY_KEY = 'run_shell_command';
|
|
906
|
+
/**
|
|
907
|
+
* CFG-27 — keys retired from the `approvals` object itself when the four-tier lattice became one
|
|
908
|
+
* ordered ladder of five rungs. `[retired, "how to say it now"]`.
|
|
909
|
+
*
|
|
910
|
+
* Rejected PRE-PARSE rather than left to the union: `approvalsSchema`'s object arm is a `z.object`,
|
|
911
|
+
* which silently strips unknown keys, so an `approvals: { mode: "auto-safe", strictness: "strict" }`
|
|
912
|
+
* would otherwise run with its declared posture quietly ignored — the worst possible failure for a
|
|
913
|
+
* safety gate, and exactly what CFG-26 fixed for the per-tool knobs.
|
|
914
|
+
*
|
|
915
|
+
* `strictness` is **deleted, not remapped**: there are no severity thresholds and no independent
|
|
916
|
+
* rater switch, so the message points at the rung that expresses the intent.
|
|
917
|
+
*
|
|
918
|
+
* The retired severity threshold `escalate` is NOT in this table, because EXT-71 gave the name back
|
|
919
|
+
* as the third rule list (§3). A non-array `escalate` — the shape the threshold had — is still
|
|
920
|
+
* caught, with the same message, by {@link RETIRED_ESCALATE_THRESHOLD_MESSAGE}.
|
|
921
|
+
*/
|
|
922
|
+
const RETIRED_APPROVALS_KEYS = [
|
|
923
|
+
[
|
|
924
|
+
'strictness',
|
|
925
|
+
'nothing — there are no strictness levels any more. Choose a rung instead: ' +
|
|
926
|
+
'"read-only"/"write" never rate, "auto-safe" escalates anything not rated safe, ' +
|
|
927
|
+
'"full-auto" lets the auto-rater decide',
|
|
928
|
+
],
|
|
929
|
+
['allowlist', '"approvals.allow" (a declared list of rule entries)'],
|
|
930
|
+
[
|
|
931
|
+
'persistAllowlist',
|
|
932
|
+
'nothing — persistence is a per-decision choice at the approval prompt (approve forgets, ' +
|
|
933
|
+
'always approve persists)',
|
|
934
|
+
],
|
|
935
|
+
];
|
|
936
|
+
/**
|
|
937
|
+
* EXT-71 — `approvals.escalate` used to be a SEVERITY THRESHOLD (a string), and is now the third
|
|
938
|
+
* rule LIST (an array of §3.1 entries). Only the old shape is an error, so the name could be
|
|
939
|
+
* reused without stranding anyone: a non-array value gets the message that names the rung which
|
|
940
|
+
* expresses the old intent, instead of a bare "expected array, received string".
|
|
941
|
+
*/
|
|
942
|
+
const RETIRED_ESCALATE_THRESHOLD_MESSAGE = 'is now the third rule LIST (an array of {type, matcher, pattern} entries that always ask the ' +
|
|
943
|
+
'human), not a severity threshold. There is no escalate threshold any more: "auto-safe" ' +
|
|
944
|
+
'escalates everything the auto-rater does not rate safe, and "full-auto" does not stop to ask.';
|
|
945
|
+
/**
|
|
946
|
+
* CFG-27 — retired `approvals.mode` VALUES → the rung that replaced them. The three-mode
|
|
947
|
+
* vocabulary is gone: `auto` was the rater-mediated mode (now the `auto-safe` rung) and `ask`
|
|
948
|
+
* meant "prompt for everything the allow-list does not cover" (now `read-only`, or `write` if the
|
|
949
|
+
* agent should still edit files freely). `bypass` survives unchanged and is deliberately absent.
|
|
950
|
+
*
|
|
951
|
+
* Caught here rather than by the enum so the error NAMES the rung instead of listing five
|
|
952
|
+
* identifiers and leaving the user to guess which one preserves their intent.
|
|
953
|
+
*/
|
|
954
|
+
const RETIRED_APPROVAL_MODES = [
|
|
955
|
+
[
|
|
956
|
+
'auto',
|
|
957
|
+
'"auto-safe" (the auto-rater rates every gated call and escalates anything questionable)',
|
|
958
|
+
],
|
|
959
|
+
[
|
|
960
|
+
'ask',
|
|
961
|
+
'"write" (Gaunt Sloth edits files freely and asks about everything else) or "read-only" ' +
|
|
962
|
+
'(it asks before writing too)',
|
|
963
|
+
],
|
|
964
|
+
];
|
|
395
965
|
/**
|
|
396
966
|
* Pointer to the migration path, appended to every deprecated-shape error so the user
|
|
397
|
-
* always learns HOW to fix it, not just that it broke.
|
|
398
|
-
*
|
|
967
|
+
* always learns HOW to fix it, not just that it broke. Doc link only, per DOC-STYLE
|
|
968
|
+
* rule 9 (user-visible doc references are absolute GitHub URLs).
|
|
969
|
+
* GS2-5 (B15) will ship `gth config migrate` / `gth doctor --fix`; restore the command
|
|
970
|
+
* reference here when that tooling lands.
|
|
399
971
|
*/
|
|
400
|
-
const MIGRATION_HINT = 'See the 2.0 migration notes
|
|
972
|
+
const MIGRATION_HINT = 'See the 2.0 migration notes: https://github.com/pukeko-robotics/gaunt-sloth/blob/main/docs/MIGRATION.md';
|
|
973
|
+
/**
|
|
974
|
+
* CFG-26 — scan one `builtInTools` value (root or per-command) for the retired
|
|
975
|
+
* `run_shell_command` approval knobs, pushing one issue per occurrence. Only the OBJECT registry
|
|
976
|
+
* form can carry them (the legacy `string[]` form is names-only), so an array/non-object value is
|
|
977
|
+
* a no-op. `pathPrefix` is `builtInTools` at the root and `commands.<name>.builtInTools` per
|
|
978
|
+
* command, so the reported path points exactly at the offending key.
|
|
979
|
+
*/
|
|
980
|
+
function collectRetiredShellToolIssues(builtInTools, pathPrefix, issues) {
|
|
981
|
+
if (!builtInTools || typeof builtInTools !== 'object' || Array.isArray(builtInTools))
|
|
982
|
+
return;
|
|
983
|
+
const shellEntry = builtInTools[SHELL_TOOL_REGISTRY_KEY];
|
|
984
|
+
if (!shellEntry || typeof shellEntry !== 'object' || Array.isArray(shellEntry))
|
|
985
|
+
return;
|
|
986
|
+
for (const [retired, replacement] of RETIRED_SHELL_TOOL_PAIRS) {
|
|
987
|
+
if (Object.prototype.hasOwnProperty.call(shellEntry, retired)) {
|
|
988
|
+
issues.push({
|
|
989
|
+
path: `${pathPrefix}.${SHELL_TOOL_REGISTRY_KEY}.${retired}`,
|
|
990
|
+
message: `Config property "${retired}" in ${pathPrefix}.${SHELL_TOOL_REGISTRY_KEY} is no longer ` +
|
|
991
|
+
`supported in 2.0. Use ${replacement} instead. ${MIGRATION_HINT}`,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* CFG-27 — scan one `approvals` value (root or per-command) for the keys and `mode` values the
|
|
998
|
+
* five-rung ladder retired, pushing one issue per occurrence. The scalar form carries no keys, so
|
|
999
|
+
* only the object form can offend — except for a retired `mode` VALUE, which the scalar form can
|
|
1000
|
+
* carry directly (`"approvals": "auto"`), so both shapes are checked for that.
|
|
1001
|
+
*
|
|
1002
|
+
* `pathPrefix` is `approvals` at the root and `commands.<name>.approvals` per command, so the
|
|
1003
|
+
* reported path points exactly at the offending key.
|
|
1004
|
+
*/
|
|
1005
|
+
function collectRetiredApprovalsIssues(approvals, pathPrefix, issues) {
|
|
1006
|
+
if (approvals === undefined || approvals === null)
|
|
1007
|
+
return;
|
|
1008
|
+
// The scalar sugar form: `"approvals": "auto"`. Only a retired VALUE is an issue here; an
|
|
1009
|
+
// unknown string is left to the enum, which lists the five valid rungs.
|
|
1010
|
+
if (typeof approvals === 'string') {
|
|
1011
|
+
for (const [retired, replacement] of RETIRED_APPROVAL_MODES) {
|
|
1012
|
+
if (approvals === retired) {
|
|
1013
|
+
issues.push({
|
|
1014
|
+
path: pathPrefix,
|
|
1015
|
+
message: `Approval mode "${retired}" is no longer supported: approvals is now one ordered ` +
|
|
1016
|
+
`ladder of five rungs (read-only, write, auto-safe, full-auto, bypass). ` +
|
|
1017
|
+
`Use ${replacement} instead. ${MIGRATION_HINT}`,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (typeof approvals !== 'object' || Array.isArray(approvals))
|
|
1024
|
+
return;
|
|
1025
|
+
const block = approvals;
|
|
1026
|
+
for (const [retired, replacement] of RETIRED_APPROVALS_KEYS) {
|
|
1027
|
+
if (Object.prototype.hasOwnProperty.call(block, retired)) {
|
|
1028
|
+
issues.push({
|
|
1029
|
+
path: `${pathPrefix}.${retired}`,
|
|
1030
|
+
message: `Config property "${retired}" in ${pathPrefix} is no longer supported: approvals is now ` +
|
|
1031
|
+
`one ordered ladder of five rungs (read-only, write, auto-safe, full-auto, bypass), ` +
|
|
1032
|
+
`and each rung fully determines behaviour. Use ${replacement}. ${MIGRATION_HINT}`,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// EXT-71 — `escalate` reused for the third rule list; only the retired THRESHOLD shape errors.
|
|
1037
|
+
if (block.escalate !== undefined && !Array.isArray(block.escalate)) {
|
|
1038
|
+
issues.push({
|
|
1039
|
+
path: `${pathPrefix}.escalate`,
|
|
1040
|
+
message: `Config property "escalate" in ${pathPrefix} ${RETIRED_ESCALATE_THRESHOLD_MESSAGE} ` +
|
|
1041
|
+
MIGRATION_HINT,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
// `rater` flattened from an object to a bare identity-profile name.
|
|
1045
|
+
const rater = block.rater;
|
|
1046
|
+
if (rater !== undefined && typeof rater !== 'string') {
|
|
1047
|
+
issues.push({
|
|
1048
|
+
path: `${pathPrefix}.rater`,
|
|
1049
|
+
message: `Config property "rater" in ${pathPrefix} is now a bare identity-profile name, not an ` +
|
|
1050
|
+
`object or a boolean (e.g. "rater": "safety-rater"). Whether the rater runs at all is ` +
|
|
1051
|
+
`decided by the rung: "auto-safe" and "full-auto" rate, the other three never do. ` +
|
|
1052
|
+
MIGRATION_HINT,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
for (const [retired, replacement] of RETIRED_APPROVAL_MODES) {
|
|
1056
|
+
if (block.mode === retired) {
|
|
1057
|
+
issues.push({
|
|
1058
|
+
path: `${pathPrefix}.mode`,
|
|
1059
|
+
message: `Approval mode "${retired}" is no longer supported: approvals is now one ordered ladder ` +
|
|
1060
|
+
`of five rungs (read-only, write, auto-safe, full-auto, bypass). Use ${replacement} ` +
|
|
1061
|
+
`instead. ${MIGRATION_HINT}`,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
401
1066
|
/**
|
|
402
1067
|
* GS2-28 — detect the removed pre-2.0 config shapes on the RAW input (read-only; no
|
|
403
1068
|
* mutation), returning one {@link DeprecatedConfigIssue} per occurrence. A non-empty
|
|
@@ -409,7 +1074,13 @@ const MIGRATION_HINT = 'See the 2.0 migration notes (or run `gth config migrate`
|
|
|
409
1074
|
* - (C) a deprecated `*Provider*` name ({@link DEPRECATED_ROOT_PAIRS} at root,
|
|
410
1075
|
* {@link DEPRECATED_COMMAND_PAIRS} per command) — must use its `*Source*` replacement;
|
|
411
1076
|
* - (D, CFG-18) a removed per-command key folded into another ({@link REMOVED_COMMAND_KEYS}, e.g.
|
|
412
|
-
* `commands.<cmd>.devTools` → configure under `builtInTools`)
|
|
1077
|
+
* `commands.<cmd>.devTools` → configure under `builtInTools`);
|
|
1078
|
+
* - (E, CFG-26) a retired `run_shell_command` approval knob ({@link RETIRED_SHELL_TOOL_PAIRS}) at
|
|
1079
|
+
* EITHER `builtInTools.run_shell_command.*` or `commands.<cmd>.builtInTools.run_shell_command.*`
|
|
1080
|
+
* — each message names the `approvals.*` key that replaced it;
|
|
1081
|
+
* - (F, CFG-27) a retired `approvals` key or `mode` value ({@link RETIRED_APPROVALS_KEYS},
|
|
1082
|
+
* {@link RETIRED_APPROVAL_MODES}) at EITHER `approvals.*` or `commands.<cmd>.approvals.*` —
|
|
1083
|
+
* each message names the rung that replaced it.
|
|
413
1084
|
*
|
|
414
1085
|
* Runs on the raw input specifically so nested `commands.*.contentProvider` is still visible
|
|
415
1086
|
* (zod's per-command `z.object` would strip it before any schema-embedded check could fire).
|
|
@@ -438,6 +1109,10 @@ export function findDeprecatedConfigIssues(raw) {
|
|
|
438
1109
|
});
|
|
439
1110
|
}
|
|
440
1111
|
}
|
|
1112
|
+
// (E, CFG-26) Retired run_shell_command approval knobs at the ROOT builtInTools registry.
|
|
1113
|
+
collectRetiredShellToolIssues(raw.builtInTools, 'builtInTools', issues);
|
|
1114
|
+
// (F, CFG-27) Retired approvals keys / mode values in the ROOT approvals value.
|
|
1115
|
+
collectRetiredApprovalsIssues(raw.approvals, 'approvals', issues);
|
|
441
1116
|
// (C) Deprecated *Provider* names + (CFG-18) removed keys inside each commands.<name> block.
|
|
442
1117
|
const commands = raw.commands;
|
|
443
1118
|
if (commands && typeof commands === 'object' && !Array.isArray(commands)) {
|
|
@@ -462,11 +1137,221 @@ export function findDeprecatedConfigIssues(raw) {
|
|
|
462
1137
|
});
|
|
463
1138
|
}
|
|
464
1139
|
}
|
|
1140
|
+
// (E, CFG-26) retired run_shell_command approval knobs in this command's registry.
|
|
1141
|
+
collectRetiredShellToolIssues(cmd.builtInTools, `commands.${name}.builtInTools`, issues);
|
|
1142
|
+
// (F, CFG-27) retired approvals keys / mode values in this command's approvals value.
|
|
1143
|
+
collectRetiredApprovalsIssues(cmd.approvals, `commands.${name}.approvals`, issues);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return issues;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* EXT-71 §3.1 — the reserved MCP server name. `*` in an entry's `server` field means *every
|
|
1151
|
+
* server*, so a server actually CALLED `*` would make `{ "server": "*" }` ambiguous — it could not
|
|
1152
|
+
* be read as either "every server" or "that one server" without picking, and a rule whose scope
|
|
1153
|
+
* depends on which reading won is worse than no rule. The name is therefore refused at load.
|
|
1154
|
+
*/
|
|
1155
|
+
const RESERVED_MCP_SERVER_NAME = '*';
|
|
1156
|
+
/**
|
|
1157
|
+
* EXT-70 §4.7.5 — the MCP server name that cannot be written about. A server key is
|
|
1158
|
+
* `z.string().min(1)` both under `approvals.mcp.servers` (§9) and on an `mcpTool` entry's `server`
|
|
1159
|
+
* field (§3.1), so a server keyed with the empty string is one no approvals rule and no trust
|
|
1160
|
+
* relationship can ever refer to by name. Its tools resolve to the unattributable-server sentinel:
|
|
1161
|
+
* fail-closed, which is safe, but also silently un-configurable — the user would get a server whose
|
|
1162
|
+
* every call is gated with no way to say anything about it and no error explaining why. Refused at
|
|
1163
|
+
* load, for the same reason as {@link RESERVED_MCP_SERVER_NAME}: a name whose rules cannot be
|
|
1164
|
+
* expressed is worse than a rejected config.
|
|
1165
|
+
*/
|
|
1166
|
+
const UNNAMEABLE_MCP_SERVER_NAME = '';
|
|
1167
|
+
/**
|
|
1168
|
+
* EXT-71 §3.1/§9.1 — validate every entry in one `approvals` value's three rule lists (root or per
|
|
1169
|
+
* command), pushing one issue per problem with a path that points at the exact entry and field.
|
|
1170
|
+
*
|
|
1171
|
+
* **Why the entries are checked HERE rather than left to the schema parse**, given that
|
|
1172
|
+
* `approvalsSchema` already carries {@link approvalEntrySchema}: the `approvals` value is a union
|
|
1173
|
+
* (the §9.1 scalar-or-object sugar), and zod reports a failing union as ONE issue at the union's
|
|
1174
|
+
* own path — `approvals: Invalid input` — with every arm's real diagnosis nested out of reach of
|
|
1175
|
+
* the formatter. That is exactly the wrong message for this grammar, where the whole requirement is
|
|
1176
|
+
* that a rejection names the offending field, key or pattern. Parsing each entry on its own gets
|
|
1177
|
+
* the precise issue back, and because this runs BEFORE the parse the precise message is the only
|
|
1178
|
+
* one the user sees. The schema keeps the entries too, so it stays the authority and the emitted
|
|
1179
|
+
* JSON Schema still describes them.
|
|
1180
|
+
*
|
|
1181
|
+
* A bare string is handled separately from the rest, because its message is the migration
|
|
1182
|
+
* affordance: it renders the entry for the string that was actually found rather than a generic
|
|
1183
|
+
* example, since what the user needs is the line they can paste back over the one they wrote.
|
|
1184
|
+
*/
|
|
1185
|
+
function collectApprovalEntryIssues(approvals, pathPrefix, issues) {
|
|
1186
|
+
if (!approvals || typeof approvals !== 'object' || Array.isArray(approvals))
|
|
1187
|
+
return;
|
|
1188
|
+
const block = approvals;
|
|
1189
|
+
for (const listKey of APPROVAL_LIST_KEYS) {
|
|
1190
|
+
const list = block[listKey];
|
|
1191
|
+
if (list === undefined)
|
|
1192
|
+
continue;
|
|
1193
|
+
// A list written as something other than an array would otherwise fall back to the union's
|
|
1194
|
+
// bland "approvals: Invalid input" — the same message this whole function exists to replace.
|
|
1195
|
+
// `escalate` is exempt: its non-array shape is the retired severity threshold and gets its own
|
|
1196
|
+
// migration message from `collectRetiredApprovalsIssues`.
|
|
1197
|
+
if (!Array.isArray(list)) {
|
|
1198
|
+
if (listKey !== 'escalate') {
|
|
1199
|
+
issues.push({
|
|
1200
|
+
path: `${pathPrefix}.${listKey}`,
|
|
1201
|
+
message: `must be a LIST of rule entries, not ${typeof list === 'object' ? 'an object' : `a ${typeof list}`}. ` +
|
|
1202
|
+
`Write it as an array, e.g. [ ${renderApprovalEntryForString('npm test')} ]. ` +
|
|
1203
|
+
MIGRATION_HINT,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
list.forEach((entry, index) => {
|
|
1209
|
+
const entryPath = `${pathPrefix}.${listKey}[${index}]`;
|
|
1210
|
+
if (typeof entry === 'string') {
|
|
1211
|
+
issues.push({
|
|
1212
|
+
path: entryPath,
|
|
1213
|
+
message: 'bare strings are no longer accepted in an approvals rule list. Write the entry ' +
|
|
1214
|
+
`explicitly: ${renderApprovalEntryForString(entry)} — type, matcher and pattern are ` +
|
|
1215
|
+
`always required, and "matcher" may be exact, glob or regexp. ${MIGRATION_HINT}`,
|
|
1216
|
+
});
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const parsed = approvalEntrySchema.safeParse(entry);
|
|
1220
|
+
if (parsed.success)
|
|
1221
|
+
return;
|
|
1222
|
+
for (const issue of parsed.error.issues) {
|
|
1223
|
+
issues.push({
|
|
1224
|
+
path: issue.path.length > 0 ? `${entryPath}.${issue.path.join('.')}` : entryPath,
|
|
1225
|
+
message: issue.message,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* EXT-70 §4.7/§9 — validate one `approvals.mcp` block, with a path that names the offending field.
|
|
1233
|
+
*
|
|
1234
|
+
* It runs PRE-PARSE for the same reason the rule entries do: `approvalsSchema` is a `z.union`, so a
|
|
1235
|
+
* bad `mcp` block otherwise collapses into the union's bland "approvals: Invalid input" — the
|
|
1236
|
+
* message this whole family of checks exists to replace. Here the user gets the server key, the
|
|
1237
|
+
* field and (for a hint name) the value they mistyped.
|
|
1238
|
+
*/
|
|
1239
|
+
function collectMcpApprovalsIssues(approvals, pathPrefix, issues) {
|
|
1240
|
+
if (!approvals || typeof approvals !== 'object' || Array.isArray(approvals))
|
|
1241
|
+
return;
|
|
1242
|
+
const mcp = approvals.mcp;
|
|
1243
|
+
if (mcp === undefined)
|
|
1244
|
+
return;
|
|
1245
|
+
const parsed = mcpApprovalsSchema.safeParse(mcp);
|
|
1246
|
+
if (parsed.success)
|
|
1247
|
+
return;
|
|
1248
|
+
for (const issue of parsed.error.issues) {
|
|
1249
|
+
issues.push({
|
|
1250
|
+
path: issue.path.length > 0 ? `${pathPrefix}.mcp.${issue.path.join('.')}` : `${pathPrefix}.mcp`,
|
|
1251
|
+
message: issue.message,
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* EXT-71 §3.1 — every hard error the rule grammar defines, found on the RAW input: each entry in
|
|
1257
|
+
* `allow`/`deny`/`escalate` validated with a path that names the offending field
|
|
1258
|
+
* ({@link collectApprovalEntryIssues}), the `approvals.mcp` block ({@link collectMcpApprovalsIssues}),
|
|
1259
|
+
* and the two `mcpServers` keys no rule can refer to — `*` ({@link RESERVED_MCP_SERVER_NAME}, which
|
|
1260
|
+
* an entry already reads as "every server") and the empty name
|
|
1261
|
+
* ({@link UNNAMEABLE_MCP_SERVER_NAME}, which no entry's `server` field can hold). Both need to see
|
|
1262
|
+
* `mcpServers`, a sibling of `approvals` rather than a field of it.
|
|
1263
|
+
*
|
|
1264
|
+
* All of them are HARD errors, reported the same way {@link findDeprecatedConfigIssues} reports
|
|
1265
|
+
* its own, and — like it — this runs BEFORE the schema parse so the precise message is the only
|
|
1266
|
+
* one the user sees.
|
|
1267
|
+
*
|
|
1268
|
+
* PURE: it only reads the object. Kept separate from {@link findDeprecatedConfigIssues} because
|
|
1269
|
+
* these are not removed pre-2.0 shapes; they are rules of the current grammar.
|
|
1270
|
+
*/
|
|
1271
|
+
export function findApprovalsGrammarIssues(raw) {
|
|
1272
|
+
const issues = [];
|
|
1273
|
+
const mcpServers = raw.mcpServers;
|
|
1274
|
+
if (mcpServers && typeof mcpServers === 'object' && !Array.isArray(mcpServers)) {
|
|
1275
|
+
if (Object.prototype.hasOwnProperty.call(mcpServers, RESERVED_MCP_SERVER_NAME)) {
|
|
1276
|
+
issues.push({
|
|
1277
|
+
path: `mcpServers.${RESERVED_MCP_SERVER_NAME}`,
|
|
1278
|
+
message: `"${RESERVED_MCP_SERVER_NAME}" is a reserved MCP server name: an approvals rule entry ` +
|
|
1279
|
+
`uses it to mean EVERY server, so a server of that name would make ` +
|
|
1280
|
+
`{ "type": "mcpTool", "server": "${RESERVED_MCP_SERVER_NAME}", ... } ambiguous. ` +
|
|
1281
|
+
'Rename the server to anything else. ' +
|
|
1282
|
+
MIGRATION_HINT,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
if (Object.prototype.hasOwnProperty.call(mcpServers, UNNAMEABLE_MCP_SERVER_NAME)) {
|
|
1286
|
+
issues.push({
|
|
1287
|
+
path: 'mcpServers.""',
|
|
1288
|
+
message: 'an MCP server may not be keyed with an empty name: both an approvals rule entry ' +
|
|
1289
|
+
'({ "type": "mcpTool", "server": ... }) and a trust relationship under ' +
|
|
1290
|
+
'"approvals.mcp.servers" require a server name of at least one character, so nothing ' +
|
|
1291
|
+
"could ever be written about this server's tools — every call it makes would be gated " +
|
|
1292
|
+
'with no way to say otherwise. Give the server a name. ' +
|
|
1293
|
+
MIGRATION_HINT,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
const collect = (approvals, prefix) => {
|
|
1298
|
+
collectApprovalEntryIssues(approvals, prefix, issues);
|
|
1299
|
+
collectMcpApprovalsIssues(approvals, prefix, issues);
|
|
1300
|
+
};
|
|
1301
|
+
collect(raw.approvals, 'approvals');
|
|
1302
|
+
const commands = raw.commands;
|
|
1303
|
+
if (commands && typeof commands === 'object' && !Array.isArray(commands)) {
|
|
1304
|
+
for (const [name, cmd] of Object.entries(commands)) {
|
|
1305
|
+
if (cmd && typeof cmd === 'object' && !Array.isArray(cmd)) {
|
|
1306
|
+
collect(cmd.approvals, `commands.${name}.approvals`);
|
|
465
1307
|
}
|
|
466
1308
|
}
|
|
467
1309
|
}
|
|
468
1310
|
return issues;
|
|
469
1311
|
}
|
|
1312
|
+
/**
|
|
1313
|
+
* CFG-26 — collect every `approvals.rater` in a raw config (root + each `commands.<name>`).
|
|
1314
|
+
* CFG-27 flattened the key: it is a BARE identity-profile name, not `rater.profile`.
|
|
1315
|
+
*
|
|
1316
|
+
* PURE — it only reads the object; the caller decides whether each name RESOLVES. That split is
|
|
1317
|
+
* deliberate: profile resolution needs the filesystem, and `schema.ts` must stay pure so
|
|
1318
|
+
* `z.toJSONSchema` and every spec that validates a config object stay cwd-independent. The loader
|
|
1319
|
+
* pairs this with `resolveIdentityProfileConfigPath` to enforce the GS2-62 rule that an
|
|
1320
|
+
* unresolvable profile is a hard error, never a silent fallback to the main model.
|
|
1321
|
+
*/
|
|
1322
|
+
export function findApprovalsRaterProfiles(raw) {
|
|
1323
|
+
const refs = [];
|
|
1324
|
+
const collect = (approvals, prefix) => {
|
|
1325
|
+
// The scalar sugar form (`"approvals": "auto-safe"`) carries no rater.
|
|
1326
|
+
if (!approvals || typeof approvals !== 'object' || Array.isArray(approvals))
|
|
1327
|
+
return;
|
|
1328
|
+
const rater = approvals.rater;
|
|
1329
|
+
if (typeof rater === 'string' && rater.trim().length > 0) {
|
|
1330
|
+
refs.push({ path: `${prefix}.rater`, profile: rater.trim() });
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
collect(raw.approvals, 'approvals');
|
|
1334
|
+
const commands = raw.commands;
|
|
1335
|
+
if (commands && typeof commands === 'object' && !Array.isArray(commands)) {
|
|
1336
|
+
for (const [name, cmd] of Object.entries(commands)) {
|
|
1337
|
+
if (cmd && typeof cmd === 'object' && !Array.isArray(cmd)) {
|
|
1338
|
+
collect(cmd.approvals, `commands.${name}.approvals`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return refs;
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* CFG-26 — the ONE message for an `approvals.rater.profile` that does not resolve. Shared by the
|
|
1346
|
+
* loader (which hard-exits a real run) and {@link validateRawGthConfig} (which backs
|
|
1347
|
+
* `gth config validate`), so the validator can never green-light a config the runtime refuses.
|
|
1348
|
+
*/
|
|
1349
|
+
export function unresolvedRaterProfileMessage(ref) {
|
|
1350
|
+
return (`identity profile "${ref.profile}" not found ` +
|
|
1351
|
+
`(checked ${GSLOTH_DIR}/${GSLOTH_SETTINGS_DIR}/${ref.profile}/). ` +
|
|
1352
|
+
'Create it with `gth config profile create`, or omit approvals.rater to rate ' +
|
|
1353
|
+
'with the main model.');
|
|
1354
|
+
}
|
|
470
1355
|
/**
|
|
471
1356
|
* Render {@link DeprecatedConfigIssue}s as the same ` - <path>: <message>` block used for
|
|
472
1357
|
* Zod validation errors, so a deprecated-shape rejection reads identically to a type-mismatch
|
|
@@ -493,7 +1378,7 @@ export function generateConfigJsonSchema() {
|
|
|
493
1378
|
* Read-only: `findDeprecatedConfigIssues`, `findUnknownTopLevelKeys` and `safeParse` never
|
|
494
1379
|
* mutate `raw`, so no defensive copy is needed.
|
|
495
1380
|
*/
|
|
496
|
-
export function validateRawGthConfig(raw) {
|
|
1381
|
+
export function validateRawGthConfig(raw, options) {
|
|
497
1382
|
const warnings = [];
|
|
498
1383
|
// Only an object config can carry deprecated/unknown keys. A null/array/primitive config is
|
|
499
1384
|
// handed straight to safeParse (below), which returns a clean "expected object" failure — the
|
|
@@ -509,6 +1394,16 @@ export function validateRawGthConfig(raw) {
|
|
|
509
1394
|
errorMessage: formatDeprecatedConfigIssues(deprecatedIssues),
|
|
510
1395
|
};
|
|
511
1396
|
}
|
|
1397
|
+
// EXT-71 — the rule-grammar errors that need to be seen before the schema parse, so the
|
|
1398
|
+
// message that explains the fix is the only one the user reads.
|
|
1399
|
+
const grammarIssues = findApprovalsGrammarIssues(raw);
|
|
1400
|
+
if (grammarIssues.length > 0) {
|
|
1401
|
+
return {
|
|
1402
|
+
ok: false,
|
|
1403
|
+
warnings: [],
|
|
1404
|
+
errorMessage: formatDeprecatedConfigIssues(grammarIssues),
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
512
1407
|
const unknownKeys = findUnknownTopLevelKeys(raw);
|
|
513
1408
|
if (unknownKeys.length > 0) {
|
|
514
1409
|
warnings.push(`Unknown top-level config ${unknownKeys.length === 1 ? 'key' : 'keys'}: ` +
|
|
@@ -519,6 +1414,22 @@ export function validateRawGthConfig(raw) {
|
|
|
519
1414
|
if (!result.success) {
|
|
520
1415
|
return { ok: false, warnings, errorMessage: formatConfigValidationError(result.error) };
|
|
521
1416
|
}
|
|
1417
|
+
// CFG-26 — `approvals.rater.profile` strict resolution, when the caller supplies a resolver.
|
|
1418
|
+
// The predicate is INJECTED rather than imported so this module stays pure (it feeds
|
|
1419
|
+
// `z.toJSONSchema`, and every spec that validates a config object must stay cwd-independent),
|
|
1420
|
+
// while `gth config validate` still agrees with the loader instead of green-lighting a config
|
|
1421
|
+
// the next real run hard-exits on. Without a resolver the check is skipped, preserving the
|
|
1422
|
+
// in-memory callers (e.g. the profile scaffolder).
|
|
1423
|
+
if (options?.resolveProfile && isRecordConfig(raw)) {
|
|
1424
|
+
const unresolved = findApprovalsRaterProfiles(raw).filter((ref) => !options.resolveProfile(ref.profile));
|
|
1425
|
+
if (unresolved.length > 0) {
|
|
1426
|
+
return {
|
|
1427
|
+
ok: false,
|
|
1428
|
+
warnings,
|
|
1429
|
+
errorMessage: formatIssueLines(unresolved.map((ref) => ({ path: ref.path, message: unresolvedRaterProfileMessage(ref) }))),
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
522
1433
|
return { ok: true, warnings };
|
|
523
1434
|
}
|
|
524
1435
|
//# sourceMappingURL=schema.js.map
|