specpi 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/NPM_RELEASE.md +110 -0
  4. package/README.md +155 -0
  5. package/SECURITY.md +85 -0
  6. package/SECURITY_MODEL.md +107 -0
  7. package/THIRD_PARTY.md +61 -0
  8. package/browser-runtime/package-lock.json +86 -0
  9. package/browser-runtime/package.json +15 -0
  10. package/extensions/browser/core.mjs +306 -0
  11. package/extensions/browser/index.ts +723 -0
  12. package/extensions/browser/smoke.mjs +47 -0
  13. package/extensions/command-guard/bash.mjs +1426 -0
  14. package/extensions/command-guard/cmd.mjs +369 -0
  15. package/extensions/command-guard/core.mjs +506 -0
  16. package/extensions/command-guard/index.ts +634 -0
  17. package/extensions/command-guard/managed-files.mjs +22 -0
  18. package/extensions/command-guard/paths.mjs +398 -0
  19. package/extensions/command-guard/powershell-parser.ps1 +47 -0
  20. package/extensions/command-guard/powershell.mjs +655 -0
  21. package/extensions/command-guard/redact.mjs +65 -0
  22. package/extensions/command-guard/rules.mjs +2557 -0
  23. package/extensions/command-guard/smoke.mjs +422 -0
  24. package/extensions/files/core.mjs +422 -0
  25. package/extensions/files/index.ts +678 -0
  26. package/extensions/spec/core.mjs +47 -0
  27. package/extensions/spec.ts +457 -0
  28. package/extensions/tool-wishlist/capabilities.json +114 -0
  29. package/extensions/tool-wishlist/core.mjs +1525 -0
  30. package/extensions/tool-wishlist/index.ts +804 -0
  31. package/extensions/tool-wishlist/registry.mjs +99 -0
  32. package/extensions/tool-wishlist/validators.mjs +345 -0
  33. package/extensions/ui-refresh/index.ts +54 -0
  34. package/extensions/workflow-controls/challenge.mjs +196 -0
  35. package/extensions/workflow-controls/experiments.mjs +628 -0
  36. package/extensions/workflow-controls/index.ts +1144 -0
  37. package/extensions/workflow-controls/scope.mjs +272 -0
  38. package/extensions/workflow-controls/smoke.mjs +201 -0
  39. package/package.json +98 -0
  40. package/scripts/check-package.mjs +483 -0
  41. package/scripts/check-pi-package.mjs +223 -0
  42. package/scripts/check-release-order.mjs +97 -0
  43. package/scripts/lib.mjs +182 -0
  44. package/scripts/lock.mjs +122 -0
  45. package/scripts/specpi.mjs +2037 -0
  46. package/scripts/verify-artifact.mjs +21 -0
  47. package/shell/pi-profiles.sh +14 -0
  48. package/site/logo.svg +9 -0
  49. package/site/self-improvement-loop-v2.svg +108 -0
  50. package/skills/donsetch/SKILL.md +76 -0
  51. package/skills/specpi-improve/SKILL.md +54 -0
  52. package/specpi +4 -0
  53. package/specpi.cmd +4 -0
  54. package/templates/AGENTS.md +23 -0
  55. package/templates/settings.json +10 -0
  56. package/themes/specpi-spec.json +96 -0
  57. package/themes/tea-house.json +89 -0
@@ -0,0 +1,369 @@
1
+ import { redactCommand } from "./redact.mjs";
2
+ import { analyzeHostArguments, HOST_NAMES as POWERSHELL_HOSTS } from "./powershell.mjs";
3
+
4
+ export const CMD_LIMITS = Object.freeze({ maxInput: 128 * 1024, maxTokens: 4096, maxLeaves: 128, maxDepth: 8 });
5
+ function lex(input, limits) {
6
+ const tokens = [],
7
+ errors = [],
8
+ dynamicConstructs = [],
9
+ redirects = [];
10
+ let word = "",
11
+ rawWord = "",
12
+ quote = false,
13
+ caret = false;
14
+ const flush = () => {
15
+ if (word || rawWord) {
16
+ tokens.push({ text: word, raw: rawWord || word });
17
+ word = "";
18
+ rawWord = "";
19
+ }
20
+ };
21
+
22
+ for (let i = 0; i < input.length; i += 1) {
23
+ const c = input[i],
24
+ n = input[i + 1];
25
+ if (caret) {
26
+ word += c;
27
+ rawWord += c;
28
+ caret = false;
29
+ continue;
30
+ }
31
+
32
+ if (c === "^" && !quote) {
33
+ rawWord += c;
34
+ caret = true;
35
+ continue;
36
+ }
37
+
38
+ if (c === '"') {
39
+ rawWord += c;
40
+ quote = !quote;
41
+ continue;
42
+ }
43
+
44
+ if (!quote && (c === "&" || c === "|" || c === ">" || c === "<" || c === "(" || c === ")")) {
45
+ flush();
46
+ let op = c;
47
+ if ((c === "&" || c === "|") && n === c) {
48
+ op += n;
49
+ i += 1;
50
+ }
51
+
52
+ if (c === ">" && n === ">") {
53
+ op += n;
54
+ i += 1;
55
+ }
56
+
57
+ tokens.push({ text: op, raw: op });
58
+ if (c === ">" || c === "<") {
59
+ redirects.push(op);
60
+ }
61
+
62
+ continue;
63
+ }
64
+
65
+ if (!quote && /\s/.test(c)) {
66
+ flush();
67
+ continue;
68
+ }
69
+
70
+ word += c;
71
+ rawWord += c;
72
+ }
73
+
74
+ flush();
75
+ if (quote || caret) {
76
+ errors.push("unterminated quote or caret");
77
+ }
78
+
79
+ if (tokens.length > limits.maxTokens) {
80
+ errors.push("token limit exceeded");
81
+ }
82
+
83
+ for (const value of tokens) {
84
+ if (/%[^%\s]+%|![^!\s]+!/.test(value.text)) {
85
+ dynamicConstructs.push({ kind: "environment-expansion" });
86
+ }
87
+ }
88
+
89
+ return { tokens, errors, dynamicConstructs, redirects };
90
+ }
91
+
92
+ function unwrap(tokens) {
93
+ const leaves = [],
94
+ redirects = [];
95
+ let current = [],
96
+ pendingSeparator,
97
+ pendingRedirect;
98
+ const flush = () => {
99
+ if (current.length) {
100
+ const executable = current[0].text.replace(/^@+/, ""),
101
+ args = current.slice(1).map((value) => value.text),
102
+ rawArgs = current.slice(1).map((value) => value.raw);
103
+ leaves.push({
104
+ shell: "cmd",
105
+ executable,
106
+ operation: args.join(" "),
107
+ args,
108
+ rawArgs,
109
+ redactedTarget: redactCommand(args.join(" ")),
110
+ dynamic: /[%!]/.test(executable),
111
+ separatorBefore: pendingSeparator,
112
+ });
113
+ pendingSeparator = undefined;
114
+ current = [];
115
+ }
116
+ };
117
+
118
+ for (const value of tokens) {
119
+ if (["&", "&&", "||", "|", "(", ")"].includes(value.text)) {
120
+ flush();
121
+ pendingSeparator = value.text;
122
+ } else if (value.text === ">" || value.text === ">>" || value.text === "<") {
123
+ pendingRedirect = value.text;
124
+ } else if (pendingRedirect) {
125
+ redirects.push({ operator: pendingRedirect, target: value.text });
126
+ leaves.push({
127
+ shell: "cmd",
128
+ executable: "<redirect>",
129
+ operation: `${pendingRedirect} ${value.text}`,
130
+ args: [value.text],
131
+ rawArgs: [value.raw],
132
+ redactedTarget: redactCommand(value.text),
133
+ redirect: pendingRedirect,
134
+ separatorBefore: pendingSeparator,
135
+ });
136
+ pendingRedirect = undefined;
137
+ } else {
138
+ current.push(value);
139
+ }
140
+ }
141
+
142
+ flush();
143
+
144
+ return { leaves, redirects, missingRedirect: Boolean(pendingRedirect) };
145
+ }
146
+
147
+ export function literalCmdPayload(args, rawArgs = args) {
148
+ for (let index = 0; index < args.length; index += 1) {
149
+ const cooked = String(args[index]),
150
+ raw = String(rawArgs[index] ?? cooked);
151
+ let payload;
152
+ if (/^\/(?:c|k)$/i.test(cooked)) {
153
+ payload = rawArgs.slice(index + 1).join(" ");
154
+ } else {
155
+ const attached = /^\/(?:c|k)(.+)$/i.exec(raw);
156
+ if (attached) {
157
+ payload = [attached[1], ...rawArgs.slice(index + 1)].join(" ");
158
+ }
159
+ }
160
+
161
+ if (payload !== undefined) {
162
+ payload = payload.trim();
163
+ if (payload.length >= 2 && payload.startsWith('"') && payload.endsWith('"')) {
164
+ payload = payload.slice(1, -1);
165
+ }
166
+
167
+ return payload;
168
+ }
169
+ }
170
+
171
+ return undefined;
172
+ }
173
+
174
+ function attachPowerShellPayload(leaf, args, options, limits, dynamicConstructs) {
175
+ const host = analyzeHostArguments(args, { ...options, depth: options.depth || 0, shell: leaf.executable });
176
+ if (host?.nested) {
177
+ leaf.nested = host.nested;
178
+ dynamicConstructs.push({ kind: "nested-powershell" }, ...host.nested.dynamicConstructs);
179
+
180
+ return;
181
+ }
182
+
183
+ if (host?.unresolved) {
184
+ dynamicConstructs.push({ kind: "dynamic-nested-powershell" });
185
+
186
+ return;
187
+ }
188
+
189
+ const file = args.findIndex((arg) => /^-(?:f|fi|fil|file)$/i.test(String(arg)));
190
+ const supportedFile = file >= 0 && /\.(?:ps1|psm1|psd1)$/i.test(String(args[file + 1] || ""));
191
+ const informational = args.some((arg) => /^-(?:version|help|\?)$/i.test(String(arg)));
192
+ const hostOnly =
193
+ informational &&
194
+ args.every((arg) => /^-(?:nologo|noprofile|noninteractive|version|help|\?)$/i.test(String(arg)));
195
+ if ((!supportedFile && !hostOnly) || (options.depth || 0) >= limits.maxDepth) {
196
+ dynamicConstructs.push({ kind: "dynamic-nested-powershell" });
197
+ }
198
+ }
199
+
200
+ export function analyze(input, options = {}) {
201
+ const limits = { ...CMD_LIMITS, ...options };
202
+ if (typeof input !== "string" || Buffer.byteLength(input, "utf8") > limits.maxInput) {
203
+ return {
204
+ shell: "cmd",
205
+ leaves: [],
206
+ redirects: [],
207
+ dynamicConstructs: [{ kind: "input-limit" }],
208
+ parseErrors: [],
209
+ indeterminate: true,
210
+ };
211
+ }
212
+
213
+ const result = lex(input, limits),
214
+ unwrapped = unwrap(result.tokens),
215
+ leaves = unwrapped.leaves;
216
+ const dynamicConstructs = [...result.dynamicConstructs];
217
+ if (unwrapped.missingRedirect) {
218
+ dynamicConstructs.push({ kind: "missing-redirect-target" });
219
+ }
220
+
221
+ for (const leaf of leaves) {
222
+ const n = leaf.executable.toLowerCase().split(/[\\/]/).pop();
223
+ if (n === "cmd" || n === "cmd.exe") {
224
+ const payload = literalCmdPayload(leaf.args, leaf.rawArgs);
225
+ if (!payload || /[%!]/.test(payload) || (options.depth || 0) >= limits.maxDepth) {
226
+ dynamicConstructs.push({ kind: "dynamic-nested-cmd" });
227
+ } else {
228
+ leaf.nested = analyze(payload, { ...options, depth: (options.depth || 0) + 1 });
229
+ dynamicConstructs.push({ kind: "nested-cmd" }, ...leaf.nested.dynamicConstructs);
230
+ }
231
+ } else if (n === "start") {
232
+ const rawArgs = leaf.rawArgs || leaf.args;
233
+ let index = 0;
234
+ const skipSwitches = () => {
235
+ while (index < leaf.args.length && /^\//.test(String(leaf.args[index]))) {
236
+ const option = String(leaf.args[index]);
237
+ index += 1;
238
+ if (/^\/(?:d|node|affinity|machine)$/i.test(option) && index < leaf.args.length) {
239
+ index += 1;
240
+ }
241
+ }
242
+ };
243
+
244
+ skipSwitches();
245
+ if (/^".*"$/s.test(String(rawArgs[index] || ""))) {
246
+ index += 1;
247
+ skipSwitches();
248
+ }
249
+
250
+ const candidate = String(leaf.args[index] || ""),
251
+ candidateName = candidate
252
+ .toLowerCase()
253
+ .replace(/\.exe$/, "")
254
+ .split(/[\\/]/)
255
+ .pop();
256
+ if (!candidate && leaf.args.some((arg) => /^\/(?:\?|help)$/i.test(String(arg)))) {
257
+ /* informational START help */
258
+ } else if (!candidate || (options.depth || 0) >= limits.maxDepth) {
259
+ dynamicConstructs.push({ kind: "dynamic-start" });
260
+ } else if (candidateName === "cmd") {
261
+ const nestedInput = [rawArgs[index] || candidate, ...rawArgs.slice(index + 1)].join(" ");
262
+ if (literalCmdPayload(leaf.args.slice(index + 1), rawArgs.slice(index + 1)) === undefined) {
263
+ dynamicConstructs.push({ kind: "dynamic-start-cmd" });
264
+ } else {
265
+ leaf.nested = analyze(nestedInput, { ...options, depth: (options.depth || 0) + 1 });
266
+ dynamicConstructs.push({ kind: "nested-start-cmd" }, ...leaf.nested.dynamicConstructs);
267
+ }
268
+ } else if (POWERSHELL_HOSTS.includes(candidateName)) {
269
+ const started = {
270
+ ...leaf,
271
+ executable: candidate,
272
+ args: leaf.args.slice(index + 1),
273
+ rawArgs: rawArgs.slice(index + 1),
274
+ operation: leaf.args.slice(index + 1).join(" "),
275
+ };
276
+ attachPowerShellPayload(
277
+ started,
278
+ started.args,
279
+ { ...options, depth: (options.depth || 0) + 1 },
280
+ limits,
281
+ dynamicConstructs,
282
+ );
283
+ if (started.nested) {
284
+ leaf.nested = started.nested;
285
+ }
286
+ }
287
+ } else if (n === "call") {
288
+ const payload = (leaf.rawArgs || leaf.args).join(" ");
289
+ if (!payload || /[%!]/.test(payload) || (options.depth || 0) >= limits.maxDepth) {
290
+ dynamicConstructs.push({ kind: "dynamic-call" });
291
+ } else {
292
+ leaf.nested = analyze(payload, { ...options, depth: (options.depth || 0) + 1 });
293
+ dynamicConstructs.push({ kind: "nested-call" }, ...leaf.nested.dynamicConstructs);
294
+ }
295
+ } else if (POWERSHELL_HOSTS.includes(n.replace(/\.exe$/, ""))) {
296
+ attachPowerShellPayload(leaf, leaf.args, options, limits, dynamicConstructs);
297
+ } else if (n === "for") {
298
+ dynamicConstructs.push({ kind: "dynamic-for" });
299
+ } else if (n === "if" || n === "else") {
300
+ // `if 1==1 rd /s /q C:\Windows` runs the delete. Only `for` was flagged, so the guarded command
301
+ // behind an `if` was parsed as argument text of a program named "if" and never classified.
302
+ const tail = conditionalTail(n, leaf.rawArgs || leaf.args || []);
303
+ if (tail === undefined || (options.depth || 0) >= limits.maxDepth) {
304
+ dynamicConstructs.push({ kind: `dynamic-${n}` });
305
+ } else {
306
+ leaf.nested = analyze(tail, { ...options, depth: (options.depth || 0) + 1 });
307
+ dynamicConstructs.push({ kind: `nested-${n}` }, ...leaf.nested.dynamicConstructs);
308
+ }
309
+ } else if (["wmic", "mshta", "rundll32", "regsvr32"].includes(n)) {
310
+ dynamicConstructs.push({ kind: `dynamic-${n}` });
311
+ }
312
+
313
+ if (n.endsWith(".bat") || n.endsWith(".cmd")) {
314
+ dynamicConstructs.push({ kind: "batch-file" });
315
+ }
316
+ }
317
+
318
+ if (leaves.length > limits.maxLeaves) {
319
+ dynamicConstructs.push({ kind: "leaf-limit" });
320
+ }
321
+
322
+ return {
323
+ shell: "cmd",
324
+ leaves: leaves.slice(0, limits.maxLeaves),
325
+ redirects: unwrapped.redirects,
326
+ dynamicConstructs,
327
+ parseErrors: result.errors,
328
+ indeterminate:
329
+ result.errors.length > 0 ||
330
+ dynamicConstructs.some((item) => /dynamic|batch|limit|missing|expansion/.test(item.kind)) ||
331
+ leaves.some((leaf) => leaf.dynamic || leaf.nested?.indeterminate),
332
+ };
333
+ }
334
+
335
+ // Strips a cmd conditional header and returns the command it guards, or undefined when the shape is not one of
336
+ // the documented forms. Block bodies `( … )` and delayed expansion stay unresolved so the caller asks.
337
+ function conditionalTail(name, args) {
338
+ const list = args.map((arg) => String(arg));
339
+ let index = 0;
340
+ if (name === "if") {
341
+ while (index < list.length && /^(?:\/i|not)$/i.test(list[index])) {
342
+ index += 1;
343
+ }
344
+
345
+ const head = list[index];
346
+ if (head === undefined) {
347
+ return undefined;
348
+ }
349
+
350
+ if (/^(?:errorlevel|exist|defined)$/i.test(head)) {
351
+ index += 2;
352
+ } else if (/==/.test(head)) {
353
+ index += 1;
354
+ } else if (/^(?:equ|neq|lss|leq|gtr|geq)$/i.test(list[index + 1] || "")) {
355
+ index += 3;
356
+ } else {
357
+ return undefined;
358
+ }
359
+ }
360
+
361
+ const tail = list.slice(index);
362
+ if (!tail.length || tail[0].startsWith("(") || tail.some((arg) => /[%!]/.test(arg))) {
363
+ return undefined;
364
+ }
365
+
366
+ return tail.join(" ");
367
+ }
368
+
369
+ export const analyzeCmd = analyze;