@levr-one/cli 0.12.8 → 0.12.9
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/dist/{addHandler-ybX54_3f.js → addHandler-CD1pda0z.js} +7 -7
- package/dist/{appendHandler-CKkl9ymw.js → appendHandler-BYOoCNXJ.js} +9 -9
- package/dist/{artifact-upload-Ba801tb6.js → artifact-upload-t6kL20b5.js} +2 -2
- package/dist/{attachHandler-CfNvVAV2.js → attachHandler-Ojk7mc2E.js} +9 -9
- package/dist/bindingsHandler-Dxx2LIci.js +2781 -0
- package/dist/change-scope-DBmyRWZV.js +379 -0
- package/dist/changedCoverageHandler-B8Zh5WIK.js +200 -0
- package/dist/cli.js +300 -15
- package/dist/{connect-yq3fGXkW.js → connect-A5VSJUv5.js} +3 -3
- package/dist/coverage-defaults-Ct3KNnGl.js +24 -0
- package/dist/{currentHandler-PaLZKLGF.js → currentHandler-DmffKHyZ.js} +1 -1
- package/dist/dependencyAuditHandler-CdpW-IvY.js +291 -0
- package/dist/dist-DU3htaZ_.js +325 -0
- package/dist/{find-workspace-4Us2vEb7.js → find-workspace-IWolSyrf.js} +1 -1
- package/dist/gate-op-BINrIT2l.js +278 -0
- package/dist/{importHandler-bYBWYC_0.js → importHandler-86cjc2Zs.js} +6 -6
- package/dist/inspect-CAxNgTwj.js +4 -0
- package/dist/{inspect-BbL7kDe-.js → inspect-CinRBSv6.js} +2 -277
- package/dist/{listHandler-Du_YGzre.js → listHandler-BoCzGUDK.js} +5 -5
- package/dist/{loginHandler-PbKmDAGo.js → loginHandler-CXmNViCo.js} +6 -6
- package/dist/{logoutHandler-DxnzGPYx.js → logoutHandler-DwTURB_A.js} +2 -2
- package/dist/{pushHandler-BulraEwP.js → pushHandler-BkCmzQz7.js} +9 -330
- package/dist/{resolve-token-DKEXT7SA.js → resolve-token-D1zteEfm.js} +2 -2
- package/dist/{resolve-workspace-D8y7twod.js → resolve-workspace-CJN1plpd.js} +2 -2
- package/dist/{runHandler-B-u-0eRk.js → runHandler-BLkPzImG.js} +8 -7
- package/dist/{sdk-client-BgzKzVIR.js → sdk-client-CSy2LOEh.js} +1 -1
- package/dist/{selectHandler-C0gs-Q70.js → selectHandler-Dp74pefc.js} +5 -5
- package/dist/{statusHandler-Dnnh80ZN.js → statusHandler-ByTUMdKK.js} +3 -3
- package/dist/{token-refresh-BpFuU4Ij.js → token-refresh-DEmYbJ8V.js} +1 -1
- package/package.json +1 -1
- package/dist/inspect-EGW-cgVD.js +0 -3
- /package/dist/{env-DjeRwmZ7.js → env-Cv-anxJA.js} +0 -0
- /package/dist/{refsHandler--ySvLjmX.js → refsHandler-BPlcdJvR.js} +0 -0
- /package/dist/{sleep-CGBHjsj5.js → sleep-DNLj0FMk.js} +0 -0
- /package/dist/{workspace-store-Bmw104Qa.js → workspace-store-BJyB_WEG.js} +0 -0
|
@@ -0,0 +1,2781 @@
|
|
|
1
|
+
import "./gate-op-BINrIT2l.js";
|
|
2
|
+
import { client, configureClient } from "./sdk-client-CSy2LOEh.js";
|
|
3
|
+
import "./token-refresh-DEmYbJ8V.js";
|
|
4
|
+
import "./env-Cv-anxJA.js";
|
|
5
|
+
import { resolveToken } from "./resolve-token-D1zteEfm.js";
|
|
6
|
+
import "./workspace-store-BJyB_WEG.js";
|
|
7
|
+
import { resolveWorkspaceFor } from "./resolve-workspace-CJN1plpd.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path$1 from "node:path";
|
|
11
|
+
|
|
12
|
+
//#region ../sdk-public/dist/gen/gate-binding-transfer/functions.js
|
|
13
|
+
/** GET /v1/gate/bindings/export */
|
|
14
|
+
const gateBindingTransferExportConfigV1 = (options) => {
|
|
15
|
+
return (options?.client ?? client).get({
|
|
16
|
+
security: [{
|
|
17
|
+
scheme: "bearer",
|
|
18
|
+
type: "http"
|
|
19
|
+
}],
|
|
20
|
+
url: "/v1/gate/bindings/export",
|
|
21
|
+
...options
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
/** POST /v1/gate/bindings/import */
|
|
25
|
+
const gateBindingTransferImportConfigV1 = (options) => {
|
|
26
|
+
return (options?.client ?? client).post({
|
|
27
|
+
security: [{
|
|
28
|
+
scheme: "bearer",
|
|
29
|
+
type: "http"
|
|
30
|
+
}],
|
|
31
|
+
url: "/v1/gate/bindings/import",
|
|
32
|
+
...options
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region ../shared/dist/constants/related-type.constants.js
|
|
38
|
+
/**
|
|
39
|
+
* Related type constants for polymorphic entities (attachments, label assignments, etc.)
|
|
40
|
+
*
|
|
41
|
+
* These are the normalized short-form related_type values used in the database.
|
|
42
|
+
* The old Levr PHP format ('App\\Models\\Test') has been replaced with simple strings ('test').
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Map of entity names to their related_type database values.
|
|
46
|
+
* Use RELATED_TYPE_MAP.Test instead of hardcoding 'test'.
|
|
47
|
+
*/
|
|
48
|
+
const RELATED_TYPE_MAP = {
|
|
49
|
+
Test: "test",
|
|
50
|
+
Step: "step",
|
|
51
|
+
Folder: "folder",
|
|
52
|
+
Plan: "plan",
|
|
53
|
+
Defect: "defect",
|
|
54
|
+
Requirement: "requirement",
|
|
55
|
+
RunResult: "run_result",
|
|
56
|
+
RunResultStep: "run_result_step",
|
|
57
|
+
ImportJob: "import_job",
|
|
58
|
+
Run: "run",
|
|
59
|
+
Issue: "issue",
|
|
60
|
+
Project: "project",
|
|
61
|
+
Initiative: "initiative",
|
|
62
|
+
Cycle: "cycle",
|
|
63
|
+
Staged: "staged",
|
|
64
|
+
DataSet: "data_set",
|
|
65
|
+
RunResultVariant: "run_result_variant",
|
|
66
|
+
ResearchStep: "research_step",
|
|
67
|
+
ProjectUpdate: "project_update",
|
|
68
|
+
AutomationRunResult: "automation_run_result",
|
|
69
|
+
Unlinked: "unlinked"
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Array of allowed related type values (for validation/enum)
|
|
73
|
+
*/
|
|
74
|
+
const RELATED_TYPES = Object.values(RELATED_TYPE_MAP);
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region ../shared/dist/constants/file-upload.constants.js
|
|
78
|
+
/** Maximum file size in bytes (50 MB) */
|
|
79
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
80
|
+
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region ../shared/dist/constants/provider-type.constants.js
|
|
83
|
+
/**
|
|
84
|
+
* LLM provider type enum + display-name map.
|
|
85
|
+
*
|
|
86
|
+
* Single source of truth shared between `apps/backender` (provider/profile
|
|
87
|
+
* services, seeds) and `apps/client` (profile form dropdowns, synced-profile
|
|
88
|
+
* provider-name display). The backender re-exports these from
|
|
89
|
+
* `provider-type.enum.ts` for backward compatibility; the client imports them
|
|
90
|
+
* from `@levr/shared` directly.
|
|
91
|
+
*
|
|
92
|
+
* NOTE: `ProviderTypeApiBaseUrl` is intentionally NOT moved here — it is
|
|
93
|
+
* backend-only (no client needs default provider base URLs) and stays local to
|
|
94
|
+
* `apps/backender/src/provider/provider-type.enum.ts`.
|
|
95
|
+
*/
|
|
96
|
+
var ProviderType;
|
|
97
|
+
(function(ProviderType$1) {
|
|
98
|
+
ProviderType$1[ProviderType$1["OpenAI"] = 1] = "OpenAI";
|
|
99
|
+
ProviderType$1[ProviderType$1["Anthropic"] = 2] = "Anthropic";
|
|
100
|
+
ProviderType$1[ProviderType$1["Groq"] = 3] = "Groq";
|
|
101
|
+
ProviderType$1[ProviderType$1["Gemini"] = 4] = "Gemini";
|
|
102
|
+
ProviderType$1[ProviderType$1["FireworksAI"] = 5] = "FireworksAI";
|
|
103
|
+
ProviderType$1[ProviderType$1["MistralAI"] = 6] = "MistralAI";
|
|
104
|
+
ProviderType$1[ProviderType$1["OpenRouter"] = 7] = "OpenRouter";
|
|
105
|
+
})(ProviderType || (ProviderType = {}));
|
|
106
|
+
const ProviderTypeNames = {
|
|
107
|
+
[ProviderType.OpenAI]: "OpenAI",
|
|
108
|
+
[ProviderType.Anthropic]: "Anthropic",
|
|
109
|
+
[ProviderType.Groq]: "Groq",
|
|
110
|
+
[ProviderType.Gemini]: "Gemini",
|
|
111
|
+
[ProviderType.FireworksAI]: "FireworksAI",
|
|
112
|
+
[ProviderType.MistralAI]: "MistralAI",
|
|
113
|
+
[ProviderType.OpenRouter]: "OpenRouter"
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region ../shared/dist/constants/browser-capture-kinds.js
|
|
118
|
+
/**
|
|
119
|
+
* Levr Browser capture kinds (ENG-7189 D1): the one place that says what each
|
|
120
|
+
* kind of browser capture is — its stored and wire types, file name, size
|
|
121
|
+
* cap, label and viewer. The router's reserve/finalise, the MCP "not saved"
|
|
122
|
+
* note, the gateway attach binding and the web client's Captures list all
|
|
123
|
+
* read this registry; none of them branches on a tool name.
|
|
124
|
+
*
|
|
125
|
+
* A capture tool names its kind on its `@levr/browser-protocol` spec
|
|
126
|
+
* (`captureKind`, a plain string: the extension never imports this package).
|
|
127
|
+
* Backender's `browser-captures.spec.ts` checks both sides list the same
|
|
128
|
+
* tools.
|
|
129
|
+
*
|
|
130
|
+
* Pure data, browser-safe, no imports.
|
|
131
|
+
*/
|
|
132
|
+
const MIB = 1024 * 1024;
|
|
133
|
+
const OCTET_STREAM = "application/octet-stream";
|
|
134
|
+
/**
|
|
135
|
+
* The slowest uplink a full-size capture upload is given time for: about
|
|
136
|
+
* 1.4 Mbit/s, so a 50 MiB recording gets 300 s (ENG-7189 D5, R1-C6).
|
|
137
|
+
*/
|
|
138
|
+
const CAPTURE_MIN_UPLINK_BITS_PER_SECOND = 14e5;
|
|
139
|
+
/** How long `maxBytes` takes at the minimum uplink, in whole seconds (ms). */
|
|
140
|
+
function uploadTimeoutFor(maxBytes) {
|
|
141
|
+
return Math.ceil(maxBytes * 8 / CAPTURE_MIN_UPLINK_BITS_PER_SECOND) * 1e3;
|
|
142
|
+
}
|
|
143
|
+
const CAPTURE_KINDS = Object.freeze({
|
|
144
|
+
screenshot: {
|
|
145
|
+
id: "screenshot",
|
|
146
|
+
labelKey: "browserCaptures.kindScreenshot",
|
|
147
|
+
label: "Screenshot",
|
|
148
|
+
noun: "screenshot",
|
|
149
|
+
tools: ["browser_screenshot", "browser_screenshot_focused_tab"],
|
|
150
|
+
mime: "image/png",
|
|
151
|
+
uploadMime: "image/png",
|
|
152
|
+
ext: "png",
|
|
153
|
+
filenamePrefix: "screenshot",
|
|
154
|
+
maxBytes: 50 * MIB,
|
|
155
|
+
viewer: "image",
|
|
156
|
+
formats: { jpeg: {
|
|
157
|
+
mime: "image/jpeg",
|
|
158
|
+
uploadMime: "image/jpeg",
|
|
159
|
+
ext: "jpg"
|
|
160
|
+
} },
|
|
161
|
+
toolLabels: { browser_screenshot_focused_tab: {
|
|
162
|
+
labelKey: "browserCaptures.kindFocusedTab",
|
|
163
|
+
label: "Screenshot of the focused tab"
|
|
164
|
+
} }
|
|
165
|
+
},
|
|
166
|
+
trace: {
|
|
167
|
+
id: "trace",
|
|
168
|
+
labelKey: "browserCaptures.kindTrace",
|
|
169
|
+
label: "Performance trace",
|
|
170
|
+
noun: "trace",
|
|
171
|
+
tools: ["browser_stop_performance_trace"],
|
|
172
|
+
mime: "application/json",
|
|
173
|
+
uploadMime: OCTET_STREAM,
|
|
174
|
+
ext: "json",
|
|
175
|
+
filenamePrefix: "trace",
|
|
176
|
+
maxBytes: 50 * MIB,
|
|
177
|
+
uploadTimeoutMs: uploadTimeoutFor(50 * MIB),
|
|
178
|
+
viewer: "trace"
|
|
179
|
+
},
|
|
180
|
+
console: {
|
|
181
|
+
id: "console",
|
|
182
|
+
labelKey: "browserCaptures.kindConsole",
|
|
183
|
+
label: "Console log",
|
|
184
|
+
noun: "console log",
|
|
185
|
+
tools: ["browser_save_console_logs"],
|
|
186
|
+
mime: "application/json",
|
|
187
|
+
uploadMime: OCTET_STREAM,
|
|
188
|
+
ext: "json",
|
|
189
|
+
filenamePrefix: "console",
|
|
190
|
+
maxBytes: 5 * MIB,
|
|
191
|
+
viewer: "console"
|
|
192
|
+
},
|
|
193
|
+
har: {
|
|
194
|
+
id: "har",
|
|
195
|
+
labelKey: "browserCaptures.kindHar",
|
|
196
|
+
label: "Network log",
|
|
197
|
+
noun: "network log",
|
|
198
|
+
tools: ["browser_save_har"],
|
|
199
|
+
mime: "application/json",
|
|
200
|
+
uploadMime: OCTET_STREAM,
|
|
201
|
+
ext: "har",
|
|
202
|
+
filenamePrefix: "network",
|
|
203
|
+
maxBytes: 25 * MIB,
|
|
204
|
+
uploadTimeoutMs: uploadTimeoutFor(25 * MIB),
|
|
205
|
+
viewer: "har"
|
|
206
|
+
},
|
|
207
|
+
screencast: {
|
|
208
|
+
id: "screencast",
|
|
209
|
+
labelKey: "browserCaptures.kindRecording",
|
|
210
|
+
label: "Recording",
|
|
211
|
+
noun: "recording",
|
|
212
|
+
tools: ["browser_stop_recording"],
|
|
213
|
+
mime: "video/webm",
|
|
214
|
+
uploadMime: "video/webm",
|
|
215
|
+
ext: "webm",
|
|
216
|
+
filenamePrefix: "recording",
|
|
217
|
+
maxBytes: 50 * MIB,
|
|
218
|
+
uploadTimeoutMs: uploadTimeoutFor(50 * MIB),
|
|
219
|
+
viewer: "video"
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
/** Every tool of every kind. */
|
|
223
|
+
const CAPTURE_KIND_TOOLS = Object.freeze(Object.values(CAPTURE_KINDS).flatMap((kind) => kind.tools));
|
|
224
|
+
/** Every wire Content-Type a capture upload may carry. */
|
|
225
|
+
const CAPTURE_UPLOAD_MIMES = Object.freeze([...new Set(Object.values(CAPTURE_KINDS).flatMap((kind) => [kind.uploadMime, ...Object.values(kind.formats ?? {}).map((f) => f.uploadMime)]))]);
|
|
226
|
+
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region ../shared/dist/constants/first-task-nudge.constants.js
|
|
229
|
+
/**
|
|
230
|
+
* The day-after first-task nudge email's opt-out (ENG-6396, plan ENG-6341
|
|
231
|
+
* D13 — user decision 2026-10-12).
|
|
232
|
+
*
|
|
233
|
+
* The nudge is a one-time getting-started email, not a notification: it is
|
|
234
|
+
* sent by DEFAULT and is independent of `notification_preference` (whose
|
|
235
|
+
* email channel stays opt-in). A person stops it with
|
|
236
|
+
* `user_preference.first_task_nudge_email = false`, from Settings >
|
|
237
|
+
* Notifications or the one-click link in the email itself. The server job
|
|
238
|
+
* and the web client read the column through `wantsFirstTaskNudge`, and the
|
|
239
|
+
* email's link is built from `FIRST_TASK_NUDGE_OPT_OUT_PATH`, which the
|
|
240
|
+
* settings page recognises.
|
|
241
|
+
*/
|
|
242
|
+
/** The settings section that holds the toggle. */
|
|
243
|
+
const FIRST_TASK_NUDGE_SETTINGS_SECTION = "notifications";
|
|
244
|
+
/** Query parameter the email's opt-out link carries. */
|
|
245
|
+
const FIRST_TASK_NUDGE_OPT_OUT_PARAM = "opt-out";
|
|
246
|
+
/** The value of `FIRST_TASK_NUDGE_OPT_OUT_PARAM` that turns the nudge off. */
|
|
247
|
+
const FIRST_TASK_NUDGE_OPT_OUT_VALUE = "first-task-email";
|
|
248
|
+
/**
|
|
249
|
+
* Workspace-relative path of the email's one-click opt-out link (append it to
|
|
250
|
+
* `<client>/w/<url_key>`). Opening it turns the nudge off for the signed-in
|
|
251
|
+
* person and shows the setting, where it can be turned back on.
|
|
252
|
+
*/
|
|
253
|
+
const FIRST_TASK_NUDGE_OPT_OUT_PATH = `/settings/${FIRST_TASK_NUDGE_SETTINGS_SECTION}?${FIRST_TASK_NUDGE_OPT_OUT_PARAM}=${FIRST_TASK_NUDGE_OPT_OUT_VALUE}`;
|
|
254
|
+
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region ../shared/dist/estimates/estimate-scale.js
|
|
257
|
+
/**
|
|
258
|
+
* T-shirt stored integer → label. 0 is the allow-zero tier.
|
|
259
|
+
*
|
|
260
|
+
* The gaps (no 4, 6, 7, …) are the point: these are Fibonacci values, not
|
|
261
|
+
* positions. `intToLabel` falls back to the raw number for anything absent, so
|
|
262
|
+
* a legacy ordinal that escaped the migration renders as "4" rather than
|
|
263
|
+
* silently masquerading as a size.
|
|
264
|
+
*/
|
|
265
|
+
const TSHIRT_LABEL_BY_INT = {
|
|
266
|
+
0: "-",
|
|
267
|
+
1: "XS",
|
|
268
|
+
2: "S",
|
|
269
|
+
3: "M",
|
|
270
|
+
5: "L",
|
|
271
|
+
8: "XL",
|
|
272
|
+
13: "XXL",
|
|
273
|
+
21: "XXXL"
|
|
274
|
+
};
|
|
275
|
+
const TSHIRT_INT_BY_LABEL = Object.fromEntries(Object.entries(TSHIRT_LABEL_BY_INT).map(([int, label]) => [label, Number(int)]));
|
|
276
|
+
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region ../shared/dist/cycles/status-fixtures.js
|
|
279
|
+
/** Offset applied to fixture numbers so they cannot collide with real cycles. */
|
|
280
|
+
const ORDERING_NUMBER_BASE = 8100;
|
|
281
|
+
const ORDERING_FIXTURES = [
|
|
282
|
+
{
|
|
283
|
+
key: "renumbered",
|
|
284
|
+
start_date: "2026-05-01",
|
|
285
|
+
end_date: "2026-05-14",
|
|
286
|
+
number: ORDERING_NUMBER_BASE + 5,
|
|
287
|
+
because: "oldest start_date but the HIGHEST number — a back-dated create, a reassigned number, or an import. `number` ordering wrongly puts it first"
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
key: "oldest",
|
|
291
|
+
start_date: "2026-06-01",
|
|
292
|
+
end_date: "2026-06-14",
|
|
293
|
+
number: ORDERING_NUMBER_BASE + 1,
|
|
294
|
+
because: "falls outside a window of 3 under the canonical ordering"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
key: "mid",
|
|
298
|
+
start_date: "2026-06-15",
|
|
299
|
+
end_date: "2026-06-28",
|
|
300
|
+
number: ORDERING_NUMBER_BASE + 2,
|
|
301
|
+
because: "the third and last member of the canonical window"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
key: "tied-early",
|
|
305
|
+
start_date: "2026-07-01",
|
|
306
|
+
end_date: "2026-07-14",
|
|
307
|
+
number: ORDERING_NUMBER_BASE + 3,
|
|
308
|
+
because: "shares a start_date with `tied-late`; the LOWER number, so it sorts second of the pair under DESC"
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
key: "tied-late",
|
|
312
|
+
start_date: "2026-07-01",
|
|
313
|
+
end_date: "2026-07-14",
|
|
314
|
+
number: ORDERING_NUMBER_BASE + 4,
|
|
315
|
+
because: "shares a start_date with `tied-early`; the HIGHER number, so the tiebreaker must place it first"
|
|
316
|
+
}
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region ../shared/dist/runs/gate-command-pattern.js
|
|
321
|
+
/** Package managers, fetchers, interpreters, shells, and wrappers. */
|
|
322
|
+
const PATTERN_WRAPPER_PROGRAMS = [
|
|
323
|
+
"yarn",
|
|
324
|
+
"npm",
|
|
325
|
+
"pnpm",
|
|
326
|
+
"bun",
|
|
327
|
+
"npx",
|
|
328
|
+
"node",
|
|
329
|
+
"deno",
|
|
330
|
+
"tsx",
|
|
331
|
+
"ts-node",
|
|
332
|
+
"python",
|
|
333
|
+
"python3",
|
|
334
|
+
"ruby",
|
|
335
|
+
"perl",
|
|
336
|
+
"sh",
|
|
337
|
+
"bash",
|
|
338
|
+
"zsh",
|
|
339
|
+
"pwsh",
|
|
340
|
+
"env",
|
|
341
|
+
"sudo",
|
|
342
|
+
"timeout",
|
|
343
|
+
"nice",
|
|
344
|
+
"xargs"
|
|
345
|
+
];
|
|
346
|
+
/**
|
|
347
|
+
* Sub-fetchers and inline-code flags. A wrapper whose first fixed positional
|
|
348
|
+
* is one of these, or another wrapper, is too broad unless a later fixed
|
|
349
|
+
* positional is a real argument. Inline-code flags refuse even then.
|
|
350
|
+
*/
|
|
351
|
+
const PATTERN_SUB_FETCHERS = [
|
|
352
|
+
"dlx",
|
|
353
|
+
"exec",
|
|
354
|
+
"x",
|
|
355
|
+
"node",
|
|
356
|
+
"run-script",
|
|
357
|
+
"eval"
|
|
358
|
+
];
|
|
359
|
+
const PATTERN_INLINE_CODE_FLAGS = [
|
|
360
|
+
"-e",
|
|
361
|
+
"-c",
|
|
362
|
+
"-p",
|
|
363
|
+
"--eval",
|
|
364
|
+
"--print",
|
|
365
|
+
"--input-type"
|
|
366
|
+
];
|
|
367
|
+
/** git subcommands that do not modify the repository. */
|
|
368
|
+
const GIT_READ_ONLY_SUBCOMMANDS = [
|
|
369
|
+
"status",
|
|
370
|
+
"diff",
|
|
371
|
+
"log",
|
|
372
|
+
"show",
|
|
373
|
+
"rev-parse",
|
|
374
|
+
"ls-files",
|
|
375
|
+
"blame",
|
|
376
|
+
"grep",
|
|
377
|
+
"describe",
|
|
378
|
+
"shortlog",
|
|
379
|
+
"name-rev",
|
|
380
|
+
"cat-file",
|
|
381
|
+
"rev-list",
|
|
382
|
+
"version",
|
|
383
|
+
"help",
|
|
384
|
+
"annotate",
|
|
385
|
+
"for-each-ref",
|
|
386
|
+
"merge-base",
|
|
387
|
+
"check-ignore",
|
|
388
|
+
"check-attr",
|
|
389
|
+
"count-objects",
|
|
390
|
+
"var",
|
|
391
|
+
"interpret-trailers"
|
|
392
|
+
];
|
|
393
|
+
const WRAPPERS = new Set(PATTERN_WRAPPER_PROGRAMS);
|
|
394
|
+
const SUB_FETCHERS = new Set(PATTERN_SUB_FETCHERS);
|
|
395
|
+
const INLINE_FLAGS = new Set(PATTERN_INLINE_CODE_FLAGS);
|
|
396
|
+
const GIT_READ_ONLY = new Set(GIT_READ_ONLY_SUBCOMMANDS);
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region ../shared/dist/runs/evidence-utilities.js
|
|
400
|
+
/**
|
|
401
|
+
* Evidence Utilities — shared logic for creating issue_test_evidence records
|
|
402
|
+
* when execution results change status.
|
|
403
|
+
*
|
|
404
|
+
* Used by the backend (tq/run/run-result-variant.service.ts) after rollup
|
|
405
|
+
* to automatically record evidence. Can also be used by the client for
|
|
406
|
+
* optimistic evidence creation.
|
|
407
|
+
*/
|
|
408
|
+
/**
|
|
409
|
+
* The evidence vocabulary — the TypeScript source of truth (ENG-4257 D1).
|
|
410
|
+
*
|
|
411
|
+
* Mirrors BOTH per-table Postgres enums (`issue_test_evidence_evidence_type_enum`
|
|
412
|
+
* and `issue_automation_test_evidence_evidence_type_enum`), which are extended
|
|
413
|
+
* SEPARATELY — per-table enums are the codebase convention and the generator
|
|
414
|
+
* mints `{table}_{column}_enum`. The three-way agreement spec in
|
|
415
|
+
* `apps/backender/src/tq/evidence/evidence-vocabulary-agreement.spec.ts`
|
|
416
|
+
* asserts this tuple set-equals `Object.values` of both generated enums, so a
|
|
417
|
+
* value added to the specs without running codegen, or to one enum and not the
|
|
418
|
+
* other, fails a test instead of diverging silently.
|
|
419
|
+
*
|
|
420
|
+
* `superseded` is a RETRACTION, not an observation: a previously recorded
|
|
421
|
+
* verdict has been withdrawn (result reset to Retest/Pending, or its run
|
|
422
|
+
* deleted). The ledger is append-only and never mutates history — a retraction
|
|
423
|
+
* is a newer row that outranks the verdict it withdraws. (The one exception
|
|
424
|
+
* is a REWORDING, never a new observation: the ENG-5030 data migration
|
|
425
|
+
* retypes `pending` rows written for blocked results to `blocked` in place,
|
|
426
|
+
* because appending would stamp a fresh recorded_at and falsely outrank a
|
|
427
|
+
* later verdict.)
|
|
428
|
+
*
|
|
429
|
+
* `override` and `not_applicable` are EXCUSES, not observations either
|
|
430
|
+
* (ENG-4187): a human accepted the link's state and authorized completion
|
|
431
|
+
* anyway (`override`), or declared that the test cannot apply to this issue
|
|
432
|
+
* (`not_applicable`). Both carry a required `reason`, assert nothing about
|
|
433
|
+
* execution, and stand until newer evidence supersedes them. They classify to
|
|
434
|
+
* the `waived` verdict in link-verification.ts — never to `verified`, so the
|
|
435
|
+
* coverage widget does not count a waived link as covered.
|
|
436
|
+
*
|
|
437
|
+
* `pending` is an OBLIGATION, and the newest member. A run exists for this
|
|
438
|
+
* link and has not answered yet. It is what separates "queued, waiting" from
|
|
439
|
+
* "never run", which had no separate word before the vocabulary alignment —
|
|
440
|
+
* both read `unverified` and the projection could not tell a user which one
|
|
441
|
+
* they were looking at.
|
|
442
|
+
*
|
|
443
|
+
* It is written where every other verdict is written: run creation and a
|
|
444
|
+
* retest append a row, and the real verdict supersedes it by recency. The
|
|
445
|
+
* projection never queries `run_result` — `verified` and `failing` do not, and
|
|
446
|
+
* `pending` earns its place by working the same way.
|
|
447
|
+
*
|
|
448
|
+
* `blocked` is the other obligation (ENG-5030). The tester could not run the
|
|
449
|
+
* test — something external stopped it — so nothing was observed and an
|
|
450
|
+
* answer is still owed, exactly as with `pending`; what the word adds is WHY.
|
|
451
|
+
* ENG-5027 wrote `pending` for a Blocked result while Blocked had no storage
|
|
452
|
+
* type of its own; with the type real, the ledger says the honest thing.
|
|
453
|
+
* Classifies to `blocked` in link-verification.ts: blocks Done like
|
|
454
|
+
* `pending`, is not coverage, and never asserts a failure.
|
|
455
|
+
*/
|
|
456
|
+
const EVIDENCE_TYPES = [
|
|
457
|
+
"detection",
|
|
458
|
+
"pass",
|
|
459
|
+
"fail",
|
|
460
|
+
"regression",
|
|
461
|
+
"verification",
|
|
462
|
+
"flaky",
|
|
463
|
+
"superseded",
|
|
464
|
+
"override",
|
|
465
|
+
"not_applicable",
|
|
466
|
+
"pending",
|
|
467
|
+
"skipped",
|
|
468
|
+
"blocked"
|
|
469
|
+
];
|
|
470
|
+
/**
|
|
471
|
+
* [issue:ENG-6034] Which evidence kinds record an EXECUTION. The rest record a
|
|
472
|
+
* DECISION: nobody ran anything, so no executor may be attributed to them. A
|
|
473
|
+
* waiver (`override`, legacy `not_applicable`) and a retraction
|
|
474
|
+
* (`superseded`) are acts about the obligation; `pending` says an answer is
|
|
475
|
+
* owed; `skipped` is an abstention that reads as waived.
|
|
476
|
+
*
|
|
477
|
+
* `issue_test_evidence.executor_type` is NULL for decision rows. Every writer
|
|
478
|
+
* reads this one table — the client-write stamp in
|
|
479
|
+
* IssueTestEvidenceService.create, the server writer in
|
|
480
|
+
* RunResultVariantService, and the client's display helper
|
|
481
|
+
* (`evidenceExecutorType`) — so none can disagree about which rows had an
|
|
482
|
+
* executor.
|
|
483
|
+
*
|
|
484
|
+
* A `Record` over the whole union rather than a list of exceptions: adding a
|
|
485
|
+
* kind to `EVIDENCE_TYPES` without deciding here is a compile error, instead
|
|
486
|
+
* of the new kind silently defaulting to "an execution" and being stamped.
|
|
487
|
+
*/
|
|
488
|
+
const IS_EXECUTION = {
|
|
489
|
+
detection: true,
|
|
490
|
+
pass: true,
|
|
491
|
+
fail: true,
|
|
492
|
+
regression: true,
|
|
493
|
+
verification: true,
|
|
494
|
+
flaky: true,
|
|
495
|
+
blocked: true,
|
|
496
|
+
superseded: false,
|
|
497
|
+
override: false,
|
|
498
|
+
not_applicable: false,
|
|
499
|
+
pending: false,
|
|
500
|
+
skipped: false
|
|
501
|
+
};
|
|
502
|
+
const NOT_EXECUTED_EVIDENCE_TYPES = EVIDENCE_TYPES.filter((t) => !IS_EXECUTION[t]);
|
|
503
|
+
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region ../shared/dist/runs/automation-failure.js
|
|
506
|
+
/**
|
|
507
|
+
* What counts as an automation FAILURE — defined once (ENG-6017, absorbs
|
|
508
|
+
* ENG-6141).
|
|
509
|
+
*
|
|
510
|
+
* An automation result is a failure when its status is `failed`, `errored`,
|
|
511
|
+
* `timed_out` or `interrupted`. Run-level surfaces always agreed on that; the
|
|
512
|
+
* test-level signals (health fail rate, worst offenders, suite ranking, flip
|
|
513
|
+
* rate, recent-results strip, PR comment) read `failed` alone, so a test that
|
|
514
|
+
* only errored, timed out or was interrupted looked healthy. Every consumer
|
|
515
|
+
* reads this module instead of keeping its own list:
|
|
516
|
+
*
|
|
517
|
+
* - backender `RED_AUTOMATION_RESULT_STATUS_TYPES` (gen automation-run-result
|
|
518
|
+
* service) is built from `AUTOMATION_FAILURE_STATUS_TYPES`;
|
|
519
|
+
* - `AutomationBuilderService.tallyResult` and the rollup `errored_count` arm
|
|
520
|
+
* (`rollup-aggregate.util.ts`) bucket `AUTOMATION_ERRORED_BUCKET_STATUS_TYPES`;
|
|
521
|
+
* - `TestHealthService` (transition and readout) and the client suite widget
|
|
522
|
+
* compute the rate through `automationFailRate`;
|
|
523
|
+
* - widget-queries `automationFailRateExpr` is the SQL twin of
|
|
524
|
+
* `automationFailRate` (drift-tested in `dialect-portability.test.ts`);
|
|
525
|
+
* - the flip-rate scorer, the recent-results strip and the PR comment list
|
|
526
|
+
* filter on `AUTOMATION_FAILURE_STATUS_TYPES`;
|
|
527
|
+
* - the client status pill and the run-detail failed filter.
|
|
528
|
+
*
|
|
529
|
+
* Pure and browser-safe: no imports.
|
|
530
|
+
*/
|
|
531
|
+
/** Every automation result status that is a failure. */
|
|
532
|
+
const AUTOMATION_FAILURE_STATUS_TYPES = [
|
|
533
|
+
"failed",
|
|
534
|
+
"errored",
|
|
535
|
+
"timed_out",
|
|
536
|
+
"interrupted"
|
|
537
|
+
];
|
|
538
|
+
const FAILURE_SET = new Set(AUTOMATION_FAILURE_STATUS_TYPES);
|
|
539
|
+
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region ../shared/dist/runs/link-verification.js
|
|
542
|
+
/** Latest-evidence types that satisfy a verifies/accepts link. */
|
|
543
|
+
const PASSING_EVIDENCE_TYPES = ["pass", "verification"];
|
|
544
|
+
/**
|
|
545
|
+
* The complement of PASSING_EVIDENCE_TYPES over the full vocabulary (ENG-4257
|
|
546
|
+
* D2) — DERIVED, never hand-written, so the coverage widget's inverted SQL
|
|
547
|
+
* predicate (D5) is built from the same list the classifier below is total
|
|
548
|
+
* over and cannot disagree with it. The partition test in
|
|
549
|
+
* link-verification.test.ts pins PASSING ∪ NON_PASSING = EVIDENCE_TYPES and
|
|
550
|
+
* PASSING ∩ NON_PASSING = ∅.
|
|
551
|
+
*/
|
|
552
|
+
const NON_PASSING_EVIDENCE_TYPES = EVIDENCE_TYPES.filter((t) => !PASSING_EVIDENCE_TYPES.includes(t));
|
|
553
|
+
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region ../shared/dist/runs/gate-command-validation.js
|
|
556
|
+
/** Plain file and text inspection — the presence and pattern checks. */
|
|
557
|
+
const INSPECTION_BINARIES = [
|
|
558
|
+
"grep",
|
|
559
|
+
"test",
|
|
560
|
+
"[",
|
|
561
|
+
"[[",
|
|
562
|
+
"wc",
|
|
563
|
+
"cat",
|
|
564
|
+
"head",
|
|
565
|
+
"tail",
|
|
566
|
+
"awk",
|
|
567
|
+
"sed",
|
|
568
|
+
"sort",
|
|
569
|
+
"uniq",
|
|
570
|
+
"echo",
|
|
571
|
+
"printf",
|
|
572
|
+
"tr",
|
|
573
|
+
"cut",
|
|
574
|
+
"diff",
|
|
575
|
+
"find",
|
|
576
|
+
"ls",
|
|
577
|
+
"xargs",
|
|
578
|
+
"cd",
|
|
579
|
+
"timeout",
|
|
580
|
+
"rg",
|
|
581
|
+
"tee",
|
|
582
|
+
"shellcheck",
|
|
583
|
+
"actionlint",
|
|
584
|
+
"jq",
|
|
585
|
+
"cmp"
|
|
586
|
+
];
|
|
587
|
+
/**
|
|
588
|
+
* Arguments a binary may never be given — second R4 follow-up. `jq` (1.7.1,
|
|
589
|
+
* checked) has no in-place option; `-i` / `--in-place` are refused anyway so
|
|
590
|
+
* a jq build or wrapper that grew one (as `yq -i` and `sed -i` have) can
|
|
591
|
+
* never rewrite a file from a gate. A short-flag cluster holding `i` counts.
|
|
592
|
+
*/
|
|
593
|
+
const FORBIDDEN_ARGS = new Map([["jq", /(?:^|\s)(?:-[A-Za-z]*i[A-Za-z]*|--in-place)(?:=|\s|$)/]]);
|
|
594
|
+
/**
|
|
595
|
+
* A short option anywhere in a cluster, attached value or not: `-r x`,
|
|
596
|
+
* `-rx`, `-vr x`. A cluster is read only up to the first letter that takes a
|
|
597
|
+
* value (`valueLetters`): in `-cfoo.yml` the `c` takes `foo.yml`, so its `o`
|
|
598
|
+
* is part of a path, not a flag.
|
|
599
|
+
*/
|
|
600
|
+
function shortFlagArg(letter, valueLetters) {
|
|
601
|
+
return /* @__PURE__ */ new RegExp(`(?:^|[\\s<>|;&(])-(?:(?![${valueLetters}])[A-Za-z])*${letter}`);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* One argument flag, alone, with an attached `=value`, or run into a shell
|
|
605
|
+
* operator (`--report-path>x`): anything but another flag-name character
|
|
606
|
+
* ends it.
|
|
607
|
+
*/
|
|
608
|
+
function flagArg(flag) {
|
|
609
|
+
return /* @__PURE__ */ new RegExp(`(?:^|[\\s<>|;&(])${flag.replace(/-/g, "\\-")}(?![A-Za-z0-9_-])`);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* The words the shell would pass, split at whitespace AND at the operators
|
|
613
|
+
* that end a word (`--config=auto>/tmp/log` passes `--config=auto`), with
|
|
614
|
+
* one layer of surrounding quotes removed. A `#` that starts a word begins a
|
|
615
|
+
* comment, and a redirection's target (`>--metrics=off`, `2> x`) is a file,
|
|
616
|
+
* so neither is an argument.
|
|
617
|
+
*/
|
|
618
|
+
function argWords(rest) {
|
|
619
|
+
return rest.replace(/(^|\s)#.*$/s, "").replace(/(?:(?<=^|[\s<>|;&()])\d+)?(?:<<?|>>?)&?\s*[^\s<>|;&()]*/g, " ").split(/[\s<>|;&()]+/).filter((word) => word.length > 0).map((word) => word.replace(/^(['"])(.*)\1$/, "$2"));
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* [issue:ENG-7618 D3.2] A config semgrep fetches from the network: a URL,
|
|
623
|
+
* `auto`, or a registry ruleset (`p/…`, `r/…`, `s/…`). Registry rules are
|
|
624
|
+
* downloaded at run time and are under the Semgrep Rules License.
|
|
625
|
+
*/
|
|
626
|
+
function isRemoteSemgrepConfig(value) {
|
|
627
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//") || value === "auto" || /^[prs]\//.test(value);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* [issue:ENG-7618 D3.2] `semgrep scan` / `opengrep scan` must name a LOCAL
|
|
631
|
+
* rule set and turn telemetry off. Without an explicit `--config` semgrep
|
|
632
|
+
* picks one from its own default source (`SEMGREP_RULES`, or the registry
|
|
633
|
+
* depending on version) and sends metrics. Every spelling is read: `--config
|
|
634
|
+
* X`, `--config=X`, `-c X`, `-cX`, `-c=X`, `-f X`, `-fX` and `-f=X` for the
|
|
635
|
+
* config, and
|
|
636
|
+
* `--metrics X` / `--metrics=X` for metrics, of which the LAST one counts (so
|
|
637
|
+
* a trailing `--metrics=on` undoes an earlier `off`).
|
|
638
|
+
*/
|
|
639
|
+
/**
|
|
640
|
+
* semgrep's short options that take a value: `-c`/`-f` config, `-e`
|
|
641
|
+
* pattern, `-l` language, `-o` output, `-j` jobs.
|
|
642
|
+
*/
|
|
643
|
+
const SEMGREP_VALUE_LETTERS = /[cfeloj]/;
|
|
644
|
+
/** semgrep's long options that take the next word as their value. */
|
|
645
|
+
const SEMGREP_LONG_VALUE_FLAGS = new Set([
|
|
646
|
+
"--pattern",
|
|
647
|
+
"--lang",
|
|
648
|
+
"--output",
|
|
649
|
+
"--jobs",
|
|
650
|
+
"--include",
|
|
651
|
+
"--exclude",
|
|
652
|
+
"--exclude-rule",
|
|
653
|
+
"--severity",
|
|
654
|
+
"--timeout",
|
|
655
|
+
"--timeout-threshold",
|
|
656
|
+
"--max-memory",
|
|
657
|
+
"--max-target-bytes",
|
|
658
|
+
"--max-chars-per-line",
|
|
659
|
+
"--max-lines-per-finding",
|
|
660
|
+
"--baseline-commit",
|
|
661
|
+
"--project-root",
|
|
662
|
+
"--replacement",
|
|
663
|
+
"--sarif-output",
|
|
664
|
+
"--json-output",
|
|
665
|
+
"--text-output",
|
|
666
|
+
"--emacs-output",
|
|
667
|
+
"--vim-output",
|
|
668
|
+
"--gitlab-sast-output",
|
|
669
|
+
"--gitlab-secrets-output",
|
|
670
|
+
"--junit-xml-output"
|
|
671
|
+
]);
|
|
672
|
+
function semgrepRequiredArgs(rest) {
|
|
673
|
+
const words = argWords(rest);
|
|
674
|
+
const configs = [];
|
|
675
|
+
let metrics;
|
|
676
|
+
for (let i = 0; i < words.length; i++) {
|
|
677
|
+
const word = words[i] ?? "";
|
|
678
|
+
const next = words[i + 1];
|
|
679
|
+
let m;
|
|
680
|
+
if (word === "--") break;
|
|
681
|
+
if (word === "--config") {
|
|
682
|
+
if (next !== void 0) configs.push(next);
|
|
683
|
+
i++;
|
|
684
|
+
} else if (m = /^--config=(.*)$/.exec(word)) configs.push(m[1] ?? "");
|
|
685
|
+
else if (m = /^-([A-Za-z]+)(=?)(.*)$/.exec(word)) {
|
|
686
|
+
const letters = m[1] ?? "";
|
|
687
|
+
const at = letters.search(SEMGREP_VALUE_LETTERS);
|
|
688
|
+
if (at === -1) continue;
|
|
689
|
+
const attached = letters.slice(at + 1) + (m[2] ?? "") + (m[3] ?? "");
|
|
690
|
+
const value = attached.replace(/^=/, "");
|
|
691
|
+
const isConfig = letters[at] === "c" || letters[at] === "f";
|
|
692
|
+
if (attached.length > 0) {
|
|
693
|
+
if (isConfig) configs.push(value);
|
|
694
|
+
} else if (next !== void 0) {
|
|
695
|
+
if (isConfig) configs.push(next);
|
|
696
|
+
i++;
|
|
697
|
+
}
|
|
698
|
+
} else if (word === "--metrics") {
|
|
699
|
+
metrics = next;
|
|
700
|
+
i++;
|
|
701
|
+
} else if (m = /^--metrics=(.*)$/.exec(word)) metrics = m[1];
|
|
702
|
+
else if (SEMGREP_LONG_VALUE_FLAGS.has(word)) i++;
|
|
703
|
+
}
|
|
704
|
+
const named = configs.filter((value) => value.length > 0);
|
|
705
|
+
if (named.length === 0) return "must name a local rule set with --config <path>";
|
|
706
|
+
const globbed = named.find((value) => /[*?[\]{}]/.test(value));
|
|
707
|
+
if (globbed !== void 0) return `may not use the glob config "${globbed}": name the rule set's path`;
|
|
708
|
+
const remote = named.find(isRemoteSemgrepConfig);
|
|
709
|
+
if (remote !== void 0) return `may not use the remote config "${remote}" (a URL, auto, or a p/ r/ s/ registry ruleset); point --config at a local path`;
|
|
710
|
+
if (metrics !== "off") return `must turn telemetry off with --metrics=off (the last --metrics value counts); found ${metrics === void 0 ? "no --metrics argument" : `--metrics=${metrics}`}`;
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
const SEMGREP_RULE = {
|
|
714
|
+
globals: /^/,
|
|
715
|
+
allowed: new Set(["scan"]),
|
|
716
|
+
refuseUncheckable: true,
|
|
717
|
+
forbiddenArgs: [
|
|
718
|
+
{
|
|
719
|
+
pattern: flagArg("--output"),
|
|
720
|
+
name: "--output"
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
pattern: shortFlagArg("o", "cfelj"),
|
|
724
|
+
name: "-o"
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
pattern: /(?:^|[\s<>|;&(])--[a-z0-9-]+-output(?![A-Za-z0-9_-])/,
|
|
728
|
+
name: "--<format>-output"
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
pattern: flagArg("--autofix"),
|
|
732
|
+
name: "--autofix"
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
pattern: flagArg("--fix"),
|
|
736
|
+
name: "--fix"
|
|
737
|
+
}
|
|
738
|
+
],
|
|
739
|
+
requiredArgs: semgrepRequiredArgs
|
|
740
|
+
};
|
|
741
|
+
const SUBCOMMAND_ALLOWLISTS = new Map([
|
|
742
|
+
["git", {
|
|
743
|
+
globals: /^(?:(?:-C\s+\S+|--no-pager|--no-optional-locks)\s+)*/,
|
|
744
|
+
allowed: new Set([
|
|
745
|
+
"diff",
|
|
746
|
+
"diff-tree",
|
|
747
|
+
"diff-index",
|
|
748
|
+
"diff-files",
|
|
749
|
+
"log",
|
|
750
|
+
"shortlog",
|
|
751
|
+
"show",
|
|
752
|
+
"show-ref",
|
|
753
|
+
"status",
|
|
754
|
+
"ls-files",
|
|
755
|
+
"ls-tree",
|
|
756
|
+
"ls-remote",
|
|
757
|
+
"grep",
|
|
758
|
+
"rev-parse",
|
|
759
|
+
"rev-list",
|
|
760
|
+
"merge-base",
|
|
761
|
+
"cat-file",
|
|
762
|
+
"blame",
|
|
763
|
+
"for-each-ref",
|
|
764
|
+
"describe",
|
|
765
|
+
"name-rev",
|
|
766
|
+
"check-ignore",
|
|
767
|
+
"check-attr"
|
|
768
|
+
]),
|
|
769
|
+
forbiddenArgs: [{
|
|
770
|
+
pattern: flagArg("--output"),
|
|
771
|
+
name: "--output (it writes a file)"
|
|
772
|
+
}]
|
|
773
|
+
}],
|
|
774
|
+
["helm", {
|
|
775
|
+
globals: /^/,
|
|
776
|
+
allowed: new Set(["lint", "template"])
|
|
777
|
+
}],
|
|
778
|
+
["gitleaks", {
|
|
779
|
+
globals: /^/,
|
|
780
|
+
allowed: new Set(["dir", "detect"]),
|
|
781
|
+
refuseUncheckable: true,
|
|
782
|
+
forbiddenArgs: [
|
|
783
|
+
{
|
|
784
|
+
pattern: flagArg("--report-path"),
|
|
785
|
+
name: "--report-path"
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
pattern: flagArg("--diagnostics"),
|
|
789
|
+
name: "--diagnostics"
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
pattern: flagArg("--diagnostics-dir"),
|
|
793
|
+
name: "--diagnostics-dir"
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
pattern: shortFlagArg("r", "cibfls"),
|
|
797
|
+
name: "-r"
|
|
798
|
+
}
|
|
799
|
+
]
|
|
800
|
+
}],
|
|
801
|
+
["semgrep", SEMGREP_RULE],
|
|
802
|
+
["opengrep", SEMGREP_RULE],
|
|
803
|
+
["trivy", {
|
|
804
|
+
globals: /^/,
|
|
805
|
+
allowed: new Set(["fs", "config"]),
|
|
806
|
+
refuseUncheckable: true,
|
|
807
|
+
forbiddenArgs: [
|
|
808
|
+
{
|
|
809
|
+
pattern: flagArg("--output"),
|
|
810
|
+
name: "--output"
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
pattern: shortFlagArg("o", "cfst"),
|
|
814
|
+
name: "-o"
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
pattern: flagArg("--download-db-only"),
|
|
818
|
+
name: "--download-db-only"
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
pattern: flagArg("--download-java-db-only"),
|
|
822
|
+
name: "--download-java-db-only"
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
pattern: flagArg("--server"),
|
|
826
|
+
name: "--server"
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
pattern: flagArg("--cache-dir"),
|
|
830
|
+
name: "--cache-dir"
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
pattern: flagArg("--module-dir"),
|
|
834
|
+
name: "--module-dir"
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
pattern: flagArg("--db-repository"),
|
|
838
|
+
name: "--db-repository"
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
pattern: flagArg("--java-db-repository"),
|
|
842
|
+
name: "--java-db-repository"
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
pattern: flagArg("--checks-bundle-repository"),
|
|
846
|
+
name: "--checks-bundle-repository"
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
pattern: flagArg("--cache-backend"),
|
|
850
|
+
name: "--cache-backend"
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
pattern: /(?:^|[\s<>|;&(])--redis-[a-z-]+(?![A-Za-z0-9_-])/,
|
|
854
|
+
name: "--redis-*"
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
pattern: flagArg("--rekor-url"),
|
|
858
|
+
name: "--rekor-url"
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
pattern: flagArg("--sbom-sources"),
|
|
862
|
+
name: "--sbom-sources"
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
pattern: flagArg("server"),
|
|
866
|
+
name: "server"
|
|
867
|
+
}
|
|
868
|
+
]
|
|
869
|
+
}]
|
|
870
|
+
]);
|
|
871
|
+
/**
|
|
872
|
+
* ENG-5106 L1 review round 3 F-003 — the utility `xargs` runs: the first word
|
|
873
|
+
* after its options, skipping each option's VALUE (`-n 1`, `-I {}`,
|
|
874
|
+
* `--max-args 1`; attached forms `-n1`, `--max-args=1` carry their own).
|
|
875
|
+
* A prefix regex let `xargs -n 1 test -n` through as a non-`test` command.
|
|
876
|
+
*/
|
|
877
|
+
const XARGS_SHORT_WITH_VALUE = new Set([
|
|
878
|
+
"a",
|
|
879
|
+
"d",
|
|
880
|
+
"E",
|
|
881
|
+
"I",
|
|
882
|
+
"L",
|
|
883
|
+
"n",
|
|
884
|
+
"P",
|
|
885
|
+
"s"
|
|
886
|
+
]);
|
|
887
|
+
const XARGS_LONG_WITH_VALUE = new Set([
|
|
888
|
+
"--arg-file",
|
|
889
|
+
"--delimiter",
|
|
890
|
+
"--max-args",
|
|
891
|
+
"--max-procs",
|
|
892
|
+
"--max-chars",
|
|
893
|
+
"--process-slot-var"
|
|
894
|
+
]);
|
|
895
|
+
/**
|
|
896
|
+
* Review round 4 F-004 / F-009 — what `xargs` may run: read-only utilities.
|
|
897
|
+
* Parsing every way to reach `test` (bundled `-rn 1`, quoted `'test'`,
|
|
898
|
+
* `timeout 5 test`, `env test`, `sh -c 'test …'`) was a losing game; live
|
|
899
|
+
* gates use `grep` alone. Anything else is refused.
|
|
900
|
+
*/
|
|
901
|
+
const XARGS_UTILITIES = new Set([
|
|
902
|
+
"grep",
|
|
903
|
+
"egrep",
|
|
904
|
+
"fgrep",
|
|
905
|
+
"rg",
|
|
906
|
+
"wc",
|
|
907
|
+
"cat",
|
|
908
|
+
"head",
|
|
909
|
+
"tail",
|
|
910
|
+
"ls",
|
|
911
|
+
"basename",
|
|
912
|
+
"dirname",
|
|
913
|
+
"stat",
|
|
914
|
+
"file",
|
|
915
|
+
"echo",
|
|
916
|
+
"printf",
|
|
917
|
+
"sort",
|
|
918
|
+
"uniq",
|
|
919
|
+
"cut",
|
|
920
|
+
"tr",
|
|
921
|
+
"jq"
|
|
922
|
+
]);
|
|
923
|
+
/**
|
|
924
|
+
* The utility `xargs` runs and that utility's own arguments. Quoted words
|
|
925
|
+
* arrive already masked to `""` by `scanShellCommand`, so a quoted utility
|
|
926
|
+
* is the empty string here — and is refused like any other.
|
|
927
|
+
*/
|
|
928
|
+
function xargsUtility(args) {
|
|
929
|
+
const words = args.trim().split(/\s+/).filter(Boolean);
|
|
930
|
+
const at = (k) => k < words.length ? {
|
|
931
|
+
utility: words[k],
|
|
932
|
+
rest: words.slice(k + 1).join(" ")
|
|
933
|
+
} : null;
|
|
934
|
+
for (let k = 0; k < words.length; k++) {
|
|
935
|
+
const w = words[k];
|
|
936
|
+
if (w === "--") return at(k + 1);
|
|
937
|
+
if (w.startsWith("--")) {
|
|
938
|
+
if (!w.includes("=") && XARGS_LONG_WITH_VALUE.has(w)) k++;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (w.startsWith("-") && w.length > 1) {
|
|
942
|
+
for (let j = 1; j < w.length; j++) if (XARGS_SHORT_WITH_VALUE.has(w[j])) {
|
|
943
|
+
if (j === w.length - 1) k++;
|
|
944
|
+
break;
|
|
945
|
+
}
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
return at(k);
|
|
949
|
+
}
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
const baseName = (word) => word.slice(word.lastIndexOf("/") + 1);
|
|
953
|
+
function isTestUtility(word) {
|
|
954
|
+
if (word === null) return false;
|
|
955
|
+
const base = baseName(word);
|
|
956
|
+
return base === "test" || base === "[";
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* The refusal for a subcommand-restricted binary, or null when this use is
|
|
960
|
+
* allowed (or the binary is not restricted).
|
|
961
|
+
*/
|
|
962
|
+
function subcommandRefusal(token, args) {
|
|
963
|
+
const xargsRuns = token === "xargs" ? xargsUtility(args) : null;
|
|
964
|
+
if (xargsRuns !== null && isTestUtility(xargsRuns.utility)) return "\"xargs test\" passes on empty input, so this check cannot fail (ENG-4761). Redeploy the gate: current templates test the search result with test -n \"$(…)\"";
|
|
965
|
+
if (xargsRuns !== null) {
|
|
966
|
+
const utility = baseName(xargsRuns.utility);
|
|
967
|
+
if (!XARGS_UTILITIES.has(utility)) return `"xargs" may only run a read-only utility (${[...XARGS_UTILITIES].join(", ")}), not ` + (utility === "\"\"" || utility === "" ? "a quoted word" : `"${utility}"`);
|
|
968
|
+
return subcommandRefusal(utility, xargsRuns.rest);
|
|
969
|
+
}
|
|
970
|
+
if (FORBIDDEN_ARGS.get(token)?.test(args)) return `"${token}" may not edit a file in place (-i / --in-place)`;
|
|
971
|
+
const rule = SUBCOMMAND_ALLOWLISTS.get(token);
|
|
972
|
+
if (!rule) return null;
|
|
973
|
+
const rest = args.replace(rule.globals, "");
|
|
974
|
+
const sub = rest.split(/\s+/)[0] ?? "";
|
|
975
|
+
if (!rule.allowed.has(sub)) return `"${token} ${sub || "(no subcommand)"}" is not allowed; ${token} may only run ${[...rule.allowed].join(", ")}`;
|
|
976
|
+
const words = rest.split(/\s+/).filter(Boolean);
|
|
977
|
+
const uncheckable = rule.refuseUncheckable ? words.find((w) => {
|
|
978
|
+
if (w.includes("\"\"") || w.includes("\\") || w.includes("$")) return true;
|
|
979
|
+
if (/\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(w)) return true;
|
|
980
|
+
return /[*?[]/.test(w);
|
|
981
|
+
}) : void 0;
|
|
982
|
+
if (uncheckable !== void 0) return `"${token} ${sub}" may not quote, escape or expand an argument (${uncheckable}): the word the shell runs could not be checked`;
|
|
983
|
+
const refusedArg = rule.forbiddenArgs?.find(({ pattern }) => pattern.test(rest));
|
|
984
|
+
if (refusedArg) return `"${token} ${sub}" may not use ${refusedArg.name}`;
|
|
985
|
+
const missing = rule.requiredArgs?.(rest);
|
|
986
|
+
if (missing) return `"${token} ${sub}" ${missing}`;
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
/** Interpreters and shells the seeded templates and L3/L4 gates use. */
|
|
990
|
+
const INTERPRETER_BINARIES = [
|
|
991
|
+
"node",
|
|
992
|
+
"python3",
|
|
993
|
+
"python",
|
|
994
|
+
"bash",
|
|
995
|
+
"sh",
|
|
996
|
+
"perl",
|
|
997
|
+
"ruby",
|
|
998
|
+
"php"
|
|
999
|
+
];
|
|
1000
|
+
/**
|
|
1001
|
+
* Package managers, build tools and test runners a command binding may
|
|
1002
|
+
* supply — review R4-01. Every toolchain the binding docs and specs name
|
|
1003
|
+
* (`running-gates.mdx`, `gate-bindings.tool.spec.ts`,
|
|
1004
|
+
* `gate-type-resolver-command-slots.spec.ts`, the toolchain migration).
|
|
1005
|
+
*/
|
|
1006
|
+
const GATE_TOOLCHAIN_BINARIES = [
|
|
1007
|
+
"yarn",
|
|
1008
|
+
"npx",
|
|
1009
|
+
"npm",
|
|
1010
|
+
"pnpm",
|
|
1011
|
+
"bun",
|
|
1012
|
+
"bunx",
|
|
1013
|
+
"deno",
|
|
1014
|
+
"tsc",
|
|
1015
|
+
"tsgo",
|
|
1016
|
+
"go",
|
|
1017
|
+
"cargo",
|
|
1018
|
+
"pytest",
|
|
1019
|
+
"uv",
|
|
1020
|
+
"poetry",
|
|
1021
|
+
"pipenv",
|
|
1022
|
+
"hatch",
|
|
1023
|
+
"tox",
|
|
1024
|
+
"phpunit",
|
|
1025
|
+
"pest",
|
|
1026
|
+
"composer",
|
|
1027
|
+
"bundle",
|
|
1028
|
+
"rspec",
|
|
1029
|
+
"rake",
|
|
1030
|
+
"mvn",
|
|
1031
|
+
"gradle",
|
|
1032
|
+
"dotnet",
|
|
1033
|
+
"swift",
|
|
1034
|
+
"mix",
|
|
1035
|
+
"make"
|
|
1036
|
+
];
|
|
1037
|
+
/** Semantic judges (L4), invoked directly or piped into. */
|
|
1038
|
+
const GATE_JUDGE_BINARIES = [
|
|
1039
|
+
"agy",
|
|
1040
|
+
"codex",
|
|
1041
|
+
"gemini"
|
|
1042
|
+
];
|
|
1043
|
+
/**
|
|
1044
|
+
* Every binary a gate command may start a command with, in one list — what a
|
|
1045
|
+
* refusal message names.
|
|
1046
|
+
*/
|
|
1047
|
+
const GATE_COMMAND_BINARIES = [
|
|
1048
|
+
...INSPECTION_BINARIES,
|
|
1049
|
+
...INTERPRETER_BINARIES,
|
|
1050
|
+
...GATE_TOOLCHAIN_BINARIES,
|
|
1051
|
+
...GATE_JUDGE_BINARIES
|
|
1052
|
+
];
|
|
1053
|
+
const ALLOWED_BINARIES = new Set(GATE_COMMAND_BINARIES);
|
|
1054
|
+
/**
|
|
1055
|
+
* Shell builtins a command may be. `exit`/`true`/`false` end real gates
|
|
1056
|
+
* (`…; exit $?`); `set -e`/`set -o pipefail` and `read` are common in
|
|
1057
|
+
* multi-line checks; `:` is the no-op. `export` is handled separately (it
|
|
1058
|
+
* must only assign).
|
|
1059
|
+
*/
|
|
1060
|
+
const ALLOWED_BUILTINS = new Set([
|
|
1061
|
+
"exit",
|
|
1062
|
+
"true",
|
|
1063
|
+
"false",
|
|
1064
|
+
"set",
|
|
1065
|
+
"read",
|
|
1066
|
+
":"
|
|
1067
|
+
]);
|
|
1068
|
+
/**
|
|
1069
|
+
* Runners a project installs locally and invokes by path — review R4-01
|
|
1070
|
+
* (`./vendor/bin/phpunit` on prod ENG-113..115, `node_modules/.bin/vitest`).
|
|
1071
|
+
*/
|
|
1072
|
+
const PROJECT_LOCAL_RUNNERS = new Set([
|
|
1073
|
+
"phpunit",
|
|
1074
|
+
"pest",
|
|
1075
|
+
"paratest",
|
|
1076
|
+
"phpstan",
|
|
1077
|
+
"psalm",
|
|
1078
|
+
"rspec",
|
|
1079
|
+
"rake",
|
|
1080
|
+
"vitest",
|
|
1081
|
+
"jest",
|
|
1082
|
+
"playwright",
|
|
1083
|
+
"mocha",
|
|
1084
|
+
"tsc",
|
|
1085
|
+
"tsx",
|
|
1086
|
+
"eslint",
|
|
1087
|
+
"pytest"
|
|
1088
|
+
]);
|
|
1089
|
+
/**
|
|
1090
|
+
* Is `token` a project-local runner (`./vendor/bin/phpunit`,
|
|
1091
|
+
* `vendor/bin/pest`, `./node_modules/.bin/vitest`, `bin/rspec`) or a build
|
|
1092
|
+
* wrapper checked into the repo (`./gradlew`, `./mvnw`)?
|
|
1093
|
+
*/
|
|
1094
|
+
function isProjectLocalTool(token) {
|
|
1095
|
+
if (token === "./gradlew" || token === "./mvnw") return true;
|
|
1096
|
+
const m = /^(?:\.\/)?(?:vendor\/bin|node_modules\/\.bin|bin)\/([\w.-]+)$/.exec(token);
|
|
1097
|
+
return m !== null && PROJECT_LOCAL_RUNNERS.has(m[1]);
|
|
1098
|
+
}
|
|
1099
|
+
/** Binaries `!` may negate — review R4-03. */
|
|
1100
|
+
const NEGATABLE_BINARIES = new Set([
|
|
1101
|
+
"grep",
|
|
1102
|
+
"test",
|
|
1103
|
+
"[",
|
|
1104
|
+
"[["
|
|
1105
|
+
]);
|
|
1106
|
+
function isAllowedBinary(token) {
|
|
1107
|
+
return ALLOWED_BINARIES.has(token) || ALLOWED_BUILTINS.has(token) || token.endsWith("/grep") || isProjectLocalTool(token);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Words after which the next word is again in command position: compound
|
|
1111
|
+
* openers and `!`. Review round 4 F-001 added `if`, `while`, `until` and `!`
|
|
1112
|
+
* (`if case x in x) touch M ;; esac; then …` slipped past without them).
|
|
1113
|
+
*/
|
|
1114
|
+
const COMMAND_OPENERS = new Set([
|
|
1115
|
+
"then",
|
|
1116
|
+
"do",
|
|
1117
|
+
"else",
|
|
1118
|
+
"elif",
|
|
1119
|
+
"if",
|
|
1120
|
+
"while",
|
|
1121
|
+
"until",
|
|
1122
|
+
"{",
|
|
1123
|
+
"!"
|
|
1124
|
+
]);
|
|
1125
|
+
/**
|
|
1126
|
+
* Is `s[i]` at the start of a word in COMMAND position — the start of the
|
|
1127
|
+
* text (`from`), or after `;` `|` `&` `(` `)` a newline, or after an opener
|
|
1128
|
+
* word that is itself in command position? A loop, not recursion (review
|
|
1129
|
+
* round 4 F-013: 40 KB of `then ` overflowed the stack).
|
|
1130
|
+
*/
|
|
1131
|
+
function isInCommandPosition(s, i, from) {
|
|
1132
|
+
let at = i;
|
|
1133
|
+
for (;;) {
|
|
1134
|
+
let p = at - 1;
|
|
1135
|
+
while (p >= from && (s[p] === " " || s[p] === " ")) p--;
|
|
1136
|
+
if (p < from) return true;
|
|
1137
|
+
if (";|&()\n".includes(s[p])) return true;
|
|
1138
|
+
let q = p;
|
|
1139
|
+
while (q >= from && /[a-z{!]/.test(s[q])) q--;
|
|
1140
|
+
const word = s.slice(q + 1, p + 1);
|
|
1141
|
+
if (!(q < from || /[\s;|&()]/.test(s[q])) || !COMMAND_OPENERS.has(word)) return false;
|
|
1142
|
+
if (q < from) return true;
|
|
1143
|
+
at = q + 1;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Review round 4 (root cause A) — what this scanner does NOT model, it
|
|
1148
|
+
* REFUSES rather than guesses. Each of these makes bash read the text
|
|
1149
|
+
* differently from `scanShellCommand`, and every one was reproduced
|
|
1150
|
+
* running a command the validator never saw:
|
|
1151
|
+
*
|
|
1152
|
+
* - `$'…'` anywhere (ANSI-C quoting: `\'` does not end it);
|
|
1153
|
+
* - a heredoc `<<` anywhere (its body is data to bash, commands to us);
|
|
1154
|
+
* - inside a `$( … )`, `<( … )` or `>( … )`: a word-start `#` (a comment
|
|
1155
|
+
* can hide the `)`), a `${` (`${x/)/y}` ends the substitution early for
|
|
1156
|
+
* us), or `case` in command position (its pattern `)`s).
|
|
1157
|
+
*
|
|
1158
|
+
* Review round 6 F-001: process substitutions are bodies bash runs exactly
|
|
1159
|
+
* like `$( )`, so they are read the same way — `grep x <(echo #)` followed
|
|
1160
|
+
* by a newline and `touch p` ran the `touch` while this looked only at `$(`.
|
|
1161
|
+
*
|
|
1162
|
+
* Measured against every live gate step on staging (10,643; 6,217
|
|
1163
|
+
* accepted today): none uses any of these.
|
|
1164
|
+
*/
|
|
1165
|
+
function unmodelledConstruct(t, inSubstitution = false) {
|
|
1166
|
+
let quote = null;
|
|
1167
|
+
for (let i = 0; i < t.length; i++) {
|
|
1168
|
+
const c = t[i];
|
|
1169
|
+
if (quote === "'") {
|
|
1170
|
+
if (c === "'") quote = null;
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
if (c === "\\") {
|
|
1174
|
+
i++;
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
if (c === "$" && t[i + 1] === "(") {
|
|
1178
|
+
const close = closeOfParen(t, i + 2);
|
|
1179
|
+
if (close === -1) return null;
|
|
1180
|
+
const inner = unmodelledConstruct(t.slice(i + 2, close - 1), true);
|
|
1181
|
+
if (inner) return inner;
|
|
1182
|
+
i = close - 1;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (quote === "\"") {
|
|
1186
|
+
if (c === "\"") quote = null;
|
|
1187
|
+
else if (inSubstitution && c === "$" && t[i + 1] === "{") return "`${` inside a substitution";
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
if ((c === "<" || c === ">") && t[i + 1] === "(") {
|
|
1191
|
+
const close = closeOfParen(t, i + 2);
|
|
1192
|
+
if (close === -1) return null;
|
|
1193
|
+
const inner = unmodelledConstruct(t.slice(i + 2, close - 1), true);
|
|
1194
|
+
if (inner) return inner;
|
|
1195
|
+
i = close - 1;
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (c === "'" || c === "\"") {
|
|
1199
|
+
if (c === "'" && t[i - 1] === "$") return "`$'…'` quoting";
|
|
1200
|
+
quote = c;
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
if (c === "<" && t[i + 1] === "<" && t[i + 2] !== "<" && t[i - 1] !== "<") return "a heredoc (`<<`)";
|
|
1204
|
+
if (!inSubstitution) continue;
|
|
1205
|
+
if (c === "#" && (i === 0 || /[\s;|&(]/.test(t[i - 1]))) return "a `#` comment inside a substitution";
|
|
1206
|
+
if (c === "$" && t[i + 1] === "{") return "`${` inside a substitution";
|
|
1207
|
+
if (c === "c" && t.startsWith("case", i) && /^(?:\s|$)/.test(t.slice(i + 4, i + 5)) && (i === 0 || /[\s;|&()]/.test(t[i - 1])) && isInCommandPosition(t, i, 0)) return "`case` inside a substitution";
|
|
1208
|
+
}
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Index just past the `)` that closes a `(` whose body starts at `from`, or
|
|
1213
|
+
* -1. Quote-aware: a `)` inside quotes does not close it.
|
|
1214
|
+
*
|
|
1215
|
+
* Exported for `splitRespectingQuotes` (ENG-5106 L1 review F-017): the
|
|
1216
|
+
* splitter that decides what the executor RUNS must read a substitution
|
|
1217
|
+
* exactly as this validator does, so there is one copy.
|
|
1218
|
+
*/
|
|
1219
|
+
function closeOfParen(s, from) {
|
|
1220
|
+
let depth = 1;
|
|
1221
|
+
let quote = null;
|
|
1222
|
+
const cases = [];
|
|
1223
|
+
const wordAt = (i, word) => s.startsWith(word, i) && /^(?:[\s;|&)]|$)/.test(s.slice(i + word.length, i + word.length + 1)) && isInCommandPosition(s, i, from);
|
|
1224
|
+
for (let i = from; i < s.length; i++) {
|
|
1225
|
+
const c = s[i];
|
|
1226
|
+
if (quote === "'") {
|
|
1227
|
+
if (c === "'") quote = null;
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
if (c === "\\") {
|
|
1231
|
+
i++;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (quote === "\"") {
|
|
1235
|
+
if (c === "\"") quote = null;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
if (c === "'" || c === "\"") quote = c;
|
|
1239
|
+
else if (c === "c" && wordAt(i, "case")) {
|
|
1240
|
+
cases.push(depth);
|
|
1241
|
+
i += 3;
|
|
1242
|
+
} else if (c === "e" && wordAt(i, "esac") && cases.at(-1) === depth) {
|
|
1243
|
+
cases.pop();
|
|
1244
|
+
i += 3;
|
|
1245
|
+
} else if (c === "(") depth++;
|
|
1246
|
+
else if (c === ")") {
|
|
1247
|
+
if (cases.at(-1) === depth) continue;
|
|
1248
|
+
if (--depth === 0) return i + 1;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return -1;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Split a command at every top-level shell separator, and collect every
|
|
1255
|
+
* substitution the shell would EXECUTE — ENG-5967 F-008, review R4-09.
|
|
1256
|
+
*
|
|
1257
|
+
* Single quotes are the one context the shell does not look inside, so a
|
|
1258
|
+
* `$(` there is literal (the L4 templates' `bash -c '…'` body) and is not
|
|
1259
|
+
* collected; in double quotes and bare text it runs, and is. A process
|
|
1260
|
+
* substitution (`<( )`, `>( )`) runs only unquoted. `$(( … ))` is arithmetic,
|
|
1261
|
+
* not a command: only the substitutions inside it are collected.
|
|
1262
|
+
*/
|
|
1263
|
+
function scanShellCommand(s) {
|
|
1264
|
+
const segments = [];
|
|
1265
|
+
const substitutions = [];
|
|
1266
|
+
let backtick = false;
|
|
1267
|
+
let unbalanced = false;
|
|
1268
|
+
let cur = "";
|
|
1269
|
+
let quote = null;
|
|
1270
|
+
const cut = (sepAfter) => {
|
|
1271
|
+
segments.push({
|
|
1272
|
+
text: cur,
|
|
1273
|
+
sepAfter
|
|
1274
|
+
});
|
|
1275
|
+
cur = "";
|
|
1276
|
+
};
|
|
1277
|
+
for (let i = 0; i < s.length; i++) {
|
|
1278
|
+
const c = s[i];
|
|
1279
|
+
if (quote === "'") {
|
|
1280
|
+
if (c === "'") quote = null;
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
if (c === "\\") {
|
|
1284
|
+
if (quote === null) cur += s.slice(i, i + 2);
|
|
1285
|
+
i++;
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
if (c === "`") {
|
|
1289
|
+
backtick = true;
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (c === "$" && s[i + 1] === "(") {
|
|
1293
|
+
const arithmetic = s[i + 2] === "(";
|
|
1294
|
+
const close = closeOfParen(s, i + 2);
|
|
1295
|
+
if (close === -1) {
|
|
1296
|
+
unbalanced = true;
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
const body = s.slice(i + 2, close - 1);
|
|
1300
|
+
if (arithmetic) {
|
|
1301
|
+
const inner = scanShellCommand(body.slice(1, -1));
|
|
1302
|
+
substitutions.push(...inner.substitutions);
|
|
1303
|
+
backtick ||= inner.backtick;
|
|
1304
|
+
unbalanced ||= inner.unbalanced;
|
|
1305
|
+
} else substitutions.push(body);
|
|
1306
|
+
if (quote === null) cur += "\"\"";
|
|
1307
|
+
i = close - 1;
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (quote === "\"") {
|
|
1311
|
+
if (c === "\"") quote = null;
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
if ((c === "<" || c === ">") && s[i + 1] === "(") {
|
|
1315
|
+
const close = closeOfParen(s, i + 2);
|
|
1316
|
+
if (close === -1) {
|
|
1317
|
+
unbalanced = true;
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
substitutions.push(s.slice(i + 2, close - 1));
|
|
1321
|
+
cur += "\"\"";
|
|
1322
|
+
i = close - 1;
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
if (c === "'" || c === "\"") {
|
|
1326
|
+
quote = c;
|
|
1327
|
+
cur += "\"\"";
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (c === ";" || c === "\n") {
|
|
1331
|
+
cut(";");
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (c === "|") {
|
|
1335
|
+
if (s[i + 1] === "|") {
|
|
1336
|
+
i++;
|
|
1337
|
+
cut("||");
|
|
1338
|
+
} else cut("|");
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
if (c === "&") {
|
|
1342
|
+
const prev = s[i - 1];
|
|
1343
|
+
if (prev === ">" || prev === "<" || s[i + 1] === ">") {
|
|
1344
|
+
cur += c;
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if (s[i + 1] === "&") {
|
|
1348
|
+
i++;
|
|
1349
|
+
cut("&&");
|
|
1350
|
+
} else cut("&");
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
cur += c;
|
|
1354
|
+
}
|
|
1355
|
+
if (quote !== null) unbalanced = true;
|
|
1356
|
+
cut(null);
|
|
1357
|
+
return {
|
|
1358
|
+
segments,
|
|
1359
|
+
substitutions,
|
|
1360
|
+
backtick,
|
|
1361
|
+
unbalanced
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
/** Reserved words that OPEN or CONTINUE a compound command before a command. */
|
|
1365
|
+
const LEADING_RESERVED = /^(?:if|then|elif|else|do|while|until)(?=\s|$)\s*/;
|
|
1366
|
+
/** Words that CLOSE a compound command; nothing runs from them. */
|
|
1367
|
+
const CLOSING_WORDS = /^(?:fi|done|esac|\}|\)+)(?=\s|$|\)|;)/;
|
|
1368
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=\S*(?:\s+|$)/;
|
|
1369
|
+
/**
|
|
1370
|
+
* [issue:ENG-7618 review N1] A scanner reads every option it has from an
|
|
1371
|
+
* environment variable too (`TRIVY_OUTPUT`, `TRIVY_CACHE_DIR`,
|
|
1372
|
+
* `TRIVY_DB_REPOSITORY`, …), so a gate setting one reaches each option the
|
|
1373
|
+
* scanner rules refuse without ever typing the flag. A gate command may not
|
|
1374
|
+
* set any variable in a scanner's namespace, by assignment, `export` or `env`.
|
|
1375
|
+
*/
|
|
1376
|
+
const SCANNER_ENV_NAME = /^(?:TRIVY|SEMGREP|GITLEAKS)_/;
|
|
1377
|
+
const SCANNER_ENV_REFUSAL = "may not set a scanner's environment variables (TRIVY_*, SEMGREP_*, GITLEAKS_*): each reaches an option the scanner rules refuse";
|
|
1378
|
+
const SCANNER_TOKENS = new Set([
|
|
1379
|
+
"trivy",
|
|
1380
|
+
"semgrep",
|
|
1381
|
+
"opengrep",
|
|
1382
|
+
"gitleaks"
|
|
1383
|
+
]);
|
|
1384
|
+
/** A scanner's config and ignore files, which a gate must not write. */
|
|
1385
|
+
const SCANNER_CONFIG_FILE = /(?:^|\/)(?:trivy\.ya?ml|\.trivy[^\s/]*|\.gitleaks[^\s/]*|\.semgrep[^\s/]*)$/;
|
|
1386
|
+
/**
|
|
1387
|
+
* [issue:ENG-7618 review round 3] Checks for a command that runs a scanner,
|
|
1388
|
+
* over the routes that reach a refused option without typing its flag. They
|
|
1389
|
+
* are DEFENCE IN DEPTH, not the boundary: a command slot is supplied only by
|
|
1390
|
+
* an Owner or Admin (the gate-impl capability, GateImplPolicyService), and a
|
|
1391
|
+
* text checker cannot model everything bash can do. What is refused:
|
|
1392
|
+
* - `set -a` / `set -o allexport`, under which `read` and `printf -v`
|
|
1393
|
+
* export what they assign (F1); and `read` / `printf -v` into a scanner
|
|
1394
|
+
* variable anywhere (F1);
|
|
1395
|
+
* - writing a scanner config or ignore file by redirect or `tee`: trivy loads
|
|
1396
|
+
* `trivy.yaml` from the working directory unasked (F2);
|
|
1397
|
+
* - a `--config` / `-c` that is absolute or climbs out with `..` (F2);
|
|
1398
|
+
* - setting HOME, TMPDIR, XDG_* or a proxy variable, which move trivy's cache
|
|
1399
|
+
* and route its downloads (F3).
|
|
1400
|
+
*/
|
|
1401
|
+
function scannerCommandRefusal(command) {
|
|
1402
|
+
const words = command.split(/[\s;&|()]+/).filter(Boolean);
|
|
1403
|
+
if (/(?:^|[\s;&|(])(?:read|printf)\b[^;&|]*?\b(?:TRIVY|SEMGREP|GITLEAKS)_/.test(command)) {
|
|
1404
|
+
if (/(?:^|[\s;&|(])read\s(?:[^;&|]*\s)?(?:TRIVY|SEMGREP|GITLEAKS)_\w*/.test(command) || /(?:^|[\s;&|(])printf\s+(?:-\S+\s+)*-v\s*(?:TRIVY|SEMGREP|GITLEAKS)_/.test(command)) return SCANNER_ENV_REFUSAL;
|
|
1405
|
+
}
|
|
1406
|
+
if (!words.some((w) => SCANNER_TOKENS.has(w))) return null;
|
|
1407
|
+
if (/(?:^|[\s;&|(])set\s+(?:-o\s+allexport\b|(?:\S+\s+)*-[A-Za-z]*a[A-Za-z]*(?:\s|$))/.test(command) || /(?:^|[\s;&|(])set\s+(?:\S+\s+)*-o\s+allexport\b/.test(command)) return "may not turn on `set -a` / `set -o allexport` in a command that runs a scanner: what it assigns reaches the scanner as environment variables";
|
|
1408
|
+
const redirected = [...command.matchAll(/>{1,2}\|?\s*(['"]?)([^\s;&|<>'"]+)\1/g)].map((m) => m[2]);
|
|
1409
|
+
const teed = [...command.matchAll(/(?:^|[\s;&|(])tee\s+((?:-\S+\s+)*)([^\s;&|<>]+)/g)].map((m) => m[2]);
|
|
1410
|
+
const written = [...redirected, ...teed].find((t) => SCANNER_CONFIG_FILE.test(t));
|
|
1411
|
+
if (written) return `may not write the scanner config file ${written}: a scanner reads it as its options`;
|
|
1412
|
+
for (const m of command.matchAll(/(?:^|[\s;&|(])(?:--config(?:=|\s+)|-c\s*=?\s*)(['"]?)([^\s;&|<>'"]+)\1/g)) {
|
|
1413
|
+
const value = m[2];
|
|
1414
|
+
if (value.startsWith("//") || value.includes("://")) continue;
|
|
1415
|
+
if (value.startsWith("/") || value.startsWith("~") || /(?:^|\/)\.\.(?:\/|$)/.test(value)) return `a scanner --config must be a path inside the repository (no leading /, ~ or ..); got ${value}`;
|
|
1416
|
+
}
|
|
1417
|
+
if (/(?:^|[\s;&|(])(?:export\s+(?:\S+\s+)*|env\s+(?:\S+\s+)*)?(?:HOME|TMPDIR|XDG_[A-Z_]+|(?:HTTPS?|ALL|NO|FTP)_PROXY|(?:https?|all|no|ftp)_proxy)\+?=/.test(command)) return "may not set HOME, TMPDIR, XDG_* or a proxy variable in a command that runs a scanner: they move its cache and route its downloads";
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
const ENV_WRAPPER = /^env(?:\s+(?:-i|-0|--ignore-environment|--null|-u\s+\S+|--unset=\S+|[A-Za-z_][A-Za-z0-9_]*=\S*))*\s+(?=\S)/;
|
|
1421
|
+
const FUNCTION_DEF = /^(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\s*\)\s*/;
|
|
1422
|
+
/**
|
|
1423
|
+
* Read one top-level segment: strip the shell grammar in front of its command
|
|
1424
|
+
* and say which binary it runs (or that it runs nothing).
|
|
1425
|
+
*/
|
|
1426
|
+
function readSegment(segment, state) {
|
|
1427
|
+
let p = segment.trim();
|
|
1428
|
+
let negated = false;
|
|
1429
|
+
for (let guard = 0; guard < 64; guard++) {
|
|
1430
|
+
if (!p) return { kind: "skip" };
|
|
1431
|
+
if (state.inCase) {
|
|
1432
|
+
if (/^esac(?=\s|$|;|\))/.test(p)) {
|
|
1433
|
+
state.inCase = false;
|
|
1434
|
+
p = p.slice(4).trim();
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
const arm = /^\(?[^\s()]*\)\s*/.exec(p);
|
|
1438
|
+
if (arm) {
|
|
1439
|
+
p = p.slice(arm[0].length);
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
if (/^[^\s()]+$/.test(p)) return { kind: "skip" };
|
|
1443
|
+
}
|
|
1444
|
+
const caseHead = /^case\s+\S+\s+in(?=\s|$)\s*/.exec(p);
|
|
1445
|
+
if (caseHead) {
|
|
1446
|
+
state.inCase = true;
|
|
1447
|
+
p = p.slice(caseHead[0].length);
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
if (/^for(?=\s|\(\()/.test(p)) return { kind: "skip" };
|
|
1451
|
+
if (CLOSING_WORDS.test(p)) return { kind: "skip" };
|
|
1452
|
+
const reserved = LEADING_RESERVED.exec(p);
|
|
1453
|
+
if (reserved) {
|
|
1454
|
+
p = p.slice(reserved[0].length);
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
if (p.startsWith("{") && (p.length === 1 || /\s/.test(p[1]))) {
|
|
1458
|
+
p = p.slice(1).trim();
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (p.startsWith("(") && !p.startsWith("((")) {
|
|
1462
|
+
p = p.slice(1).trim();
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
if (p.startsWith("((")) return { kind: "skip" };
|
|
1466
|
+
if (/^!(?=\s|$)/.test(p)) {
|
|
1467
|
+
negated = true;
|
|
1468
|
+
p = p.slice(1).trim();
|
|
1469
|
+
if (!p) return {
|
|
1470
|
+
kind: "invalid",
|
|
1471
|
+
reason: "a bare `!` negates nothing"
|
|
1472
|
+
};
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
const fn = FUNCTION_DEF.exec(p);
|
|
1476
|
+
if (fn) {
|
|
1477
|
+
state.functions.add(fn[1]);
|
|
1478
|
+
p = p.slice(fn[0].length);
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
const assignment = ASSIGNMENT.exec(p);
|
|
1482
|
+
if (assignment) {
|
|
1483
|
+
if (SCANNER_ENV_NAME.test(assignment[0])) return {
|
|
1484
|
+
kind: "invalid",
|
|
1485
|
+
reason: SCANNER_ENV_REFUSAL
|
|
1486
|
+
};
|
|
1487
|
+
p = p.slice(assignment[0].length);
|
|
1488
|
+
if (!p) return { kind: "skip" };
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
const env = ENV_WRAPPER.exec(p);
|
|
1492
|
+
if (env) {
|
|
1493
|
+
if (env[0].split(/\s+/).some((w) => w.includes("=") && SCANNER_ENV_NAME.test(w))) return {
|
|
1494
|
+
kind: "invalid",
|
|
1495
|
+
reason: SCANNER_ENV_REFUSAL
|
|
1496
|
+
};
|
|
1497
|
+
p = p.slice(env[0].length);
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
if (!p) return { kind: "skip" };
|
|
1503
|
+
const token = p.split(/\s+/)[0].replace(/\)+$/, "");
|
|
1504
|
+
if (!token) return { kind: "skip" };
|
|
1505
|
+
if (token === "export") {
|
|
1506
|
+
const args$1 = p.split(/\s+/).slice(1);
|
|
1507
|
+
if (!args$1.every((a) => /^[A-Za-z_][A-Za-z0-9_]*(?:=\S*)?$/.test(a))) return {
|
|
1508
|
+
kind: "invalid",
|
|
1509
|
+
reason: "`export` may only name or assign variables"
|
|
1510
|
+
};
|
|
1511
|
+
if (args$1.some((a) => SCANNER_ENV_NAME.test(a))) return {
|
|
1512
|
+
kind: "invalid",
|
|
1513
|
+
reason: SCANNER_ENV_REFUSAL
|
|
1514
|
+
};
|
|
1515
|
+
return { kind: "skip" };
|
|
1516
|
+
}
|
|
1517
|
+
const args = p.slice(p.split(/\s+/)[0].length).trim();
|
|
1518
|
+
if (token === "timeout") {
|
|
1519
|
+
const wrapped = timeoutCommand(args);
|
|
1520
|
+
if (wrapped === null) return {
|
|
1521
|
+
kind: "invalid",
|
|
1522
|
+
reason: "`timeout` must name the command it runs"
|
|
1523
|
+
};
|
|
1524
|
+
const inner = readSegment(wrapped, state);
|
|
1525
|
+
return inner.kind === "command" ? {
|
|
1526
|
+
...inner,
|
|
1527
|
+
negated
|
|
1528
|
+
} : inner;
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
kind: "command",
|
|
1532
|
+
token,
|
|
1533
|
+
negated,
|
|
1534
|
+
args
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* The command `timeout` runs: what follows its options and its DURATION
|
|
1539
|
+
* (GNU and BSD `timeout`: `-s SIG`, `-k DUR`, `--signal=SIG`,
|
|
1540
|
+
* `--kill-after=DUR`, `--preserve-status`, `--foreground`, `-v`), or null
|
|
1541
|
+
* when there is none.
|
|
1542
|
+
*/
|
|
1543
|
+
function timeoutCommand(args) {
|
|
1544
|
+
const words = args.split(/\s+/).filter(Boolean);
|
|
1545
|
+
let k = 0;
|
|
1546
|
+
while (k < words.length) {
|
|
1547
|
+
const w = words[k];
|
|
1548
|
+
if (w === "--") {
|
|
1549
|
+
k++;
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
if (w === "-s" || w === "-k" || w === "--signal" || w === "--kill-after") {
|
|
1553
|
+
k += 2;
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
if (w.startsWith("-")) {
|
|
1557
|
+
k++;
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
const rest = words.slice(k + 1);
|
|
1563
|
+
return rest.length > 0 ? rest.join(" ") : null;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Validates that a gate command is well-constructed. Returns null when it may
|
|
1567
|
+
* be handed out and run, or the reason it may not.
|
|
1568
|
+
*/
|
|
1569
|
+
function validateGateCommand(command, technique) {
|
|
1570
|
+
const who = technique ? `${technique} command` : "Gate command";
|
|
1571
|
+
const trimmed = command.trim();
|
|
1572
|
+
if (trimmed.startsWith("@api:")) return null;
|
|
1573
|
+
if (!trimmed) return null;
|
|
1574
|
+
const unmodelled = unmodelledConstruct(trimmed);
|
|
1575
|
+
if (unmodelled) return `${who} uses ${unmodelled}, which this checker cannot read the way bash does; rewrite it without`;
|
|
1576
|
+
const scanned = scanShellCommand(trimmed);
|
|
1577
|
+
if (scanned.backtick) return `${who} uses a backtick command substitution; write $( … ) so its command can be checked`;
|
|
1578
|
+
if (scanned.unbalanced) return `${who} has an unterminated quote or substitution`;
|
|
1579
|
+
const scannerRefusal = scannerCommandRefusal(trimmed);
|
|
1580
|
+
if (scannerRefusal) return `${who}: ${scannerRefusal}`;
|
|
1581
|
+
const state = {
|
|
1582
|
+
inCase: false,
|
|
1583
|
+
functions: /* @__PURE__ */ new Set()
|
|
1584
|
+
};
|
|
1585
|
+
const reads = scanned.segments.map((segment) => segment.text.trim().startsWith("@api:") ? { kind: "skip" } : readSegment(segment.text, state));
|
|
1586
|
+
for (let i = 0; i < reads.length; i++) {
|
|
1587
|
+
const read = reads[i];
|
|
1588
|
+
if (read.kind === "skip") continue;
|
|
1589
|
+
if (read.kind === "invalid") return `${who}: ${read.reason}`;
|
|
1590
|
+
const { token, negated } = read;
|
|
1591
|
+
const refusedUse = subcommandRefusal(token, read.args);
|
|
1592
|
+
if (refusedUse !== null) return `${who}: ${refusedUse}`;
|
|
1593
|
+
if (!isAllowedBinary(token) && !SUBCOMMAND_ALLOWLISTS.has(token) && !state.functions.has(token)) return `${who} uses disallowed binary "${token}". Allowed: ${GATE_COMMAND_BINARIES.join(", ")}; and with read-only subcommands only: ${[...SUBCOMMAND_ALLOWLISTS.keys()].join(", ")}`;
|
|
1594
|
+
if (negated) {
|
|
1595
|
+
let last = i;
|
|
1596
|
+
while (scanned.segments[last].sepAfter === "|" && last + 1 < reads.length) last++;
|
|
1597
|
+
const tail = reads[last];
|
|
1598
|
+
const tailToken = tail.kind === "command" ? tail.token : token;
|
|
1599
|
+
if (!NEGATABLE_BINARIES.has(tailToken) && !tailToken.endsWith("/grep")) return `${who} negates "${tailToken}" with \`!\`; only grep and test may be negated — a negated test runner or typecheck turns a failure into exit 0`;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
for (const inner of scanned.substitutions) {
|
|
1603
|
+
if (!inner.trim()) return `${who} has an empty substitution`;
|
|
1604
|
+
const problem = validateGateCommand(inner, technique);
|
|
1605
|
+
if (problem) return `${problem} (inside a $( ), <( ) or >( ) substitution)`;
|
|
1606
|
+
}
|
|
1607
|
+
return null;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
//#endregion
|
|
1611
|
+
//#region ../shared/dist/runs/gate-verdict.js
|
|
1612
|
+
/**
|
|
1613
|
+
* Wrapper commands that run the command after them — ENG-5967 review R3-03.
|
|
1614
|
+
* `time yarn test`, `env CI=1 yarn test`, `nice -n 5 yarn test`,
|
|
1615
|
+
* `exec yarn test`, `timeout 60 yarn test`, `… | xargs yarn test` all RUN
|
|
1616
|
+
* `yarn test`; matched before stripping, a runner behind one was plain shell
|
|
1617
|
+
* output and a run that ran nothing passed.
|
|
1618
|
+
*/
|
|
1619
|
+
const WRAPPER_PREFIX_RE = /* @__PURE__ */ new RegExp("^(?:" + [
|
|
1620
|
+
String.raw`timeout(?:\s+(?:-[sk]\s*\S+|--\S+|-[A-Za-z]+))*(?:\s+\d+(?:\.\d+)?[smhd]?)?`,
|
|
1621
|
+
String.raw`time(?:\s+-p)?`,
|
|
1622
|
+
String.raw`(?:(?:\/usr)?\/bin\/)?env(?:\s+(?:-i|-u\s*\S+|--ignore-environment|--unset=\S+|-))*`,
|
|
1623
|
+
String.raw`command`,
|
|
1624
|
+
String.raw`stdbuf(?:\s+-[ioe]\s*\S+)*`,
|
|
1625
|
+
String.raw`nice(?:\s+(?:-n\s*-?\d+|-\d+|--adjustment=-?\d+))?`,
|
|
1626
|
+
String.raw`exec(?:\s+-[cl]+)*`,
|
|
1627
|
+
String.raw`nohup`,
|
|
1628
|
+
String.raw`xargs(?:\s+(?:-[ILnPsdE]\s*\S+|--\S+|-[A-Za-z0-9]+))*`
|
|
1629
|
+
].join("|") + String.raw`)\s+`);
|
|
1630
|
+
/**
|
|
1631
|
+
* Any `test*` script counts, not a fixed alternation — `test:behavioral`,
|
|
1632
|
+
* `test:integration`, `test:cov` and future names must all be authoritative.
|
|
1633
|
+
* Also covers the workspace-dispatch forms (`yarn workspace <pkg> <script>`,
|
|
1634
|
+
* `yarn --cwd <dir> <script>`) and the npm/npx equivalents.
|
|
1635
|
+
*/
|
|
1636
|
+
const TEST_RUNNER_RE = new RegExp([
|
|
1637
|
+
String.raw`^yarn(?:\s+(?:-s|--silent|--verbose|--offline|--frozen-lockfile|--immutable|workspace\s+\S+|--cwd\s+\S+))*\s+(?:run\s+)?(?:test(?::[\w-]+)?\b|vitest\b|jest\b)`,
|
|
1638
|
+
String.raw`^yarn\s+workspaces\s+foreach(?:\s+(?:--(?:include|exclude|from|jobs)(?:=|\s+)\S+|-j\s+\S+|-[A-Za-z]+|--[\w-]+(?:=\S+)?))*\s+(?:run\s+)?test(?::[\w-]+)?\b`,
|
|
1639
|
+
String.raw`^npm\s+(?:run\s+)?test(?::[\w-]+)?\b`,
|
|
1640
|
+
String.raw`^pnpm\s+(?:(?:--filter|-F|--dir|-C)\s+\S+\s+|(?:-r|--recursive)\s+)*(?:run\s+)?test(?::[\w-]+)?\b`,
|
|
1641
|
+
String.raw`^pnpm\s+(?:exec\s+|dlx\s+)?(?:vitest|jest|playwright)\b`,
|
|
1642
|
+
String.raw`^npx\s+(?:(?:--yes|-y|--no-install|--no|--quiet|-q|-p\s+\S+|--package(?:=|\s+)\S+)\s+)*(?:vitest|jest|playwright)\b`,
|
|
1643
|
+
String.raw`^node\s+(?:-\S+\s+)*\S*\b(?:vitest|jest|playwright|mocha|_mocha)\b\S*(?:\s|$)`,
|
|
1644
|
+
String.raw`^(?:(?:\.\/)?node_modules\/\.bin\/)?(?:vitest|jest|playwright)(?:\s|$)`,
|
|
1645
|
+
String.raw`^(?:php\s+(?:-d\s*\S+\s+)*)?(?:\.\/)?(?:vendor\/bin\/|bin\/)?(?:phpunit|pest|paratest)(?:\s|$)`,
|
|
1646
|
+
String.raw`^php\s+artisan\s+test(?:\s|$)`,
|
|
1647
|
+
String.raw`^composer\s+(?:run(?:-script)?\s+)?test(?:[:-][\w-]+)?(?:\s|$)`,
|
|
1648
|
+
String.raw`^(?:(?:uv|poetry|pipenv|hatch)\s+run\s+)?(?:python[\d.]*\s+-m\s+)?(?:py\.test|pytest)(?:\s|$)`,
|
|
1649
|
+
String.raw`^go\s+test(?:\s|$)`,
|
|
1650
|
+
String.raw`^cargo\s+(?:\+\S+\s+)?(?:test|nextest)(?:\s|$)`,
|
|
1651
|
+
String.raw`^(?:\.\/)?mvnw?(?:\s+\S+)*?\s+(?:test|verify|integration-test)(?:\s|$)`,
|
|
1652
|
+
String.raw`^(?:\.\/)?gradlew?(?:\s+\S+)*?\s+(?:\S*:)?(?:test|check)(?:\s|$)`,
|
|
1653
|
+
String.raw`^dotnet\s+test(?:\s|$)`,
|
|
1654
|
+
String.raw`^(?:bundle\s+exec\s+)?(?:bin\/)?rspec(?:\s|$)`,
|
|
1655
|
+
String.raw`^(?:npx\s+(?:(?:--yes|-y|--no-install|--no|-p\s+\S+|--package(?:=|\s+)\S+)\s+)*|yarn\s+(?:run\s+)?|pnpm\s+(?:exec\s+)?|(?:\.\/)?node_modules\/\.bin\/)?mocha(?:\s|$)`,
|
|
1656
|
+
String.raw`^yarn\s+playwright\s+test(?:\s|$)`
|
|
1657
|
+
].join("|"));
|
|
1658
|
+
/**
|
|
1659
|
+
* The CLIs whose answer IS a gate's verdict. One list feeds both regexes
|
|
1660
|
+
* below, so a judge recognised on the non-zero branch is also read on exit 0.
|
|
1661
|
+
* ENG-7698: grok was missing, so a grok `{"result": "pass", …}` whose reason
|
|
1662
|
+
* said "fail closed" went to the generic shell scan and recorded a fail.
|
|
1663
|
+
*/
|
|
1664
|
+
const JUDGE_BINARIES = "agy|codex|gemini|grok";
|
|
1665
|
+
/**
|
|
1666
|
+
* Semantic judges, matched anywhere in the segment — L4 templates invoke them
|
|
1667
|
+
* after a pipe (`echo "..." | agy -p`) or inside a wrapper
|
|
1668
|
+
* (`timeout 120 bash -c '... | agy ...'`), so an anchored test would miss them.
|
|
1669
|
+
*/
|
|
1670
|
+
const SEMANTIC_RUNNER_RE = /* @__PURE__ */ new RegExp(`(?:^|[\\s|;&(])(?:${JUDGE_BINARIES})(?:\\s|$)`);
|
|
1671
|
+
/**
|
|
1672
|
+
* A judge INVOKED in command position — `agy …`, `| agy …`, `$(agy …`,
|
|
1673
|
+
* `timeout 120 agy …`, `bash -c '… $(agy …) …'` — ENG-5967 B.
|
|
1674
|
+
*
|
|
1675
|
+
* Narrower than `isSemanticVerdictAuthoritative` on purpose. That one decides
|
|
1676
|
+
* what a NON-zero exit means, where a false positive only costs a soft-pass
|
|
1677
|
+
* nobody should have had. This one decides that an EXIT-0 step's verdict is
|
|
1678
|
+
* the judge's stated answer, overriding the expectation — so `grep gemini
|
|
1679
|
+
* src/x.ts`, which mentions a judge without running one, must not match.
|
|
1680
|
+
*
|
|
1681
|
+
* Review R3-07: decided on the command's STRUCTURE, never on its text. A
|
|
1682
|
+
* regex over the raw string matched `grep -Fc '$(agy --model' template.ts`
|
|
1683
|
+
* — single-quoted, so `$(` is literal and nothing runs — and a green
|
|
1684
|
+
* presence check on a judge template read `SEMANTIC_UNKNOWN`. Every command
|
|
1685
|
+
* the shell would run (each list command and pipe stage, every `$( … )` /
|
|
1686
|
+
* backtick body outside single quotes, the script of `bash|sh -c`, behind
|
|
1687
|
+
* any wrapper) is a candidate; a judge is one whose FIRST word is the binary.
|
|
1688
|
+
*/
|
|
1689
|
+
const JUDGE_COMMAND_RE = /* @__PURE__ */ new RegExp(`^(?:${JUDGE_BINARIES})(?:\\s|$)`);
|
|
1690
|
+
|
|
1691
|
+
//#endregion
|
|
1692
|
+
//#region ../shared/dist/runs/gate-binding-config.js
|
|
1693
|
+
/**
|
|
1694
|
+
* Gate binding config validation (ENG-7284 D4). Pure: no framework imports.
|
|
1695
|
+
*
|
|
1696
|
+
* The one validator for a `gate_binding.config`, shared by the server
|
|
1697
|
+
* (`GateBindingService`, which turns a `GateBindingConfigError` into a 400)
|
|
1698
|
+
* and by local tooling that writes bindings (the D7 apply script), so a config
|
|
1699
|
+
* refused by one is refused by the other with the same message.
|
|
1700
|
+
*
|
|
1701
|
+
* A config is a plain object. Three roots are read:
|
|
1702
|
+
*
|
|
1703
|
+
* - `cmd` and `impl`: slot trees that bottom out in a non-empty command
|
|
1704
|
+
* string, an argv leaf `{ cwd, argv }`, a `{ legacy }` leaf, or a
|
|
1705
|
+
* path-prefix map of those.
|
|
1706
|
+
* - `params`: per-gate-type param defaults, `params.<gate_key>.<param>`, with
|
|
1707
|
+
* string leaves only (e.g. `params.ast_pattern.language: "Rust"`). Only the
|
|
1708
|
+
* params a resolver reads (BINDING_PARAM_DEFAULTS) are accepted, and, when
|
|
1709
|
+
* the binding's gate key is known, only under that key: the resolver reads
|
|
1710
|
+
* `params.<gate_key>` from that gate type's own bindings and nowhere else.
|
|
1711
|
+
*
|
|
1712
|
+
* Any other top-level key — a typo (`cdm`), or the flat `cmd.typecheck` form —
|
|
1713
|
+
* is refused (review M4). It would be stored, never read, and its commands
|
|
1714
|
+
* would bypass every check.
|
|
1715
|
+
*
|
|
1716
|
+
* A wrong shape here is a gate that quietly resolves `unconfigured` forever
|
|
1717
|
+
* with nothing pointing at the typo, so it is refused at write.
|
|
1718
|
+
*/
|
|
1719
|
+
/**
|
|
1720
|
+
* The binding param defaults each RESOLVER reads, from
|
|
1721
|
+
* `params.<the binding's gate key>`. A param not listed here would be stored
|
|
1722
|
+
* and ignored, so it is refused at write.
|
|
1723
|
+
*/
|
|
1724
|
+
const BINDING_PARAM_DEFAULTS = {
|
|
1725
|
+
ast_pattern: ["language"],
|
|
1726
|
+
symbol_referenced: ["language"]
|
|
1727
|
+
};
|
|
1728
|
+
const CONFIG_ROOTS = new Set([
|
|
1729
|
+
"cmd",
|
|
1730
|
+
"impl",
|
|
1731
|
+
"params"
|
|
1732
|
+
]);
|
|
1733
|
+
/** A malformed binding config. `path` names the offending node. */
|
|
1734
|
+
var GateBindingConfigError = class extends Error {
|
|
1735
|
+
path;
|
|
1736
|
+
constructor(message, path$2 = null) {
|
|
1737
|
+
super(message);
|
|
1738
|
+
this.name = "GateBindingConfigError";
|
|
1739
|
+
this.path = path$2;
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
/** An object with an `argv` array is one slot leaf, not a path-prefix map. */
|
|
1743
|
+
function isArgvLeaf(value) {
|
|
1744
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1745
|
+
return Array.isArray(value.argv);
|
|
1746
|
+
}
|
|
1747
|
+
/** `{ legacy: command }` is one slot leaf. */
|
|
1748
|
+
function isLegacyLeaf(value) {
|
|
1749
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1750
|
+
return typeof value.legacy === "string";
|
|
1751
|
+
}
|
|
1752
|
+
function isPlainObject$1(value) {
|
|
1753
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Throws `GateBindingConfigError` when `config` is not a well-formed gate
|
|
1757
|
+
* binding config. `null`/`undefined` is "no config" and passes.
|
|
1758
|
+
*/
|
|
1759
|
+
function assertWellFormedGateBindingConfig(config, gateKey, resolver) {
|
|
1760
|
+
if (config == null) return;
|
|
1761
|
+
if (!isPlainObject$1(config)) throw new GateBindingConfigError("config must be a JSON object.");
|
|
1762
|
+
for (const root of Object.keys(config)) {
|
|
1763
|
+
if (CONFIG_ROOTS.has(root)) continue;
|
|
1764
|
+
const dotted = /^(cmd|impl|params)\./.exec(root);
|
|
1765
|
+
throw new GateBindingConfigError(dotted ? `config key "${root}" is the flat form; nest it instead ({ "${dotted[1]}": { ... } }). A flat key is never read.` : `config key "${root}" is not a binding root; use cmd, impl or params.`, root);
|
|
1766
|
+
}
|
|
1767
|
+
for (const slotRoot of ["cmd", "impl"]) {
|
|
1768
|
+
const tree = config[slotRoot];
|
|
1769
|
+
if (tree === void 0) continue;
|
|
1770
|
+
assertSlotTree(tree, slotRoot);
|
|
1771
|
+
}
|
|
1772
|
+
if (config.params !== void 0) assertParamsRoot(config.params, gateKey, resolver);
|
|
1773
|
+
}
|
|
1774
|
+
function assertSlotTree(node, pathSoFar) {
|
|
1775
|
+
if (typeof node === "string") {
|
|
1776
|
+
if (node.trim().length === 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" is a blank string — an empty command is treated as unbound; omit it instead.`, pathSoFar);
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (isArgvLeaf(node)) {
|
|
1780
|
+
assertArgvLeaf(node, pathSoFar);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (isLegacyLeaf(node)) {
|
|
1784
|
+
assertLegacyLeaf(node, pathSoFar);
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
if (isPlainObject$1(node)) {
|
|
1788
|
+
for (const [key, child] of Object.entries(node)) {
|
|
1789
|
+
if (key === "argv" || key === "cwd") throw new GateBindingConfigError(`config slot "${pathSoFar}.${key}" uses "${key}" as a path-prefix key. Those names are argv-leaf fields; put the command in { cwd, argv }.`, `${pathSoFar}.${key}`);
|
|
1790
|
+
assertSlotTree(child, `${pathSoFar}.${key}`);
|
|
1791
|
+
}
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
throw new GateBindingConfigError(`config slot "${pathSoFar}" must be a command string, an argv leaf { cwd, argv }, a { legacy } command, or a nested object of them (got ${Array.isArray(node) ? "array" : typeof node}).`, pathSoFar);
|
|
1795
|
+
}
|
|
1796
|
+
function assertArgvLeaf(node, pathSoFar) {
|
|
1797
|
+
const extra = Object.keys(node).filter((key) => key !== "cwd" && key !== "argv");
|
|
1798
|
+
if (extra.length > 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" is an argv leaf with extra keys (${extra.join(", ")}). An argv leaf is only { cwd, argv }.`, pathSoFar);
|
|
1799
|
+
if (node.argv.length === 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" has an empty argv — an empty command is treated as unbound; omit it instead.`, pathSoFar);
|
|
1800
|
+
if (node.argv.some((element) => typeof element !== "string" || element.length === 0)) throw new GateBindingConfigError(`config slot "${pathSoFar}" argv must be non-empty strings.`, pathSoFar);
|
|
1801
|
+
if (node.cwd !== void 0 && (typeof node.cwd !== "string" || node.cwd.length === 0)) throw new GateBindingConfigError(`config slot "${pathSoFar}" cwd must be a non-empty string.`, pathSoFar);
|
|
1802
|
+
}
|
|
1803
|
+
function assertLegacyLeaf(node, pathSoFar) {
|
|
1804
|
+
const extra = Object.keys(node).filter((key) => key !== "legacy");
|
|
1805
|
+
if (extra.length > 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" is a legacy leaf with extra keys (${extra.join(", ")}).`, pathSoFar);
|
|
1806
|
+
if (node.legacy.trim().length === 0) throw new GateBindingConfigError(`config slot "${pathSoFar}" is a blank legacy command — an empty command is treated as unbound; omit it instead.`, pathSoFar);
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* `params.<gate_key>.<param>`: exactly two levels of objects, then a
|
|
1810
|
+
* non-blank string. No slot maps and no argv leaves — a param default is a
|
|
1811
|
+
* value the resolver substitutes, never a command.
|
|
1812
|
+
*/
|
|
1813
|
+
function assertParamsRoot(node, bindingGateKey, resolver) {
|
|
1814
|
+
if (!isPlainObject$1(node)) throw new GateBindingConfigError(`config "params" must be an object of gate keys (got ${Array.isArray(node) ? "array" : typeof node}).`, "params");
|
|
1815
|
+
for (const [gateKey, perGate] of Object.entries(node)) {
|
|
1816
|
+
const gatePath = `params.${gateKey}`;
|
|
1817
|
+
if (bindingGateKey !== void 0 && gateKey !== bindingGateKey) throw new GateBindingConfigError(`config "${gatePath}" is on a "${bindingGateKey}" binding; params for "${gateKey}" are read only from a "${gateKey}" binding.`, gatePath);
|
|
1818
|
+
const readable = resolver === void 0 ? BINDING_PARAM_DEFAULTS[gateKey] : resolver ? BINDING_PARAM_DEFAULTS[resolver] : void 0;
|
|
1819
|
+
if (!readable) throw new GateBindingConfigError(`config "${gatePath}": "${gateKey}" reads no binding params (resolvers that do: ${Object.keys(BINDING_PARAM_DEFAULTS).join(", ")}).`, gatePath);
|
|
1820
|
+
if (!isPlainObject$1(perGate)) throw new GateBindingConfigError(`config "${gatePath}" must be an object of param defaults (got ${Array.isArray(perGate) ? "array" : typeof perGate}).`, gatePath);
|
|
1821
|
+
for (const [param, value] of Object.entries(perGate)) {
|
|
1822
|
+
const paramPath = `${gatePath}.${param}`;
|
|
1823
|
+
if (!readable.includes(param)) throw new GateBindingConfigError(`config "${paramPath}": "${gateKey}" reads only ${readable.join(", ")} from a binding.`, paramPath);
|
|
1824
|
+
if (typeof value !== "string") throw new GateBindingConfigError(`config "${paramPath}" must be a string (got ${Array.isArray(value) ? "array" : value === null ? "null" : typeof value}).`, paramPath);
|
|
1825
|
+
if (value.trim().length === 0) throw new GateBindingConfigError(`config "${paramPath}" is a blank string; omit it instead.`, paramPath);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
//#endregion
|
|
1831
|
+
//#region ../shared/dist/runs/binding-command-migration.js
|
|
1832
|
+
/** An object with an `argv` array is one slot leaf, not a path-prefix map. */
|
|
1833
|
+
function isArgvSlotValue(value) {
|
|
1834
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1835
|
+
return Array.isArray(value.argv);
|
|
1836
|
+
}
|
|
1837
|
+
/** `{ legacy: command }` is one slot leaf. */
|
|
1838
|
+
function isLegacySlotValue(value) {
|
|
1839
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1840
|
+
return typeof value.legacy === "string";
|
|
1841
|
+
}
|
|
1842
|
+
/** Shell text of a resolved slot, placeholders left unquoted. */
|
|
1843
|
+
function resolvedSlotText(slot) {
|
|
1844
|
+
if (slot.kind === "legacy") return slot.command ?? "";
|
|
1845
|
+
const argv = (slot.argv ?? []).join(" ");
|
|
1846
|
+
if (!slot.cwd || slot.cwd === ".") return argv;
|
|
1847
|
+
return `cd ${slot.cwd} && ${argv}`;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
//#endregion
|
|
1851
|
+
//#region ../shared/dist/runs/gate-command-authoring.js
|
|
1852
|
+
/**
|
|
1853
|
+
* Write-time validation of a rendered gate command (ENG-6176).
|
|
1854
|
+
*
|
|
1855
|
+
* Pure domain: no NestJS and no database. Callers turn a refusal into a
|
|
1856
|
+
* 400 `GATE_COMMAND_REFUSED` and write nothing.
|
|
1857
|
+
*
|
|
1858
|
+
* `fail_closed`: a command the validator refuses is never written, deployed,
|
|
1859
|
+
* or issued. An unknown placeholder type gets a generic sample and is
|
|
1860
|
+
* validated. It is never skipped.
|
|
1861
|
+
*/
|
|
1862
|
+
const GATE_COMMAND_REFUSED = "GATE_COMMAND_REFUSED";
|
|
1863
|
+
const PLACEHOLDER = /\{([^{}]+)\}/g;
|
|
1864
|
+
/**
|
|
1865
|
+
* One sample per `{placeholder}` in `template`.
|
|
1866
|
+
*
|
|
1867
|
+
* Named samples win over the declared type: `{workspace_dir}` is
|
|
1868
|
+
* `apps/sample` and `{test_file}` is `src/sample.spec.ts`. A declared
|
|
1869
|
+
* `path` is `src/sample.ts` and a declared `string` is `sample`. Anything
|
|
1870
|
+
* else, including a placeholder with no declaration, is the generic
|
|
1871
|
+
* `sample` (`fail_closed`).
|
|
1872
|
+
*/
|
|
1873
|
+
function samplePlaceholderValues(template, paramSchema = {}) {
|
|
1874
|
+
const values = {};
|
|
1875
|
+
for (const match of template.matchAll(PLACEHOLDER)) {
|
|
1876
|
+
const name = match[1];
|
|
1877
|
+
if (!name || values[name] !== void 0) continue;
|
|
1878
|
+
values[name] = sampleFor(name, paramSchema[name]?.type);
|
|
1879
|
+
}
|
|
1880
|
+
return values;
|
|
1881
|
+
}
|
|
1882
|
+
function sampleFor(name, declaredType) {
|
|
1883
|
+
if (name === "workspace_dir") return "apps/sample";
|
|
1884
|
+
if (name === "test_file") return "src/sample.spec.ts";
|
|
1885
|
+
if (declaredType === "path") return "src/sample.ts";
|
|
1886
|
+
if (declaredType === "string") return "sample";
|
|
1887
|
+
return "sample";
|
|
1888
|
+
}
|
|
1889
|
+
function renderGateCommandTemplate(template, values) {
|
|
1890
|
+
return template.replace(PLACEHOLDER, (whole, name) => Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole);
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Every command under `config.cmd` and `config.impl`.
|
|
1894
|
+
*
|
|
1895
|
+
* A path-prefix map is a command per prefix. An argv leaf is one command:
|
|
1896
|
+
* its unquoted `cd <cwd> && argv` text, so the allowlist sees the same
|
|
1897
|
+
* shape a string slot used to store. A `{ legacy }` leaf is that string.
|
|
1898
|
+
* The slot key is the path (`cmd.typecheck`, or `cmd.test.unit.scripts/`
|
|
1899
|
+
* for a map). `sandbox_policy` is not a command template.
|
|
1900
|
+
*/
|
|
1901
|
+
function commandSlotsInBindingConfig(config) {
|
|
1902
|
+
if (config == null || typeof config !== "object" || Array.isArray(config)) return [];
|
|
1903
|
+
const record = config;
|
|
1904
|
+
const slots = [];
|
|
1905
|
+
collectCommandStrings(record.cmd, "cmd", slots);
|
|
1906
|
+
collectCommandStrings(record.impl, "impl", slots);
|
|
1907
|
+
return slots;
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* `resolver_template.template`, when the payload sets one.
|
|
1911
|
+
* Slot key is `resolver_template.template`. Declared param types drive samples.
|
|
1912
|
+
*/
|
|
1913
|
+
function commandSlotInResolverTemplate(resolverTemplate) {
|
|
1914
|
+
if (resolverTemplate == null || typeof resolverTemplate !== "object" || Array.isArray(resolverTemplate)) return null;
|
|
1915
|
+
const record = resolverTemplate;
|
|
1916
|
+
if (typeof record.template !== "string") return null;
|
|
1917
|
+
const template = record.template.trim();
|
|
1918
|
+
if (template.length === 0) return null;
|
|
1919
|
+
return {
|
|
1920
|
+
slotKey: "resolver_template.template",
|
|
1921
|
+
template,
|
|
1922
|
+
paramSchema: paramSchemaOf(record.param_schema)
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Every command a `resolver_template` carries: its shell `template` and an
|
|
1927
|
+
* argv `op_template` (ENG-6180 review). The op_template is what the renderer
|
|
1928
|
+
* issues, so it is validated at write time exactly like the template string,
|
|
1929
|
+
* as its unquoted `cd <cwd> && argv` text. Slot keys are
|
|
1930
|
+
* `resolver_template.template` and `resolver_template.op_template`.
|
|
1931
|
+
*/
|
|
1932
|
+
function commandSlotsInResolverTemplate(resolverTemplate) {
|
|
1933
|
+
const slots = [];
|
|
1934
|
+
const templateSlot = commandSlotInResolverTemplate(resolverTemplate);
|
|
1935
|
+
if (templateSlot) slots.push(templateSlot);
|
|
1936
|
+
if (resolverTemplate == null || typeof resolverTemplate !== "object" || Array.isArray(resolverTemplate)) return slots;
|
|
1937
|
+
const record = resolverTemplate;
|
|
1938
|
+
const op = record.op_template;
|
|
1939
|
+
if (op == null || typeof op !== "object" || Array.isArray(op)) return slots;
|
|
1940
|
+
const opRecord = op;
|
|
1941
|
+
if (opRecord.op !== "argv") return slots;
|
|
1942
|
+
const argv = opRecord.argv;
|
|
1943
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.some((element) => typeof element !== "string" || element.length === 0)) {
|
|
1944
|
+
slots.push({
|
|
1945
|
+
slotKey: "resolver_template.op_template",
|
|
1946
|
+
template: "",
|
|
1947
|
+
paramSchema: paramSchemaOf(record.param_schema)
|
|
1948
|
+
});
|
|
1949
|
+
return slots;
|
|
1950
|
+
}
|
|
1951
|
+
const cwdRaw = opRecord.cwd;
|
|
1952
|
+
const cwd = typeof cwdRaw === "string" && cwdRaw.length > 0 ? cwdRaw : ".";
|
|
1953
|
+
slots.push({
|
|
1954
|
+
slotKey: "resolver_template.op_template",
|
|
1955
|
+
template: resolvedSlotText({
|
|
1956
|
+
kind: "argv",
|
|
1957
|
+
cwd,
|
|
1958
|
+
argv
|
|
1959
|
+
}),
|
|
1960
|
+
paramSchema: paramSchemaOf(record.param_schema)
|
|
1961
|
+
});
|
|
1962
|
+
return slots;
|
|
1963
|
+
}
|
|
1964
|
+
function collectCommandStrings(value, path$2, slots) {
|
|
1965
|
+
if (typeof value === "string") {
|
|
1966
|
+
const template = value.trim();
|
|
1967
|
+
if (template.length > 0) slots.push({
|
|
1968
|
+
slotKey: path$2,
|
|
1969
|
+
template
|
|
1970
|
+
});
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
if (isArgvSlotValue(value)) {
|
|
1974
|
+
if (value.argv.length === 0 || value.argv.some((element) => typeof element !== "string")) return;
|
|
1975
|
+
const cwdRaw = value.cwd;
|
|
1976
|
+
const template = resolvedSlotText({
|
|
1977
|
+
kind: "argv",
|
|
1978
|
+
cwd: typeof cwdRaw === "string" && cwdRaw.length > 0 ? cwdRaw : ".",
|
|
1979
|
+
argv: value.argv
|
|
1980
|
+
});
|
|
1981
|
+
if (template.trim().length > 0) slots.push({
|
|
1982
|
+
slotKey: path$2,
|
|
1983
|
+
template
|
|
1984
|
+
});
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
if (isLegacySlotValue(value)) {
|
|
1988
|
+
if (value.legacy.trim().length > 0) slots.push({
|
|
1989
|
+
slotKey: path$2,
|
|
1990
|
+
template: value.legacy
|
|
1991
|
+
});
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
if (value != null && typeof value === "object" && !Array.isArray(value)) for (const [key, child] of Object.entries(value)) collectCommandStrings(child, `${path$2}.${key}`, slots);
|
|
1995
|
+
}
|
|
1996
|
+
function paramSchemaOf(value) {
|
|
1997
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return;
|
|
1998
|
+
const schema = {};
|
|
1999
|
+
for (const [key, decl] of Object.entries(value)) if (decl != null && typeof decl === "object" && !Array.isArray(decl)) {
|
|
2000
|
+
const type = decl.type;
|
|
2001
|
+
schema[key] = { type: typeof type === "string" ? type : void 0 };
|
|
2002
|
+
}
|
|
2003
|
+
return schema;
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Render `template` with real values when they were given, otherwise with
|
|
2007
|
+
* typed samples, then run `validate`. A refusal names the slot, the rendered
|
|
2008
|
+
* command, and the validator's reason.
|
|
2009
|
+
*/
|
|
2010
|
+
function validateRenderedSlotCommand(input) {
|
|
2011
|
+
const values = input.paramValues ?? samplePlaceholderValues(input.template, input.paramSchema ?? {});
|
|
2012
|
+
const rendered = renderGateCommandTemplate(input.template, values);
|
|
2013
|
+
const reason = rendered.trim().length === 0 ? "fail_closed: the command is empty" : input.validate(rendered);
|
|
2014
|
+
if (reason == null) return {
|
|
2015
|
+
ok: true,
|
|
2016
|
+
rendered
|
|
2017
|
+
};
|
|
2018
|
+
return {
|
|
2019
|
+
ok: false,
|
|
2020
|
+
code: GATE_COMMAND_REFUSED,
|
|
2021
|
+
rendered,
|
|
2022
|
+
reason: `${GATE_COMMAND_REFUSED}: slot ${input.slotKey} rendered \`${rendered}\`: ${reason}`
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
//#endregion
|
|
2027
|
+
//#region ../shared/dist/runs/verification-technique.js
|
|
2028
|
+
/**
|
|
2029
|
+
* The six techniques, in DISPLAY order. The order is not a ranking: a
|
|
2030
|
+
* Semantic check is not "deeper" than a Presence check, and no code may treat
|
|
2031
|
+
* a later entry as stronger than an earlier one.
|
|
2032
|
+
*/
|
|
2033
|
+
const VERIFICATION_TECHNIQUES = [
|
|
2034
|
+
"presence",
|
|
2035
|
+
"pattern",
|
|
2036
|
+
"structure",
|
|
2037
|
+
"semantic",
|
|
2038
|
+
"execution",
|
|
2039
|
+
"state"
|
|
2040
|
+
];
|
|
2041
|
+
const VerificationTechniqueSchema = z.enum(VERIFICATION_TECHNIQUES);
|
|
2042
|
+
function isVerificationTechnique(value) {
|
|
2043
|
+
return typeof value === "string" && VERIFICATION_TECHNIQUES.includes(value);
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Profiles as explicit technique sets. `full` is the only profile that
|
|
2047
|
+
* verifies everything; `quick` and `standard` are fix-loop aids. A gate's
|
|
2048
|
+
* profile follows its DEFINITION's technique, never a step's label.
|
|
2049
|
+
*/
|
|
2050
|
+
const PROFILE_TECHNIQUES = {
|
|
2051
|
+
quick: new Set([
|
|
2052
|
+
"presence",
|
|
2053
|
+
"pattern",
|
|
2054
|
+
"state"
|
|
2055
|
+
]),
|
|
2056
|
+
standard: new Set([
|
|
2057
|
+
"presence",
|
|
2058
|
+
"pattern",
|
|
2059
|
+
"state",
|
|
2060
|
+
"structure"
|
|
2061
|
+
]),
|
|
2062
|
+
full: new Set(VERIFICATION_TECHNIQUES)
|
|
2063
|
+
};
|
|
2064
|
+
|
|
2065
|
+
//#endregion
|
|
2066
|
+
//#region ../shared/dist/runs/gate-bindings-payload.js
|
|
2067
|
+
/** A file, binding, config or command the import refuses. */
|
|
2068
|
+
var PayloadError = class extends Error {
|
|
2069
|
+
constructor(message) {
|
|
2070
|
+
super(message);
|
|
2071
|
+
this.name = "PayloadError";
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2074
|
+
const TOP_KEYS = new Set([
|
|
2075
|
+
"description",
|
|
2076
|
+
"definitions",
|
|
2077
|
+
"bindings"
|
|
2078
|
+
]);
|
|
2079
|
+
const BINDING_KEYS = new Set([
|
|
2080
|
+
"gate_key",
|
|
2081
|
+
"repo_key",
|
|
2082
|
+
"path_prefix",
|
|
2083
|
+
"enabled",
|
|
2084
|
+
"config",
|
|
2085
|
+
"note",
|
|
2086
|
+
"delete"
|
|
2087
|
+
]);
|
|
2088
|
+
const DELETE_ENTRY_KEYS = new Set([
|
|
2089
|
+
"gate_key",
|
|
2090
|
+
"repo_key",
|
|
2091
|
+
"path_prefix",
|
|
2092
|
+
"note",
|
|
2093
|
+
"delete"
|
|
2094
|
+
]);
|
|
2095
|
+
const DEFINITION_KEYS = new Set([
|
|
2096
|
+
"key",
|
|
2097
|
+
"name",
|
|
2098
|
+
"description",
|
|
2099
|
+
"technique",
|
|
2100
|
+
"category",
|
|
2101
|
+
"resolver_template"
|
|
2102
|
+
]);
|
|
2103
|
+
const DEFINITION_KEY_RE = /^[a-z0-9_]+$/;
|
|
2104
|
+
function isPlainObject(value) {
|
|
2105
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
2106
|
+
}
|
|
2107
|
+
function optionalTierValue(entry, field, at) {
|
|
2108
|
+
const value = entry[field];
|
|
2109
|
+
if (value === void 0 || value === null) return null;
|
|
2110
|
+
if (typeof value !== "string" || value.trim() === "") throw new PayloadError(`${at}.${field} must be a non-empty string or null`);
|
|
2111
|
+
return value;
|
|
2112
|
+
}
|
|
2113
|
+
/** The key a binding's tier is matched on. */
|
|
2114
|
+
function bindingTierKey(b) {
|
|
2115
|
+
return JSON.stringify([
|
|
2116
|
+
b.gate_key,
|
|
2117
|
+
b.repo_key ?? null,
|
|
2118
|
+
b.path_prefix ?? null
|
|
2119
|
+
]);
|
|
2120
|
+
}
|
|
2121
|
+
function parseBinding(entry, at) {
|
|
2122
|
+
if (!isPlainObject(entry)) throw new PayloadError(`${at} is not an object`);
|
|
2123
|
+
for (const key of Object.keys(entry)) if (!BINDING_KEYS.has(key)) throw new PayloadError(`${at}: unknown field "${key}"`);
|
|
2124
|
+
const gateKey = entry.gate_key;
|
|
2125
|
+
if (typeof gateKey !== "string" || gateKey.trim() === "") throw new PayloadError(`${at}.gate_key must be a non-empty string`);
|
|
2126
|
+
if (entry.note !== void 0 && typeof entry.note !== "string") throw new PayloadError(`${at}.note must be a string`);
|
|
2127
|
+
if (entry.delete !== void 0 && typeof entry.delete !== "boolean") throw new PayloadError(`${at}.delete must be a boolean`);
|
|
2128
|
+
const tier = {
|
|
2129
|
+
gate_key: gateKey,
|
|
2130
|
+
repo_key: optionalTierValue(entry, "repo_key", at),
|
|
2131
|
+
path_prefix: optionalTierValue(entry, "path_prefix", at)
|
|
2132
|
+
};
|
|
2133
|
+
const note = entry.note ? { note: entry.note } : {};
|
|
2134
|
+
if (entry.delete === true) {
|
|
2135
|
+
for (const key of Object.keys(entry)) if (!DELETE_ENTRY_KEYS.has(key)) throw new PayloadError(`${at}: a "delete": true entry names the binding to delete and nothing else; remove "${key}"`);
|
|
2136
|
+
return {
|
|
2137
|
+
...tier,
|
|
2138
|
+
enabled: false,
|
|
2139
|
+
config: {},
|
|
2140
|
+
...note,
|
|
2141
|
+
delete: true
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
const config = entry.config ?? {};
|
|
2145
|
+
if (!isPlainObject(config)) throw new PayloadError(`${at}.config must be an object`);
|
|
2146
|
+
if (entry.enabled !== void 0 && typeof entry.enabled !== "boolean") throw new PayloadError(`${at}.enabled must be a boolean`);
|
|
2147
|
+
return {
|
|
2148
|
+
...tier,
|
|
2149
|
+
enabled: entry.enabled !== false,
|
|
2150
|
+
config,
|
|
2151
|
+
...note
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
function parseDefinition(entry, at) {
|
|
2155
|
+
if (!isPlainObject(entry)) throw new PayloadError(`${at} is not an object`);
|
|
2156
|
+
if ("delete" in entry) throw new PayloadError(`${at}: a definition cannot be deleted by import (that removes every gate of the type); delete it on purpose instead`);
|
|
2157
|
+
for (const key$1 of Object.keys(entry)) if (!DEFINITION_KEYS.has(key$1)) throw new PayloadError(`${at}: unknown field "${key$1}"`);
|
|
2158
|
+
const key = entry.key;
|
|
2159
|
+
if (typeof key !== "string" || !DEFINITION_KEY_RE.test(key)) throw new PayloadError(`${at}.key must be lowercase snake_case`);
|
|
2160
|
+
if (typeof entry.name !== "string" || entry.name.trim() === "") throw new PayloadError(`${at}.name must be a non-empty string`);
|
|
2161
|
+
for (const field of ["description", "category"]) {
|
|
2162
|
+
const v = entry[field];
|
|
2163
|
+
if (v !== void 0 && v !== null && typeof v !== "string") throw new PayloadError(`${at}.${field} must be a string or null`);
|
|
2164
|
+
}
|
|
2165
|
+
if (entry.technique !== void 0 && !isVerificationTechnique(entry.technique)) throw new PayloadError(`${at}.technique "${String(entry.technique)}" is not a verification technique`);
|
|
2166
|
+
const template = entry.resolver_template;
|
|
2167
|
+
if (template !== void 0 && template !== null && !isPlainObject(template)) throw new PayloadError(`${at}.resolver_template must be an object or null`);
|
|
2168
|
+
const def = {
|
|
2169
|
+
key,
|
|
2170
|
+
name: entry.name
|
|
2171
|
+
};
|
|
2172
|
+
if (entry.description !== void 0) def.description = entry.description;
|
|
2173
|
+
if (entry.technique !== void 0) def.technique = entry.technique;
|
|
2174
|
+
if (entry.category !== void 0) def.category = entry.category;
|
|
2175
|
+
if (template !== void 0) def.resolver_template = template;
|
|
2176
|
+
return def;
|
|
2177
|
+
}
|
|
2178
|
+
/** Parse and shape-check a gate configuration file. Throws `PayloadError`. */
|
|
2179
|
+
function parsePayload(raw) {
|
|
2180
|
+
if (!isPlainObject(raw) || !Array.isArray(raw.bindings)) throw new PayloadError("the file must be an object with a \"bindings\" array");
|
|
2181
|
+
for (const key of Object.keys(raw)) if (!TOP_KEYS.has(key)) throw new PayloadError(`unknown field "${key}"`);
|
|
2182
|
+
if (raw.description !== void 0 && typeof raw.description !== "string") throw new PayloadError("\"description\" must be a string");
|
|
2183
|
+
if (raw.definitions !== void 0 && !Array.isArray(raw.definitions)) throw new PayloadError("\"definitions\" must be an array");
|
|
2184
|
+
const seenTier = /* @__PURE__ */ new Set();
|
|
2185
|
+
const bindings = raw.bindings.map((entry, i) => {
|
|
2186
|
+
const b = parseBinding(entry, `bindings[${i}]`);
|
|
2187
|
+
const tier = bindingTierKey(b);
|
|
2188
|
+
if (seenTier.has(tier)) throw new PayloadError(`bindings[${i}]: the tier (gate_key "${b.gate_key}", repo_key ${b.repo_key ?? "null"}, path_prefix ${b.path_prefix ?? "null"}) appears twice`);
|
|
2189
|
+
seenTier.add(tier);
|
|
2190
|
+
return b;
|
|
2191
|
+
});
|
|
2192
|
+
const seenKey = /* @__PURE__ */ new Set();
|
|
2193
|
+
const definitions = (raw.definitions ?? []).map((entry, i) => {
|
|
2194
|
+
const d = parseDefinition(entry, `definitions[${i}]`);
|
|
2195
|
+
if (seenKey.has(d.key)) throw new PayloadError(`definitions[${i}]: key "${d.key}" appears twice`);
|
|
2196
|
+
seenKey.add(d.key);
|
|
2197
|
+
return d;
|
|
2198
|
+
});
|
|
2199
|
+
return {
|
|
2200
|
+
...typeof raw.description === "string" ? { description: raw.description } : {},
|
|
2201
|
+
definitions,
|
|
2202
|
+
bindings
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
/** Every command a config issues, with the slot path it sits at. */
|
|
2206
|
+
function commandLeaves(config) {
|
|
2207
|
+
const out = [];
|
|
2208
|
+
const walk = (node, at, key) => {
|
|
2209
|
+
if (typeof node === "string") {
|
|
2210
|
+
out.push({
|
|
2211
|
+
path: at,
|
|
2212
|
+
command: node,
|
|
2213
|
+
...key ? { key } : {}
|
|
2214
|
+
});
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
if (!isPlainObject(node)) return;
|
|
2218
|
+
if (Array.isArray(node.argv)) {
|
|
2219
|
+
out.push({
|
|
2220
|
+
path: at,
|
|
2221
|
+
command: node.argv.map(String).join(" "),
|
|
2222
|
+
...key ? { key } : {}
|
|
2223
|
+
});
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
if (typeof node.legacy === "string") {
|
|
2227
|
+
out.push({
|
|
2228
|
+
path: at,
|
|
2229
|
+
command: node.legacy,
|
|
2230
|
+
...key ? { key } : {}
|
|
2231
|
+
});
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
for (const [k, child] of Object.entries(node)) walk(child, `${at}.${k}`, k);
|
|
2235
|
+
};
|
|
2236
|
+
for (const root of ["cmd", "impl"]) if (config[root] !== void 0) walk(config[root], root);
|
|
2237
|
+
return out;
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Every check that needs no network or database: each binding config's shape
|
|
2241
|
+
* and each command slot, rendered with typed samples and run through the
|
|
2242
|
+
* gate command allowlist exactly as the server checks it at write time; and
|
|
2243
|
+
* each definition's resolver template commands. Throws `PayloadError` naming
|
|
2244
|
+
* the entry. Delete entries carry no config and are not checked.
|
|
2245
|
+
*
|
|
2246
|
+
* Which `params` a binding may carry depends on its definition's resolver.
|
|
2247
|
+
* Locally that is known for a definition in the file and for the system gate
|
|
2248
|
+
* types (the key is the resolver); for any other key the `params` subtree is
|
|
2249
|
+
* left to the server, which checks every row against the stored definition,
|
|
2250
|
+
* so a valid file is never refused here. The server passes
|
|
2251
|
+
* `{ configShape: false }` and runs that per-row check itself.
|
|
2252
|
+
*/
|
|
2253
|
+
function validatePayload(payload, opts = {}) {
|
|
2254
|
+
const fileResolver = new Map(payload.definitions.map((d) => {
|
|
2255
|
+
const r = d.resolver_template?.resolver;
|
|
2256
|
+
return [d.key, typeof r === "string" ? r : null];
|
|
2257
|
+
}));
|
|
2258
|
+
for (const def of payload.definitions) for (const slot of commandSlotsInResolverTemplate(def.resolver_template ?? null)) {
|
|
2259
|
+
const result = validateRenderedSlotCommand({
|
|
2260
|
+
slotKey: slot.slotKey,
|
|
2261
|
+
template: slot.template,
|
|
2262
|
+
paramSchema: slot.paramSchema,
|
|
2263
|
+
validate: validateGateCommand
|
|
2264
|
+
});
|
|
2265
|
+
if (!result.ok) throw new PayloadError(`definition ${def.key}: ${result.reason}`);
|
|
2266
|
+
}
|
|
2267
|
+
for (const binding of payload.bindings) {
|
|
2268
|
+
if (binding.delete) continue;
|
|
2269
|
+
try {
|
|
2270
|
+
if (opts.configShape !== false) {
|
|
2271
|
+
const key = binding.gate_key;
|
|
2272
|
+
if (fileResolver.has(key)) assertWellFormedGateBindingConfig(binding.config, key, fileResolver.get(key));
|
|
2273
|
+
else if (Object.prototype.hasOwnProperty.call(BINDING_PARAM_DEFAULTS, key)) assertWellFormedGateBindingConfig(binding.config, key);
|
|
2274
|
+
else {
|
|
2275
|
+
const { params: _left,...rest } = binding.config;
|
|
2276
|
+
assertWellFormedGateBindingConfig(rest, key);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
} catch (e) {
|
|
2280
|
+
if (e instanceof GateBindingConfigError) throw new PayloadError(`${binding.gate_key}: ${e.message}`);
|
|
2281
|
+
throw e;
|
|
2282
|
+
}
|
|
2283
|
+
for (const slot of commandSlotsInBindingConfig(binding.config)) {
|
|
2284
|
+
const result = validateRenderedSlotCommand({
|
|
2285
|
+
slotKey: slot.slotKey,
|
|
2286
|
+
template: slot.template,
|
|
2287
|
+
paramSchema: slot.paramSchema,
|
|
2288
|
+
validate: validateGateCommand
|
|
2289
|
+
});
|
|
2290
|
+
if (!result.ok) throw new PayloadError(`${binding.gate_key}: ${result.reason}`);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* The files a file's lanes name, repository-relative: script paths in a
|
|
2296
|
+
* command, `tests/<name>.rs` for each `--test <name>`, and path-prefix keys
|
|
2297
|
+
* that name a file. Placeholders (`{path}`) are not files.
|
|
2298
|
+
*/
|
|
2299
|
+
function namedFiles(payload) {
|
|
2300
|
+
const files = /* @__PURE__ */ new Set();
|
|
2301
|
+
for (const binding of payload.bindings) for (const leaf of commandLeaves(binding.config)) {
|
|
2302
|
+
const words = leaf.command.split(/\s+/);
|
|
2303
|
+
words.forEach((word, i) => {
|
|
2304
|
+
if (word === "--test" && words[i + 1] && !words[i + 1].includes("{")) files.add(`tests/${words[i + 1]}.rs`);
|
|
2305
|
+
else if (/^[\w.-]+(?:\/[\w.-]+)+\.[A-Za-z0-9]+$/.test(word) && !word.startsWith("@")) files.add(word);
|
|
2306
|
+
});
|
|
2307
|
+
if (leaf.key && /\.[A-Za-z0-9]+$/.test(leaf.key) && leaf.key.includes("/")) files.add(leaf.key);
|
|
2308
|
+
}
|
|
2309
|
+
return [...files].sort();
|
|
2310
|
+
}
|
|
2311
|
+
/** `namedFiles` for which `exists(relativePath)` is false. */
|
|
2312
|
+
function missingFiles(payload, exists) {
|
|
2313
|
+
return namedFiles(payload).filter((f) => !exists(f));
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
//#endregion
|
|
2317
|
+
//#region ../shared/dist/runs/gate-expectations.js
|
|
2318
|
+
/** The branch names a positive grep count may be judged by. */
|
|
2319
|
+
const POSITIVE_COUNT = [
|
|
2320
|
+
"count OP N",
|
|
2321
|
+
">= N",
|
|
2322
|
+
">= N each",
|
|
2323
|
+
"both >= N",
|
|
2324
|
+
"PASS"
|
|
2325
|
+
];
|
|
2326
|
+
/** The branch names a NEGATIVE grep count may be judged by. */
|
|
2327
|
+
const NEGATIVE_COUNT = ["zero", "count OP N"];
|
|
2328
|
+
const expect = (defaultExpected, accepts) => ({
|
|
2329
|
+
defaultExpected,
|
|
2330
|
+
verdictSource: "expectation",
|
|
2331
|
+
...accepts ? { accepts } : {}
|
|
2332
|
+
});
|
|
2333
|
+
const RUNNER = {
|
|
2334
|
+
defaultExpected: null,
|
|
2335
|
+
verdictSource: "runner"
|
|
2336
|
+
};
|
|
2337
|
+
const JUDGE = {
|
|
2338
|
+
defaultExpected: null,
|
|
2339
|
+
verdictSource: "judge"
|
|
2340
|
+
};
|
|
2341
|
+
/**
|
|
2342
|
+
* Every system gate type's expectation contract — ENG-5967 A.
|
|
2343
|
+
*
|
|
2344
|
+
* The user-decided defaults (binding): `file_exists` EXISTS, `file_not_exists`
|
|
2345
|
+
* NOT_EXISTS, `file_contains`/`module_imports` `>= 1`, `file_not_contains`/
|
|
2346
|
+
* `no_stubs` `0`, `done_criteria_check` `all met`, `shell` `exit 0`. The rest
|
|
2347
|
+
* follow the same reading of their own templates: a grep count asks `>= 1`
|
|
2348
|
+
* (or `0` when it is a prohibition), a script whose exit code is its verdict
|
|
2349
|
+
* asks `exit 0`, and a check that prints one token asks for that token.
|
|
2350
|
+
*
|
|
2351
|
+
* `seed_data_count` deliberately has NO default: "there should be exactly N"
|
|
2352
|
+
* has no N anyone but the author knows, so deploy refuses it without one.
|
|
2353
|
+
*/
|
|
2354
|
+
const GATE_TYPE_EXPECTATIONS = {
|
|
2355
|
+
file_exists: expect("EXISTS", [
|
|
2356
|
+
"EXISTS",
|
|
2357
|
+
"exit 0",
|
|
2358
|
+
"contains:"
|
|
2359
|
+
]),
|
|
2360
|
+
entity_generated: expect("EXISTS", [
|
|
2361
|
+
"EXISTS",
|
|
2362
|
+
"exit 0",
|
|
2363
|
+
"contains:"
|
|
2364
|
+
]),
|
|
2365
|
+
file_not_exists: expect("NOT_EXISTS", [
|
|
2366
|
+
"NOT_EXISTS",
|
|
2367
|
+
"exit 0",
|
|
2368
|
+
"contains:"
|
|
2369
|
+
]),
|
|
2370
|
+
file_contains: expect(">= 1", POSITIVE_COUNT),
|
|
2371
|
+
module_imports: expect(">= 1", POSITIVE_COUNT),
|
|
2372
|
+
schema_has_field: expect(">= 1", POSITIVE_COUNT),
|
|
2373
|
+
entity_has_column: expect(">= 1", POSITIVE_COUNT),
|
|
2374
|
+
service_has_method: expect(">= 1", POSITIVE_COUNT),
|
|
2375
|
+
assertion_floor: expect(">= 1", POSITIVE_COUNT),
|
|
2376
|
+
seed_data_count: {
|
|
2377
|
+
defaultExpected: null,
|
|
2378
|
+
verdictSource: "expectation",
|
|
2379
|
+
accepts: POSITIVE_COUNT.concat(["zero"])
|
|
2380
|
+
},
|
|
2381
|
+
file_not_contains: expect("0", NEGATIVE_COUNT),
|
|
2382
|
+
no_stubs: expect("0", NEGATIVE_COUNT),
|
|
2383
|
+
symbol_removed: expect("0", NEGATIVE_COUNT),
|
|
2384
|
+
done_criteria_check: expect("all met", [
|
|
2385
|
+
"all met",
|
|
2386
|
+
"exit 0",
|
|
2387
|
+
"contains:"
|
|
2388
|
+
]),
|
|
2389
|
+
schema_registered: expect("PASS", ["PASS", "contains:"]),
|
|
2390
|
+
shell: expect("exit 0"),
|
|
2391
|
+
typecheck: expect("exit 0"),
|
|
2392
|
+
lint: expect("exit 0"),
|
|
2393
|
+
changed_line_coverage: expect("exit 0"),
|
|
2394
|
+
secret_scan: expect("exit 0"),
|
|
2395
|
+
security_testing: expect("exit 0"),
|
|
2396
|
+
iac_misconfig: expect("exit 0"),
|
|
2397
|
+
dependency_audit: expect("exit 0"),
|
|
2398
|
+
symbol_referenced: expect("exit 0"),
|
|
2399
|
+
ast_pattern: expect("exit 0"),
|
|
2400
|
+
schema_column_set: expect("exit 0"),
|
|
2401
|
+
migration_revert_scope: expect("exit 0"),
|
|
2402
|
+
migration_contains: expect("exit 0"),
|
|
2403
|
+
unit_test_pass: RUNNER,
|
|
2404
|
+
e2e_test_pass: RUNNER,
|
|
2405
|
+
spec_conformance: JUDGE,
|
|
2406
|
+
behavioral_intent: JUDGE,
|
|
2407
|
+
edge_case_coverage: JUDGE,
|
|
2408
|
+
guided: {
|
|
2409
|
+
defaultExpected: null,
|
|
2410
|
+
verdictSource: "observation"
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
|
|
2414
|
+
//#endregion
|
|
2415
|
+
//#region ../shared/dist/schemas/test-data.zod.js
|
|
2416
|
+
const GherkinKeywordSchema = z.enum([
|
|
2417
|
+
"Given",
|
|
2418
|
+
"When",
|
|
2419
|
+
"Then",
|
|
2420
|
+
"And",
|
|
2421
|
+
"But",
|
|
2422
|
+
"*"
|
|
2423
|
+
]);
|
|
2424
|
+
const DataTableDataSchema = z.object({ rows: z.array(z.array(z.string())).describe("") }).describe("");
|
|
2425
|
+
/**
|
|
2426
|
+
* A GUIDED step — plan ENG-4825 D-F / R7.1, R7.2, R7.8.
|
|
2427
|
+
*
|
|
2428
|
+
* A gate a human *or an agent* performs: an instruction and what should be
|
|
2429
|
+
* true afterwards. It is the third step shape, alongside the legacy shell
|
|
2430
|
+
* (`method`) and the declarative (`type` + `params`) forms.
|
|
2431
|
+
*
|
|
2432
|
+
* ## Why `guided` and not `manual`
|
|
2433
|
+
*
|
|
2434
|
+
* Seven real manual tests in the corpus already carry `type: 'manual'` on
|
|
2435
|
+
* their steps. Reusing that marker would retroactively turn them into gates an
|
|
2436
|
+
* agent is expected to perform and record per-step results for, which is not
|
|
2437
|
+
* what their authors wrote. A new word costs nothing and cannot collide.
|
|
2438
|
+
*
|
|
2439
|
+
* ## Why the field is `step` and not `method`
|
|
2440
|
+
*
|
|
2441
|
+
* `method` means "a shell command to run". A guided step is an instruction to
|
|
2442
|
+
* follow, and calling it `method` would invite every executor that shells out
|
|
2443
|
+
* on `method` to try. The normaliser must NOT rename it — see
|
|
2444
|
+
* `test.service.ts`.
|
|
2445
|
+
*
|
|
2446
|
+
* ## Ordering matters — this arm goes FIRST in every union
|
|
2447
|
+
*
|
|
2448
|
+
* The competitor is the DECLARATIVE arm, which accepts any `type` string and
|
|
2449
|
+
* has no `step` field: under `.strict()` it rejects, but under `.passthrough()`
|
|
2450
|
+
* a guided step matching it would keep `step` only by accident of the
|
|
2451
|
+
* passthrough, and a later `.strict()` consumer would silently drop the
|
|
2452
|
+
* instruction. First place makes the match deterministic instead.
|
|
2453
|
+
*/
|
|
2454
|
+
const GuidedStepItemSchema = z.object({
|
|
2455
|
+
id: z.string().uuid().optional(),
|
|
2456
|
+
type: z.literal("guided"),
|
|
2457
|
+
step: z.string().describe(""),
|
|
2458
|
+
expected_result: z.string().optional().describe(""),
|
|
2459
|
+
result: z.string().optional().describe(""),
|
|
2460
|
+
technique: VerificationTechniqueSchema.optional().describe("")
|
|
2461
|
+
});
|
|
2462
|
+
const StepItemSchema = z.object({
|
|
2463
|
+
id: z.string().uuid().optional(),
|
|
2464
|
+
method: z.string(),
|
|
2465
|
+
expected_result: z.string().optional(),
|
|
2466
|
+
shared_step_id: z.string().uuid().optional(),
|
|
2467
|
+
keyword: GherkinKeywordSchema.optional(),
|
|
2468
|
+
data_table: DataTableDataSchema.optional().describe(""),
|
|
2469
|
+
technique: VerificationTechniqueSchema.optional().describe("")
|
|
2470
|
+
});
|
|
2471
|
+
const PreconditionItemSchema = z.object({
|
|
2472
|
+
id: z.string().uuid().optional(),
|
|
2473
|
+
condition: z.string(),
|
|
2474
|
+
shared_precondition_id: z.string().uuid().optional(),
|
|
2475
|
+
keyword: GherkinKeywordSchema.optional(),
|
|
2476
|
+
data_table: DataTableDataSchema.optional().describe("")
|
|
2477
|
+
});
|
|
2478
|
+
/**
|
|
2479
|
+
* Strict test data schema for MCP tools.
|
|
2480
|
+
* Rejects unknown fields so LLMs receive clear schema guidance.
|
|
2481
|
+
*/
|
|
2482
|
+
const TestDataSchemaStrict = z.object({
|
|
2483
|
+
description: z.string().optional().describe(""),
|
|
2484
|
+
shared_precondition_id: z.string().uuid().nullable().optional().describe(""),
|
|
2485
|
+
preconditions: z.array(PreconditionItemSchema).optional().describe(""),
|
|
2486
|
+
steps: z.array(z.union([GuidedStepItemSchema, StepItemSchema])).optional().describe("")
|
|
2487
|
+
}).strict().describe("");
|
|
2488
|
+
|
|
2489
|
+
//#endregion
|
|
2490
|
+
//#region ../shared/dist/schemas/review-loop-events.zod.js
|
|
2491
|
+
/**
|
|
2492
|
+
* ENG-3822 (D17 of ENG-3320) — wire format for review-loop escape events
|
|
2493
|
+
* published by `apps/github-app` to the JetStream subject
|
|
2494
|
+
* `jobs.backender.review_loop.event` (BACKENDER_RX stream) and consumed
|
|
2495
|
+
* by backender's `ReviewLoopEventConsumer`, which feeds the existing
|
|
2496
|
+
* `ReviewLoopEscapeService` (D13). Replaces the retired
|
|
2497
|
+
* `POST /v1/internal/github/review-loop/event` internal HTTP endpoint.
|
|
2498
|
+
*
|
|
2499
|
+
* This is the SINGLE source of truth for the payload shape — both sides
|
|
2500
|
+
* parse/build through these schemas; never hand-roll the type on either
|
|
2501
|
+
* side (the jira `@levr/jira-shared` payload-schema precedent).
|
|
2502
|
+
*
|
|
2503
|
+
* ## What the event carries — and deliberately does NOT (dedupe vs DB)
|
|
2504
|
+
*
|
|
2505
|
+
* github-app persists webhook data to the shared DB BEFORE these events
|
|
2506
|
+
* are published (`webhook.service.ts` awaits the persistence handlers
|
|
2507
|
+
* first), so the event only carries what the DB does **not** already
|
|
2508
|
+
* reliably hold at consume time:
|
|
2509
|
+
*
|
|
2510
|
+
* - PR `body` / `head_sha` / `merged_at`: NOT carried. Reliably present on
|
|
2511
|
+
* `github_pull_request` (upserted by `PullRequestService.upsertFromWebhook`
|
|
2512
|
+
* before publish; the publisher gates on the same repo-linked +
|
|
2513
|
+
* `pr_sync_enabled` conditions as the upsert). The consumer hydrates them
|
|
2514
|
+
* from the DB row.
|
|
2515
|
+
* - PR changed filenames: CARRIED. Nothing persists them; github-app fetches
|
|
2516
|
+
* them (metadata only, never contents) at publish time.
|
|
2517
|
+
* - `merge_commit_sha`: CARRIED. `github_pull_request` has no such column.
|
|
2518
|
+
* - push commit `{sha, message}` pairs: CARRIED. `github_commit` persistence
|
|
2519
|
+
* is best-effort — capped at 50 commits per push and fail-soft per row
|
|
2520
|
+
* (`CommitService.processCommit` returns null on save failure), so the DB
|
|
2521
|
+
* is NOT a reliable source for the exact commit set of this push.
|
|
2522
|
+
*
|
|
2523
|
+
* The `event` discriminator values intentionally match the
|
|
2524
|
+
* `REVIEW_LOOP_PUSH_EVENT` / `REVIEW_LOOP_PR_MERGED_EVENT` constants in
|
|
2525
|
+
* backender's `review-loop-escape.service.ts` (the D13 HTTP contract this
|
|
2526
|
+
* transport replaces) so the consumer's event→DTO mapping is 1:1.
|
|
2527
|
+
*/
|
|
2528
|
+
const REVIEW_LOOP_EVENT_PUSH = "push";
|
|
2529
|
+
const REVIEW_LOOP_EVENT_PR_MERGED = "pull_request_merged";
|
|
2530
|
+
/** A pushed commit — message is required for `This reverts commit <sha>` detection. */
|
|
2531
|
+
const ReviewLoopCommitSchema = z.object({
|
|
2532
|
+
sha: z.string().min(1),
|
|
2533
|
+
message: z.string()
|
|
2534
|
+
});
|
|
2535
|
+
const ReviewLoopPushEventSchema = z.object({
|
|
2536
|
+
event: z.literal(REVIEW_LOOP_EVENT_PUSH),
|
|
2537
|
+
workspace_id: z.string().uuid(),
|
|
2538
|
+
github_repository_id: z.string().uuid(),
|
|
2539
|
+
repo_full_name: z.string(),
|
|
2540
|
+
ref: z.string(),
|
|
2541
|
+
commits: z.array(ReviewLoopCommitSchema).min(1),
|
|
2542
|
+
producer_id: z.string()
|
|
2543
|
+
});
|
|
2544
|
+
const ReviewLoopPrMergedEventSchema = z.object({
|
|
2545
|
+
event: z.literal(REVIEW_LOOP_EVENT_PR_MERGED),
|
|
2546
|
+
workspace_id: z.string().uuid(),
|
|
2547
|
+
github_repository_id: z.string().uuid(),
|
|
2548
|
+
repo_full_name: z.string(),
|
|
2549
|
+
installation_id: z.number().int(),
|
|
2550
|
+
pr_number: z.number().int().positive(),
|
|
2551
|
+
merge_commit_sha: z.string().nullable(),
|
|
2552
|
+
changed_files: z.array(z.string()),
|
|
2553
|
+
changed_files_truncated: z.boolean(),
|
|
2554
|
+
producer_id: z.string()
|
|
2555
|
+
});
|
|
2556
|
+
/**
|
|
2557
|
+
* Discriminated union over `event`. An unknown `event` value fails parse —
|
|
2558
|
+
* the consumer must treat that as an explicit reject (loud log + term),
|
|
2559
|
+
* never a silent ack (the webhook default-throw rule, transposed).
|
|
2560
|
+
*/
|
|
2561
|
+
const ReviewLoopEventSchema = z.discriminatedUnion("event", [ReviewLoopPushEventSchema, ReviewLoopPrMergedEventSchema]);
|
|
2562
|
+
|
|
2563
|
+
//#endregion
|
|
2564
|
+
//#region ../shared/dist/first-task/first-task-prompt.js
|
|
2565
|
+
/**
|
|
2566
|
+
* The framing of a first-task prompt — plan ENG-6341 D4 rev 3.1 (ENG-6763).
|
|
2567
|
+
*
|
|
2568
|
+
* The client builds the prompts the user pastes into their coding agent from
|
|
2569
|
+
* these constants (the first-task card, the own-feature dialog), and the
|
|
2570
|
+
* backender strips the same framing from the request before choosing a recipe
|
|
2571
|
+
* and titling the Plan and the task (`extractFirstTaskRequest`). One source,
|
|
2572
|
+
* so client and server cannot drift.
|
|
2573
|
+
*
|
|
2574
|
+
* Pure and browser-safe: no imports.
|
|
2575
|
+
*/
|
|
2576
|
+
/** The product name the app's prompts carry (the email words its own brand). */
|
|
2577
|
+
const FIRST_TASK_PROMPT_BRAND = "Levr";
|
|
2578
|
+
/**
|
|
2579
|
+
* The opening of a first-task prompt for a brand: `Use <brand> to hold you to
|
|
2580
|
+
* this task: `. Review F-061: the nudge email builds its prompt with the
|
|
2581
|
+
* brand it is sent under, so the backender strips this framing for ANY
|
|
2582
|
+
* single-word brand, not only the literal "Levr".
|
|
2583
|
+
*/
|
|
2584
|
+
function firstTaskPromptPrefix(brandName) {
|
|
2585
|
+
return `Use ${brandName} to hold you to this task: `;
|
|
2586
|
+
}
|
|
2587
|
+
/** Opens every first-task prompt; an instruction to the agent, never the task. */
|
|
2588
|
+
const FIRST_TASK_PROMPT_PREFIX = firstTaskPromptPrefix(FIRST_TASK_PROMPT_BRAND);
|
|
2589
|
+
/**
|
|
2590
|
+
* The fixed todo-starter task, in the first-task card's words (plan ENG-6341
|
|
2591
|
+
* D4, the `todo-starter` recipe). Code review F-007 (ENG-7148): ONE source for
|
|
2592
|
+
* the card the user copies from and the `get_context` example the agent reads.
|
|
2593
|
+
*/
|
|
2594
|
+
const FIRST_TASK_TODO_TASK = "build a tiny TypeScript todo app with tests: add, complete, delete; reject empty or whitespace-only titles; completing a todo twice is a no-op.";
|
|
2595
|
+
/**
|
|
2596
|
+
* The todo-starter prompt: what the first-task card copies and what
|
|
2597
|
+
* `get_context` offers an owner's agent as `first_task.example_prompt`.
|
|
2598
|
+
*/
|
|
2599
|
+
const FIRST_TASK_TODO_PROMPT = `${FIRST_TASK_PROMPT_PREFIX}in this empty folder, ${FIRST_TASK_TODO_TASK}`;
|
|
2600
|
+
|
|
2601
|
+
//#endregion
|
|
2602
|
+
//#region ../shared/dist/ai-pricing/rate-card.js
|
|
2603
|
+
const PER_TOKEN = 1 / 1e6;
|
|
2604
|
+
|
|
2605
|
+
//#endregion
|
|
2606
|
+
//#region ../shared/dist/schemas/ai-usage-batch.zod.js
|
|
2607
|
+
const AI_USAGE_BATCH_VERSION = 1;
|
|
2608
|
+
const AiUsageBatchEntrySchema = z.object({
|
|
2609
|
+
workspace_id: z.string().uuid().nullable(),
|
|
2610
|
+
provider: z.string().min(1).max(64),
|
|
2611
|
+
model: z.string().min(1).max(128),
|
|
2612
|
+
calls: z.number().int().positive().max(1e7),
|
|
2613
|
+
input_tokens: z.number().int().nonnegative().max(0xe8d4a51000).nullable(),
|
|
2614
|
+
latency_ms: z.number().int().nonnegative().max(0xe8d4a51000)
|
|
2615
|
+
});
|
|
2616
|
+
const AiUsageBatchSchema = z.object({
|
|
2617
|
+
v: z.literal(AI_USAGE_BATCH_VERSION),
|
|
2618
|
+
batch_id: z.string().min(1).max(64),
|
|
2619
|
+
flushed_at: z.string().datetime(),
|
|
2620
|
+
entries: z.array(AiUsageBatchEntrySchema).min(1).max(5e3)
|
|
2621
|
+
});
|
|
2622
|
+
|
|
2623
|
+
//#endregion
|
|
2624
|
+
//#region src/commands/gates/bindingsHandler.ts
|
|
2625
|
+
function fail(code, cmd, message) {
|
|
2626
|
+
return {
|
|
2627
|
+
exitCode: code,
|
|
2628
|
+
stdout: "",
|
|
2629
|
+
stderr: `gates bindings ${cmd}: ${message}\n`
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
/** The server's own message for a refusal (Nest puts it in `message`). */
|
|
2633
|
+
function serverMessage(body, status) {
|
|
2634
|
+
if (body && typeof body === "object") {
|
|
2635
|
+
const m = body.message;
|
|
2636
|
+
if (typeof m === "string") return m;
|
|
2637
|
+
if (Array.isArray(m)) return m.map(String).join("; ");
|
|
2638
|
+
}
|
|
2639
|
+
if (typeof body === "string" && body.trim()) return body.trim();
|
|
2640
|
+
return `HTTP ${status}`;
|
|
2641
|
+
}
|
|
2642
|
+
function tierLabel(b) {
|
|
2643
|
+
if (b.repo_key == null) return "workspace-wide";
|
|
2644
|
+
return b.path_prefix ? `${b.repo_key}:${b.path_prefix}` : b.repo_key;
|
|
2645
|
+
}
|
|
2646
|
+
/** `export`: the file on stdout, or written to `--out`. */
|
|
2647
|
+
async function executeBindingsExport(flags, deps) {
|
|
2648
|
+
let reply;
|
|
2649
|
+
try {
|
|
2650
|
+
reply = await deps.exportConfig();
|
|
2651
|
+
} catch (err) {
|
|
2652
|
+
return fail(1, "export", `request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2653
|
+
}
|
|
2654
|
+
if (reply.status !== 200) return fail(1, "export", serverMessage(reply.body, reply.status));
|
|
2655
|
+
const text = `${JSON.stringify(reply.body, null, 2)}\n`;
|
|
2656
|
+
if (!flags.out) return {
|
|
2657
|
+
exitCode: 0,
|
|
2658
|
+
stdout: text,
|
|
2659
|
+
stderr: ""
|
|
2660
|
+
};
|
|
2661
|
+
try {
|
|
2662
|
+
deps.writeFile(flags.out, text);
|
|
2663
|
+
} catch (err) {
|
|
2664
|
+
return fail(2, "export", `cannot write ${flags.out}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2665
|
+
}
|
|
2666
|
+
const body = reply.body;
|
|
2667
|
+
return {
|
|
2668
|
+
exitCode: 0,
|
|
2669
|
+
stdout: "",
|
|
2670
|
+
stderr: `Wrote ${flags.out}: ${body.bindings?.length ?? 0} binding(s), ${body.definitions?.length ?? 0} definition(s).\n`
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
/** `import <file>`: check locally, then apply (or preview with --dry-run). */
|
|
2674
|
+
async function executeBindingsImport(flags, file, deps) {
|
|
2675
|
+
let raw;
|
|
2676
|
+
try {
|
|
2677
|
+
raw = JSON.parse(deps.readFile(file));
|
|
2678
|
+
} catch (err) {
|
|
2679
|
+
return fail(2, "import", `cannot read ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
2680
|
+
}
|
|
2681
|
+
let payload;
|
|
2682
|
+
try {
|
|
2683
|
+
payload = parsePayload(raw);
|
|
2684
|
+
validatePayload(payload);
|
|
2685
|
+
} catch (err) {
|
|
2686
|
+
if (err instanceof PayloadError) return fail(2, "import", `${file} refused, nothing sent: ${err.message}`);
|
|
2687
|
+
throw err;
|
|
2688
|
+
}
|
|
2689
|
+
const out = [];
|
|
2690
|
+
if (flags["repo-root"]) {
|
|
2691
|
+
const root = flags["repo-root"];
|
|
2692
|
+
const missing = missingFiles(payload, (rel) => deps.exists(root, rel));
|
|
2693
|
+
for (const f of namedFiles(payload)) out.push(`${missing.includes(f) ? "MISSING" : "present"} ${f}`);
|
|
2694
|
+
if (missing.length > 0) return {
|
|
2695
|
+
exitCode: 1,
|
|
2696
|
+
stdout: `${out.join("\n")}\n`,
|
|
2697
|
+
stderr: `gates bindings import: ${missing.length} file(s) the lanes name are missing under ${root}; nothing sent\n`
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
let reply;
|
|
2701
|
+
try {
|
|
2702
|
+
reply = await deps.importConfig(raw, flags["dry-run"]);
|
|
2703
|
+
} catch (err) {
|
|
2704
|
+
return fail(1, "import", `request failed: ${err instanceof Error ? err.message : String(err)}` + (flags["dry-run"] ? "" : " (the import may or may not have been applied; run it with --dry-run to see)"));
|
|
2705
|
+
}
|
|
2706
|
+
if (reply.status !== 200) return fail(1, "import", serverMessage(reply.body, reply.status));
|
|
2707
|
+
const result = reply.body;
|
|
2708
|
+
for (const d of result.definitions ?? []) out.push(`${d.action.padEnd(8)} definition ${d.key}`);
|
|
2709
|
+
for (const b of result.bindings ?? []) {
|
|
2710
|
+
const differs$1 = b.action === "differs" ? " (NOT applied: the stored row differs)" : "";
|
|
2711
|
+
const note = b.note ? ` # ${b.note}` : "";
|
|
2712
|
+
out.push(`${b.action.padEnd(8)} ${b.gate_key} [${tierLabel(b)}]${differs$1}${note}`);
|
|
2713
|
+
}
|
|
2714
|
+
const rows = [...result.definitions ?? [], ...result.bindings ?? []];
|
|
2715
|
+
const written = rows.filter((r) => r.applied).length;
|
|
2716
|
+
out.push(result.dry_run ? "# Dry run: nothing written. Run again without --dry-run to apply." : `# Applied: ${written} row(s) written.`);
|
|
2717
|
+
const differs = rows.filter((r) => r.action === "differs").length;
|
|
2718
|
+
return {
|
|
2719
|
+
exitCode: differs > 0 ? 1 : 0,
|
|
2720
|
+
stdout: `${out.join("\n")}\n`,
|
|
2721
|
+
stderr: differs > 0 ? `gates bindings import: ${differs} row(s) differ from what the workspace stores and were not applied; change them in the workspace on purpose, or delete them and import again.\n` : ""
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
/** The real dependencies: the logged-in backend (the public SDK), the disk. */
|
|
2725
|
+
async function liveDeps(workspaceFlag) {
|
|
2726
|
+
let auth;
|
|
2727
|
+
try {
|
|
2728
|
+
auth = await resolveToken();
|
|
2729
|
+
} catch (err) {
|
|
2730
|
+
return fail(1, "auth", err instanceof Error ? err.message : "Authentication failed.");
|
|
2731
|
+
}
|
|
2732
|
+
configureClient(auth);
|
|
2733
|
+
try {
|
|
2734
|
+
const ws = await resolveWorkspaceFor(auth, workspaceFlag);
|
|
2735
|
+
if (ws) client.setConfig({
|
|
2736
|
+
...client.getConfig(),
|
|
2737
|
+
workspaceId: ws.workspaceId
|
|
2738
|
+
});
|
|
2739
|
+
} catch (err) {
|
|
2740
|
+
return fail(1, "workspace", err instanceof Error ? err.message : "Workspace resolution failed.");
|
|
2741
|
+
}
|
|
2742
|
+
const reply = (r) => ({
|
|
2743
|
+
status: r.response.status,
|
|
2744
|
+
body: r.ok ? r.data : r.error
|
|
2745
|
+
});
|
|
2746
|
+
return {
|
|
2747
|
+
exportConfig: async () => reply(await gateBindingTransferExportConfigV1()),
|
|
2748
|
+
importConfig: async (file, dryRun) => reply(await gateBindingTransferImportConfigV1({
|
|
2749
|
+
body: file,
|
|
2750
|
+
...dryRun ? { query: { dry_run: "true" } } : {}
|
|
2751
|
+
})),
|
|
2752
|
+
readFile: (file) => fs.readFileSync(file, "utf8"),
|
|
2753
|
+
writeFile: (file, content) => fs.writeFileSync(file, content),
|
|
2754
|
+
exists: (root, rel) => fs.existsSync(path$1.join(root, rel))
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
function emit(ctx, outcome) {
|
|
2758
|
+
if (outcome.stdout) ctx.process.stdout.write(outcome.stdout);
|
|
2759
|
+
if (outcome.stderr) ctx.process.stderr.write(outcome.stderr);
|
|
2760
|
+
ctx.process.exitCode = outcome.exitCode;
|
|
2761
|
+
}
|
|
2762
|
+
async function gatesBindingsExportHandler(flags) {
|
|
2763
|
+
const deps = await liveDeps(flags["workspace-id"]);
|
|
2764
|
+
if ("exitCode" in deps) return emit(this, deps);
|
|
2765
|
+
emit(this, await executeBindingsExport(flags, deps));
|
|
2766
|
+
}
|
|
2767
|
+
async function gatesBindingsImportHandler(flags, file) {
|
|
2768
|
+
let preflight = null;
|
|
2769
|
+
try {
|
|
2770
|
+
validatePayload(parsePayload(JSON.parse(fs.readFileSync(file, "utf8"))));
|
|
2771
|
+
} catch (err) {
|
|
2772
|
+
preflight = fail(2, "import", err instanceof PayloadError ? `${file} refused, nothing sent: ${err.message}` : `cannot read ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
2773
|
+
}
|
|
2774
|
+
if (preflight) return emit(this, preflight);
|
|
2775
|
+
const deps = await liveDeps(flags["workspace-id"]);
|
|
2776
|
+
if ("exitCode" in deps) return emit(this, deps);
|
|
2777
|
+
emit(this, await executeBindingsImport(flags, file, deps));
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
//#endregion
|
|
2781
|
+
export { gatesBindingsExportHandler, gatesBindingsImportHandler };
|