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,506 @@
1
+ import crypto from "node:crypto";
2
+ import { analyze as analyzeBash } from "./bash.mjs";
3
+ import { analyze as analyzeCmd } from "./cmd.mjs";
4
+ import { analyze as analyzePowerShell } from "./powershell.mjs";
5
+ import {
6
+ catastrophicTextScan,
7
+ evaluateRules,
8
+ findMutates,
9
+ GUARD_APPROVAL_RULES,
10
+ POLICY_VERSION,
11
+ ruleCatalog,
12
+ } from "./rules.mjs";
13
+ import { classifyPath, pathDecision } from "./paths.mjs";
14
+ import { boundedReason, redactCommand } from "./redact.mjs";
15
+
16
+ export const MODES = Object.freeze(["guard", "strict", "off", "locked"]);
17
+ export const LIMITS = Object.freeze({
18
+ maxInput: 128 * 1024,
19
+ maxDepth: 8,
20
+ maxTokens: 4096,
21
+ maxLeaves: 128,
22
+ timeoutMs: 3000,
23
+ cacheSize: 256,
24
+ });
25
+ const rank = Object.freeze({ low: 0, medium: 1, high: 2, critical: 3 });
26
+ const actionRank = Object.freeze({ allow: 0, ask: 1, deny: 2 });
27
+ const analysisCache = new Map();
28
+ const builtInRuleIds = new Set(ruleCatalog.ruleIds);
29
+ const categories = new Set([
30
+ "filesystem",
31
+ "disk",
32
+ "system",
33
+ "security",
34
+ "process",
35
+ "network",
36
+ "git",
37
+ "database",
38
+ "container",
39
+ "cloud",
40
+ "package",
41
+ "protected-path",
42
+ "dynamic",
43
+ "unknown",
44
+ ]);
45
+
46
+ function empty() {
47
+ return {
48
+ action: "allow",
49
+ severity: "low",
50
+ category: "unknown",
51
+ ruleIds: [],
52
+ leaves: [],
53
+ reason: "No protected operation was detected.",
54
+ indeterminate: false,
55
+ lockSession: false,
56
+ };
57
+ }
58
+
59
+ function malformedDecision() {
60
+ return {
61
+ action: "deny",
62
+ severity: "critical",
63
+ category: "security",
64
+ ruleIds: ["policy.integrity"],
65
+ leaves: [],
66
+ reason: "The command policy returned malformed decision data; execution is denied.",
67
+ indeterminate: true,
68
+ lockSession: false,
69
+ };
70
+ }
71
+
72
+ function validDecision(value) {
73
+ const validLeaf = (leaf) =>
74
+ leaf &&
75
+ typeof leaf === "object" &&
76
+ typeof leaf.executable === "string" &&
77
+ Buffer.byteLength(leaf.executable, "utf8") <= 256 &&
78
+ typeof leaf.operation === "string" &&
79
+ Buffer.byteLength(leaf.operation, "utf8") <= 4096 &&
80
+ (!Object.hasOwn(leaf, "redactedTarget") ||
81
+ (typeof leaf.redactedTarget === "string" && Buffer.byteLength(leaf.redactedTarget, "utf8") <= 512));
82
+
83
+ return (
84
+ value &&
85
+ typeof value === "object" &&
86
+ ["allow", "ask", "deny"].includes(value.action) &&
87
+ Object.hasOwn(rank, value.severity) &&
88
+ categories.has(value.category) &&
89
+ Array.isArray(value.ruleIds) &&
90
+ value.ruleIds.length <= 32 &&
91
+ (value.action === "allow" || value.ruleIds.length > 0) &&
92
+ value.ruleIds.every(
93
+ (id) => typeof id === "string" && /^[a-z][a-z0-9.-]{2,95}$/.test(id) && builtInRuleIds.has(id),
94
+ ) &&
95
+ Array.isArray(value.leaves) &&
96
+ value.leaves.length <= LIMITS.maxLeaves &&
97
+ value.leaves.every(validLeaf) &&
98
+ typeof value.reason === "string" &&
99
+ Buffer.byteLength(value.reason, "utf8") > 0 &&
100
+ Buffer.byteLength(value.reason, "utf8") <= 512 &&
101
+ (!Object.hasOwn(value, "saferAlternative") ||
102
+ (typeof value.saferAlternative === "string" && Buffer.byteLength(value.saferAlternative, "utf8") <= 512)) &&
103
+ (!Object.hasOwn(value, "indeterminate") || typeof value.indeterminate === "boolean") &&
104
+ (!Object.hasOwn(value, "lockSession") || typeof value.lockSession === "boolean")
105
+ );
106
+ }
107
+
108
+ export function aggregateDecisions(decisions, options = {}) {
109
+ if (!Array.isArray(decisions) || decisions.some((item) => !validDecision(item))) {
110
+ return malformedDecision();
111
+ }
112
+
113
+ const list = decisions;
114
+ if (!list.length) {
115
+ return empty();
116
+ }
117
+
118
+ const result = list.reduce((best, item) => {
119
+ const a =
120
+ rank[item.severity] > rank[best.severity] ||
121
+ (rank[item.severity] === rank[best.severity] && actionRank[item.action] > actionRank[best.action]);
122
+ if (!a) {
123
+ return best;
124
+ }
125
+
126
+ return {
127
+ ...item,
128
+ ruleIds: [...new Set(item.ruleIds || [])].slice(0, 32),
129
+ leaves: (item.leaves || []).slice(0, LIMITS.maxLeaves),
130
+ reason: boundedReason(item.reason),
131
+ };
132
+ }, empty());
133
+ const lockSession = list.some(
134
+ (item) => item.lockSession === true && item.action === "deny" && item.severity === "critical",
135
+ );
136
+ if (result.action === "deny" || result.severity === "critical") {
137
+ return { ...result, action: "deny", indeterminate: Boolean(result.indeterminate), lockSession };
138
+ }
139
+
140
+ if (result.action === "ask" && options.hasUI === false) {
141
+ return {
142
+ ...result,
143
+ action: "deny",
144
+ reason: "Approval is unavailable in this non-interactive session.",
145
+ indeterminate: Boolean(result.indeterminate),
146
+ lockSession: false,
147
+ };
148
+ }
149
+
150
+ return { ...result, indeterminate: Boolean(result.indeterminate), lockSession: false };
151
+ }
152
+
153
+ export function analyzeCommand(command, options = {}) {
154
+ const shell = String(options.shell || (options.platform === "win32" ? "powershell" : "bash")).toLowerCase();
155
+ const cacheKey =
156
+ options.cache !== false && typeof command === "string" && Buffer.byteLength(command, "utf8") <= LIMITS.maxInput
157
+ ? sha256(
158
+ JSON.stringify({
159
+ shell,
160
+ command,
161
+ cwd: options.cwd || "",
162
+ parser: 1,
163
+ policy: POLICY_VERSION,
164
+ helper: options.helperPath || "",
165
+ executable: options.executable || "",
166
+ }),
167
+ )
168
+ : "";
169
+ if (cacheKey && analysisCache.has(cacheKey)) {
170
+ const cached = analysisCache.get(cacheKey);
171
+ analysisCache.delete(cacheKey);
172
+ analysisCache.set(cacheKey, cached);
173
+
174
+ return structuredClone(cached);
175
+ }
176
+
177
+ let result;
178
+ try {
179
+ if (shell === "powershell" || shell === "pwsh") {
180
+ result = analyzePowerShell(command, options);
181
+ } else if (shell === "cmd" || shell === "cmd.exe") {
182
+ result = analyzeCmd(command, options);
183
+ } else {
184
+ result = analyzeBash(command, options);
185
+ }
186
+ } catch {
187
+ result = {
188
+ shell,
189
+ leaves: [],
190
+ redirects: [],
191
+ dynamicConstructs: [{ kind: "parser-exception" }],
192
+ parseErrors: [],
193
+ indeterminate: true,
194
+ };
195
+ }
196
+
197
+ try {
198
+ attachBashHostPayloads(result, options);
199
+ } catch {
200
+ result.dynamicConstructs.push({ kind: "parser-exception" });
201
+ result.indeterminate = true;
202
+ }
203
+
204
+ if (cacheKey) {
205
+ analysisCache.set(cacheKey, result);
206
+ if (analysisCache.size > LIMITS.cacheSize) {
207
+ analysisCache.delete(analysisCache.keys().next().value);
208
+ }
209
+ }
210
+
211
+ return structuredClone(result);
212
+ }
213
+
214
+ export function clearAnalysisCache() {
215
+ analysisCache.clear();
216
+ }
217
+
218
+ const BASH_HOSTS = new Set(["bash", "sh", "zsh", "dash", "ksh", "fish"]);
219
+ function attachBashHostPayloads(analysis, options, depth = options.depth || 0) {
220
+ for (const leaf of analysis.leaves || []) {
221
+ if (leaf.nested) {
222
+ attachBashHostPayloads(leaf.nested, options, depth + 1);
223
+ continue;
224
+ }
225
+
226
+ const name = String(leaf.executable || "")
227
+ .toLowerCase()
228
+ .replace(/\.exe$/, "")
229
+ .split(/[\\/]/)
230
+ .pop();
231
+ if (!BASH_HOSTS.has(name)) {
232
+ continue;
233
+ }
234
+
235
+ const flag = (leaf.args || []).findIndex((arg) => /^-[a-z]*c[a-z]*$/i.test(String(arg)));
236
+ const payload = flag >= 0 ? leaf.args[flag + 1] : undefined;
237
+ if (
238
+ typeof payload !== "string" ||
239
+ !payload ||
240
+ /\[dynamic\]|[$`]/.test(payload) ||
241
+ depth >= (options.maxDepth || LIMITS.maxDepth)
242
+ ) {
243
+ if (flag >= 0) {
244
+ analysis.dynamicConstructs.push({ kind: "dynamic-nested-bash" });
245
+ analysis.indeterminate = true;
246
+ }
247
+
248
+ continue;
249
+ }
250
+
251
+ leaf.nested = analyzeBash(payload, { ...options, shell: "bash", depth: depth + 1 });
252
+ analysis.dynamicConstructs.push({ kind: "nested-bash" }, ...leaf.nested.dynamicConstructs);
253
+ analysis.indeterminate ||= leaf.nested.indeterminate;
254
+ attachBashHostPayloads(leaf.nested, options, depth + 1);
255
+ }
256
+ }
257
+
258
+ function flattenLeaves(analysis) {
259
+ return [
260
+ ...(analysis.leaves || []),
261
+ ...(analysis.leaves || []).flatMap((leaf) => (leaf.nested ? flattenLeaves(leaf.nested) : [])),
262
+ ];
263
+ }
264
+
265
+ function clearlyReadOnly(analysis) {
266
+ const leaves = flattenLeaves(analysis).filter((leaf) => leaf.executable !== "<redirect>");
267
+ if (
268
+ !leaves.length ||
269
+ (analysis.redirects || []).some((redirect) =>
270
+ String(typeof redirect === "string" ? redirect : redirect.operator || "").includes(">"),
271
+ )
272
+ ) {
273
+ return false;
274
+ }
275
+
276
+ return leaves.every((leaf) => {
277
+ const name = String(leaf.executable || "")
278
+ .toLowerCase()
279
+ .replace(/\.exe$/, "")
280
+ .split(/[\\/]/)
281
+ .pop();
282
+ if (
283
+ [
284
+ "pwd",
285
+ "ls",
286
+ "printf",
287
+ "echo",
288
+ "cat",
289
+ "less",
290
+ "more",
291
+ "true",
292
+ "false",
293
+ "whoami",
294
+ "id",
295
+ "uname",
296
+ "date",
297
+ "basename",
298
+ "dirname",
299
+ "realpath",
300
+ "which",
301
+ "where",
302
+ "type",
303
+ "grep",
304
+ "rg",
305
+ "head",
306
+ "tail",
307
+ "wc",
308
+ ].includes(name)
309
+ ) {
310
+ return true;
311
+ }
312
+
313
+ if (name === "find") {
314
+ return !findMutates(leaf.args);
315
+ }
316
+
317
+ if (name === "git") {
318
+ return (
319
+ ["status", "diff", "log", "show", "blame"].includes(String(leaf.args?.[0] || "").toLowerCase()) ||
320
+ (String(leaf.args?.[0] || "").toLowerCase() === "branch" &&
321
+ (leaf.args || []).some((arg) => ["--list", "-l"].includes(arg)))
322
+ );
323
+ }
324
+
325
+ if (/^(?:get|test|select|where|measure|compare)-/.test(name)) {
326
+ return true;
327
+ }
328
+
329
+ return ["gci", "gl", "gps", "pwd", "dir"].includes(name);
330
+ });
331
+ }
332
+
333
+ export function decideCommand(command, options = {}) {
334
+ const mode = MODES.includes(options.mode) ? options.mode : "guard";
335
+ if (mode === "off") {
336
+ return { ...empty(), reason: "Command guard is off for this session." };
337
+ }
338
+
339
+ if (mode === "locked") {
340
+ return {
341
+ action: "deny",
342
+ severity: "critical",
343
+ category: "security",
344
+ ruleIds: ["session.locked"],
345
+ leaves: [],
346
+ reason: "The command guard is locked after a critical attempt.",
347
+ indeterminate: false,
348
+ lockSession: false,
349
+ };
350
+ }
351
+
352
+ if (typeof command !== "string") {
353
+ return malformedDecision();
354
+ }
355
+
356
+ // Enforcement always reparses the complete call. The cache is reserved for explicit analysis-only callers;
357
+ // an approval must never reuse parser state from an earlier attempt.
358
+ const analysis = analyzeCommand(command, { ...options, cache: false });
359
+ const fatalKinds = new Set([
360
+ "helper-unavailable",
361
+ "helper-failure",
362
+ "helper-timeout",
363
+ "invalid-helper-json",
364
+ "invalid-helper-result",
365
+ "parser-exception",
366
+ "input-limit",
367
+ "output-limit",
368
+ "token-limit",
369
+ "command-limit",
370
+ "element-limit",
371
+ "redirection-limit",
372
+ "literal-limit",
373
+ "leaf-limit",
374
+ "depth-limit",
375
+ ]);
376
+ const parserFailure =
377
+ (analysis.dynamicConstructs || []).some((entry) => fatalKinds.has(entry.kind)) ||
378
+ (analysis.parseErrors || []).some((error) =>
379
+ /\blimit\b/i.test(typeof error === "string" ? error : error?.message || ""),
380
+ );
381
+ const decisions = evaluateRules(analysis, { ...options, criticalOnly: parserFailure });
382
+ if (parserFailure) {
383
+ // The structural analysis is unusable, so fall back to reading the raw text before settling for an ask.
384
+ const unparsed = catastrophicTextScan(command, options);
385
+ if (unparsed) {
386
+ decisions.push(unparsed);
387
+ }
388
+
389
+ decisions.push({
390
+ action: "ask",
391
+ severity: "high",
392
+ category: "dynamic",
393
+ ruleIds: ["parser.indeterminate"],
394
+ leaves: [],
395
+ reason: "The command parser is unavailable, exceeded a safety limit, or failed; approval is required.",
396
+ indeterminate: true,
397
+ });
398
+ }
399
+
400
+ if ((analysis.parseErrors || []).length) {
401
+ decisions.push({
402
+ action: "ask",
403
+ severity: "high",
404
+ category: "dynamic",
405
+ ruleIds: ["parser.syntax"],
406
+ leaves: [],
407
+ reason: "Malformed shell syntax requires approval.",
408
+ indeterminate: true,
409
+ });
410
+ }
411
+
412
+ if (analysis.indeterminate && !decisions.some((item) => item.ruleIds?.includes("parser.indeterminate"))) {
413
+ decisions.push({
414
+ action: "ask",
415
+ severity: "high",
416
+ category: "dynamic",
417
+ ruleIds: ["parser.indeterminate"],
418
+ leaves: [],
419
+ reason: "The command could not be completely analyzed and requires approval.",
420
+ indeterminate: true,
421
+ });
422
+ }
423
+
424
+ // Guard stays quiet for determinate noncritical work, with one explicit exception set: rules such as
425
+ // git.force-push and git.destructive that discard or rewrite work still surface as approvals rather than
426
+ // running silently.
427
+ const applicable =
428
+ mode === "guard"
429
+ ? decisions.filter(
430
+ (item) =>
431
+ item.action === "deny" ||
432
+ item.severity === "critical" ||
433
+ item.indeterminate ||
434
+ (item.ruleIds || []).some((id) => GUARD_APPROVAL_RULES.has(id)),
435
+ )
436
+ : decisions;
437
+ const result = aggregateDecisions(applicable, options);
438
+ if (mode === "strict" && result.action === "allow" && !clearlyReadOnly(analysis)) {
439
+ return {
440
+ ...aggregateDecisions(
441
+ [
442
+ {
443
+ action: "ask",
444
+ severity: "medium",
445
+ category: "unknown",
446
+ ruleIds: ["strict.execution"],
447
+ leaves: [],
448
+ reason: "Strict mode requires approval for commands that are not proven read-only.",
449
+ },
450
+ ],
451
+ options,
452
+ ),
453
+ indeterminate: false,
454
+ };
455
+ }
456
+
457
+ return {
458
+ ...result,
459
+ indeterminate: Boolean(result.indeterminate || analysis.indeterminate),
460
+ lockSession: Boolean(result.lockSession),
461
+ };
462
+ }
463
+
464
+ export function decidePath(input, operation, options = {}) {
465
+ const mode = MODES.includes(options.mode) ? options.mode : "guard";
466
+ if (mode === "off") {
467
+ return { ...empty(), reason: "Command guard is off for this session." };
468
+ }
469
+
470
+ if (mode === "locked") {
471
+ return {
472
+ action: "deny",
473
+ severity: "critical",
474
+ category: "security",
475
+ ruleIds: ["session.locked"],
476
+ leaves: [],
477
+ reason: "The command guard is locked after a critical attempt.",
478
+ indeterminate: false,
479
+ lockSession: false,
480
+ };
481
+ }
482
+
483
+ const classification = classifyPath(input, { ...options, read: operation === "read" });
484
+ const decision = pathDecision(input, { ...options, read: operation === "read" });
485
+ if (mode === "strict" && operation !== "read" && decision.action === "allow") {
486
+ decision.action = "ask";
487
+ decision.severity = "medium";
488
+ decision.category = "filesystem";
489
+ decision.ruleIds = ["strict.mutation"];
490
+ decision.reason = "Strict mode requires approval for mutation.";
491
+ }
492
+
493
+ return {
494
+ ...aggregateDecisions([decision], options),
495
+ indeterminate: Boolean(classification.indeterminate),
496
+ lockSession: Boolean(decision.lockSession && operation !== "read"),
497
+ };
498
+ }
499
+
500
+ export function sha256(value) {
501
+ return crypto.createHash("sha256").update(String(value)).digest("hex");
502
+ }
503
+
504
+ export const evaluatePolicy = decideCommand;
505
+ export const policyDecision = decideCommand;
506
+ export { redactCommand };