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,398 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { COMMAND_GUARD_MANAGED_FILES } from "./managed-files.mjs";
5
+
6
+ export const PATH_LIMIT = 4096;
7
+ const unixProtected = [
8
+ /^\/$/,
9
+ /^\/boot(?:\/|$)/i,
10
+ /^\/etc(?:\/|$)/i,
11
+ /^\/bin(?:\/|$)/i,
12
+ /^\/sbin(?:\/|$)/i,
13
+ /^\/(?:usr|lib|lib64|opt|srv|var)(?:\/|$)/i,
14
+ /^\/root\/?$/i,
15
+ /^\/home\/?$/i,
16
+ /^\/home\/[^/]+\/?$/i,
17
+ // macOS system roots. /System/Volumes/Data/… is the firmlinked user-data tree, not system state.
18
+ /^\/System(?:$|\/(?!Volumes\/Data\/.))/i,
19
+ /^\/(?:Library|Applications|cores)(?:\/|$)/i,
20
+ /^\/Users\/?$/i,
21
+ /^\/Users\/[^/]+\/?$/i,
22
+ /^\/Volumes\/?$/i,
23
+ /^\/Volumes\/[^/]+\/?$/i,
24
+ /^\/private\/?$/i,
25
+ /^\/private\/(?:etc|var|db)(?:\/|$)/i,
26
+ ];
27
+ const unixCatastrophic = [
28
+ /^\/$/,
29
+ /^\/boot(?:\/|$)/i,
30
+ /^\/(?:etc|bin|sbin|lib|lib64|usr|opt|srv|var)\/?$/i,
31
+ /^\/usr\/(?:bin|sbin|lib|lib64)\/?$/i,
32
+ /^\/var\/lib\/?$/i,
33
+ // /etc, /var and /db are firmlinked to /private/… on macOS, so the /private spelling reaches the same
34
+ // system state and must carry the same weight as the short form.
35
+ /^\/private\/(?:etc|var|db)\/?$/i,
36
+ /^\/(?:root|home|Users|Volumes|private)\/?$/i,
37
+ /^\/(?:home|Users|Volumes)\/[^/]+\/?$/i,
38
+ /^\/System\/?$/i,
39
+ /^\/System\/Library\/?$/i,
40
+ /^\/(?:Library|Applications|cores)\/?$/i,
41
+ /^\/Library\/(?:LaunchDaemons|LaunchAgents|PrivilegedHelperTools)(?:\/|$)/i,
42
+ /^\/(?:etc|private\/etc)\/(?:passwd|shadow|sudoers|fstab|crypttab|ld\.so\.preload)$/i,
43
+ /^\/(?:bin|sbin)\/(?:sh|bash|init|systemd)$/i,
44
+ /^\/usr\/lib\/systemd\/systemd$/i,
45
+ ];
46
+ const unixSensitive = [
47
+ /(?:^|\/)(?:\.bashrc|\.bash_profile|\.bash_login|\.profile|\.zshrc|\.zshenv|\.zprofile|\.zlogin)$/i,
48
+ ];
49
+ const windowsProtected = [
50
+ /^[a-z]:[\\/]$/i,
51
+ /^[a-z]:[\\/]Windows(?:[\\/]|$)/i,
52
+ /^[a-z]:[\\/](?:Boot|EFI|Recovery)(?:[\\/]|$)/i,
53
+ /^[a-z]:[\\/](?:ProgramData|Program Files(?: \(x86\))?)(?:[\\/]|$)/i,
54
+ /^[a-z]:[\\/]Users[\\/]?$/i,
55
+ /^[a-z]:[\\/]Users[\\/][^\\/]+[\\/]?$/i,
56
+ /^\\\\[^\\/]+\\[^\\/]+[\\/]?$/i,
57
+ ];
58
+ const windowsCatastrophic = [
59
+ /^[a-z]:[\\\/]$/i,
60
+ /^[a-z]:[\\\/]Windows[\\\/]?$/i,
61
+ /^[a-z]:[\\\/]Windows[\\\/]System32[\\\/]?$/i,
62
+ // The EFI system partition and the Recovery tree are boot state: losing either can leave an unbootable host.
63
+ /^[a-z]:[\\\/](?:Boot|EFI|Recovery)(?:[\\\/]|$)/i,
64
+ /^[a-z]:[\\\/]Users[\\\/]?$/i,
65
+ /^[a-z]:[\\\/]Users[\\\/][^\\\/]+[\\\/]?$/i,
66
+ /^[a-z]:[\\\/]Windows[\\\/]System32[\\\/]config[\\\/](?:SAM|SECURITY|SYSTEM)$/i,
67
+ /^[a-z]:[\\\/]Windows[\\\/](?:System32[\\\/])?(?:ntoskrnl\.exe|winload\.exe)$/i,
68
+ /^\\\\[^\\\/]+\\[^\\\/]+[\\\/]?$/i,
69
+ ];
70
+ const windowsSensitive = [
71
+ /(?:^|[\\/])Documents[\\/](?:WindowsPowerShell|PowerShell)[\\/](?:Microsoft\.)?PowerShell_profile\.ps1$/i,
72
+ /(?:^|[\\/])profile\.ps1$/i,
73
+ ];
74
+ // Credential locations are matched by credential-shaped NAMES, never by bare words that ordinary source trees
75
+ // use as directories. `credentials`, `token`, `secret`, `passwd` and `shadow` were matched as standalone path
76
+ // segments, so a monorepo's packages/token/, src/secret/ or app/credentials/ became a critical read denial on
77
+ // every file beneath them. Each now needs a dot prefix, a credential extension, or a system location.
78
+ const privatePath =
79
+ /^\/proc\/(?:\d+|self|thread-self)\/(?:environ|mem)(?:$|\/)|(?:^|[\\/])(?:\.ssh|\.aws|\.azure|\.gnupg)(?:[\\/]|$)|(?:^|[\\/])(?:\.npmrc|\.netrc|\.pypirc|\.git-credentials|\.credentials|\.token|\.secret|\.secrets|id_rsa|id_ed25519|id_ecdsa|id_dsa|private\.key|config\.gcloud|hosts\.yml|ntuser\.dat|login data)(?:$|[\\/])|^\/(?:etc|private\/etc)\/(?:passwd|shadow|gshadow|sudoers)(?:$|[\\/])|(?:^|[\\/])(?:credentials|token|secret|secrets)\.(?:json|ya?ml|ini|toml|txt|enc)$|(?:^|[\\/])(?:\.docker|\.kube)[\\/]config(?:\.json)?$|(?:^|[\\/])\.config[\\/]gcloud(?:[\\/]|$)|(?:^|[\\/])Windows[\\/]System32[\\/]config[\\/](?:SAM|SECURITY|SYSTEM)(?:$|[\\/])|(?:^|[\\/])(?:AppData[\\/](?:Roaming|Local)[\\/])?Microsoft[\\/](?:Credentials|Vault|Protect)(?:[\\/]|$)|(?:^|[\\/])Library[\\/]Keychains(?:[\\/]|$)|\.(?:pem|p12|pfx|key)$/i;
80
+
81
+ // A .pi directory is Pi state wherever it appears, including the default agent directory.
82
+ const dotPi = /(?:^|[\\/])\.pi(?:[\\/]|$)/i;
83
+ // Pi and SpecPi private state is identified by LOCATION, not by name. Matching these as bare relative segments
84
+ // protected every repository that merely contained a specpi/ or extensions/command-guard/ path — SpecPi's own
85
+ // source tree included — and the unanchored POSIX variant matched ordinary project files such as
86
+ // src/api/session.ts ("pi" inside "api", then "session"), denying them critically and locking the session.
87
+ const agentPrivateState = /^(?:specpi[\\/](?:manifest\.json|backups|wishlist))(?:[\\/]|$)/i;
88
+ const agentPrivateName = /^(?:auth|sessions?|history|missions?|trust|private)[^\\/]*(?:[\\/]|$)/i;
89
+ const agentGuardSource = /^extensions[\\/]command-guard(?:[\\/]|$)/i;
90
+ // The installed state Guard must keep intact to keep enforcing, expressed as path segments so containment can be
91
+ // tested in BOTH directions. A regex prefix test only answers "is the target inside this subtree"; deleting an
92
+ // ancestor that CONTAINS the subtree reaches the same state and must weigh the same.
93
+ const enforcementNodes = [
94
+ ["settings.json"],
95
+ ["specpi", "manifest.json"],
96
+ ...COMMAND_GUARD_MANAGED_FILES.map((name) => ["extensions", "command-guard", name]),
97
+ ];
98
+ function agentDirectories(windows) {
99
+ const api = windows ? path.win32 : path.posix;
100
+ const configured = process.env.PI_CODING_AGENT_DIR;
101
+ const fallback = api.join(slash(os.homedir(), windows), ".pi", "agent");
102
+ const lexical = api.resolve(configured ? slash(configured, windows) : fallback);
103
+ const canonical = canonicalNearest(lexical, windows);
104
+ const compare = (entry) => (windows ? entry.toLowerCase() : entry);
105
+ const roots = canonical ? [lexical, slash(canonical, windows)] : [lexical];
106
+
107
+ return roots.filter((root, index) => roots.findIndex((entry) => compare(entry) === compare(root)) === index);
108
+ }
109
+
110
+ // Returns the path's location relative to either the lexical or canonical agent directory, or undefined when it
111
+ // is outside both. Comparing both roots prevents a configured symlink or junction from creating an alternate
112
+ // spelling that bypasses enforcement-node protection.
113
+ function agentRelative(value, windows) {
114
+ const compare = (entry) => (windows ? entry.toLowerCase() : entry);
115
+ const separator = windows ? "\\" : "/";
116
+ const candidate = compare(value);
117
+ for (const root of agentDirectories(windows)) {
118
+ const base = compare(root);
119
+ if (candidate === base) {
120
+ return "";
121
+ }
122
+
123
+ if (candidate.startsWith(`${base}${separator}`)) {
124
+ return value.slice(root.length + 1);
125
+ }
126
+ }
127
+
128
+ return undefined;
129
+ }
130
+
131
+ // True when mutating `value` would reach guard-enforcement state, whether the target is a managed node, IS the
132
+ // agent directory, or is an ancestor that contains one. Managed files are checked as nodes rather than protecting
133
+ // the whole command-guard directory, so unrelated test descendants remain ordinary work.
134
+ function reachesEnforcementState(value, windows) {
135
+ const relative = agentRelative(value, windows);
136
+ if (relative === undefined) {
137
+ return false;
138
+ }
139
+
140
+ if (relative === "") {
141
+ return true;
142
+ }
143
+
144
+ const compare = (entry) => (windows ? entry.toLowerCase() : entry);
145
+ const parts = relative.split(/[\\/]/).filter(Boolean);
146
+
147
+ return enforcementNodes.some((node) => {
148
+ if (parts.length > node.length) {
149
+ return false;
150
+ }
151
+
152
+ return parts.every((part, index) => compare(part) === compare(node[index]));
153
+ });
154
+ }
155
+
156
+ function slash(value, windows) {
157
+ return windows ? value.replaceAll("/", "\\") : value.replaceAll("\\", "/");
158
+ }
159
+
160
+ function lexical(value, cwd, windows) {
161
+ let raw = String(value).slice(0, PATH_LIMIT);
162
+ if (!windows && raw === "~") {
163
+ raw = os.homedir();
164
+ } else if (!windows && raw.startsWith("~/")) {
165
+ raw = path.posix.join(os.homedir(), raw.slice(2));
166
+ }
167
+
168
+ if (windows) {
169
+ const normalized = slash(raw, true);
170
+ const base =
171
+ path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")
172
+ ? normalized
173
+ : path.win32.resolve(slash(cwd, true), normalized);
174
+
175
+ return trimWin32Components(path.win32.normalize(base).replace(/\\+$/, (m) => (m.length > 1 ? "\\" : m)));
176
+ }
177
+
178
+ return path.posix.normalize(path.posix.isAbsolute(raw) ? raw : path.posix.resolve(cwd, raw));
179
+ }
180
+
181
+ // Win32 discards trailing dots and spaces from every path component, so `C:\Windows.` and `C:\Windows ` open
182
+ // `C:\Windows`. Comparing the untrimmed spelling let that punctuation walk a protected target past every pattern.
183
+ // `.` and `..` are real components and must survive.
184
+ function trimWin32Components(value) {
185
+ const prefix = /^(\\\\[?.]\\|\\\\)/.exec(value);
186
+ const head = prefix ? prefix[0] : "";
187
+ const body = value.slice(head.length);
188
+
189
+ return (
190
+ head +
191
+ body
192
+ .split("\\")
193
+ .map((part) => (part === "." || part === ".." ? part : part.replace(/[. ]+$/, "") || part))
194
+ .join("\\")
195
+ );
196
+ }
197
+
198
+ function canonicalNearest(value, windows) {
199
+ const api = windows ? fs.realpathSync.native : fs.realpathSync;
200
+ const pathApi = windows ? path.win32 : path.posix;
201
+ let current = value;
202
+ try {
203
+ return api(current);
204
+ } catch {
205
+ /* Find the nearest existing ancestor without enumerating protected directories. */
206
+ }
207
+
208
+ const suffix = [];
209
+ while (current && current !== pathApi.dirname(current)) {
210
+ suffix.unshift(pathApi.basename(current));
211
+ current = pathApi.dirname(current);
212
+ try {
213
+ return pathApi.join(api(current), ...suffix);
214
+ } catch {
215
+ /* continue */
216
+ }
217
+ }
218
+
219
+ return undefined;
220
+ }
221
+
222
+ function isProtected(value, windows, read, mode) {
223
+ if (read && (privatePath.test(value) || dotPi.test(value))) {
224
+ return true;
225
+ }
226
+
227
+ const system = (windows ? windowsProtected : unixProtected).some((pattern) => pattern.test(value));
228
+ const catastrophic = (windows ? windowsCatastrophic : unixCatastrophic).some((pattern) => pattern.test(value));
229
+ if (!read && (mode === "guard" || mode === "strict") && catastrophic) {
230
+ return true;
231
+ }
232
+
233
+ const enforcement = !read && reachesEnforcementState(value, windows);
234
+ if (!read && (mode === "guard" || mode === "strict")) {
235
+ return enforcement;
236
+ }
237
+
238
+ const relative = agentRelative(value, windows);
239
+ const agentPrivate =
240
+ relative !== undefined && (agentPrivateState.test(relative) || agentPrivateName.test(relative));
241
+ if (read) {
242
+ return agentPrivate;
243
+ }
244
+
245
+ const sensitive = (windows ? windowsSensitive : unixSensitive).some((pattern) => pattern.test(value));
246
+
247
+ return (
248
+ system ||
249
+ sensitive ||
250
+ privatePath.test(value) ||
251
+ dotPi.test(value) ||
252
+ agentPrivate ||
253
+ (relative !== undefined && agentGuardSource.test(relative))
254
+ );
255
+ }
256
+
257
+ export function classifyPath(input, options = {}) {
258
+ const windows =
259
+ options.platform === "win32" ||
260
+ options.platform === "windows" ||
261
+ (process.platform === "win32" && !options.platform);
262
+ const cwd = typeof options.cwd === "string" && options.cwd ? options.cwd : process.cwd();
263
+ if (typeof input !== "string" || !input || Buffer.byteLength(input, "utf8") > PATH_LIMIT) {
264
+ return { protected: false, indeterminate: true, reason: "The path is malformed or exceeds the safety limit." };
265
+ }
266
+
267
+ let requested = input;
268
+ const shellName = String(options.shell || "").toLowerCase();
269
+ const bashOnWindows = windows && /^(?:bash|sh|zsh|dash|ksh|fish)$/i.test(shellName);
270
+ // PowerShell resolves ~ to the user profile exactly as a POSIX shell does, so `Remove-Item -Recurse -Force ~`
271
+ // is whole-profile deletion. Expanding it only for bash left the native Windows shell spelling unprotected.
272
+ const tildeHome = bashOnWindows || !windows || /^(?:powershell|pwsh)$/.test(shellName);
273
+ if (windows && tildeHome && (requested === "~" || /^~[\\/]/.test(requested))) {
274
+ requested = path.win32.join(os.homedir(), requested === "~" ? "" : slash(requested.slice(2), true));
275
+ } else if (bashOnWindows) {
276
+ const msysDrive = /^\/(?:cygdrive\/)?([a-z])(?:\/(.*))?$/i.exec(requested);
277
+ if (msysDrive) {
278
+ requested = `${msysDrive[1]}:\\${String(msysDrive[2] || "").replaceAll("/", "\\")}`;
279
+ }
280
+ }
281
+
282
+ const lexicalPath = lexical(requested, cwd, windows);
283
+ const protectedLexical = isProtected(lexicalPath, windows, Boolean(options.read), options.mode);
284
+ const normalizedCwd = lexical(cwd, cwd, windows);
285
+ const comparable = (value) => (windows ? value.toLowerCase() : value);
286
+ const within = (candidate, root) =>
287
+ comparable(candidate) === comparable(root) ||
288
+ comparable(candidate).startsWith(`${comparable(root)}${windows ? "\\" : "/"}`);
289
+ if (options.read && protectedLexical) {
290
+ return {
291
+ input,
292
+ lexical: lexicalPath,
293
+ canonical: undefined,
294
+ protected: true,
295
+ device: false,
296
+ ads: false,
297
+ withinWorkspace: within(lexicalPath, normalizedCwd),
298
+ indeterminate: false,
299
+ kind: "protected",
300
+ };
301
+ }
302
+
303
+ const canonicalPath = canonicalNearest(lexicalPath, windows);
304
+ const protectedCanonical = canonicalPath
305
+ ? isProtected(slash(canonicalPath, windows), windows, Boolean(options.read), options.mode)
306
+ : false;
307
+ const canonicalCwd = canonicalNearest(normalizedCwd, windows);
308
+ const withinWorkspace =
309
+ within(lexicalPath, normalizedCwd) && (!canonicalPath || !canonicalCwd || within(canonicalPath, canonicalCwd));
310
+ const safePseudoDevice =
311
+ !windows && /^\/dev\/(?:null|zero|random|urandom|stdin|stdout|stderr|fd\/[012])$/i.test(lexicalPath);
312
+ const device = windows
313
+ ? lexicalPath.startsWith("\\\\.") || lexicalPath.startsWith("\\\\?")
314
+ ? lexicalPath[3] === "\\"
315
+ : lexicalPath.toLowerCase().startsWith("\\\\device\\")
316
+ : !safePseudoDevice && /^\/dev(?:\/|$)/.test(lexicalPath);
317
+ const ads = windows && /:[^\\/]+$/.test(lexicalPath.slice(3));
318
+ const indeterminate = !canonicalPath && (protectedLexical || (windows && lexicalPath.startsWith("\\\\")));
319
+
320
+ return {
321
+ input,
322
+ lexical: lexicalPath,
323
+ canonical: canonicalPath,
324
+ protected: protectedLexical || protectedCanonical || device || ads,
325
+ device,
326
+ ads,
327
+ withinWorkspace,
328
+ indeterminate,
329
+ kind: device
330
+ ? "device"
331
+ : ads
332
+ ? "alternate-data-stream"
333
+ : protectedLexical || protectedCanonical
334
+ ? "protected"
335
+ : "ordinary",
336
+ };
337
+ }
338
+
339
+ // True when the path resolves to installed state required to enforce the guard. A checkout that merely
340
+ // contains a specpi/ or extensions/command-guard/ directory remains ordinary work.
341
+ export function isAgentPath(input, options = {}) {
342
+ const result = classifyPath(input, options);
343
+ if (typeof result.lexical !== "string") {
344
+ return false;
345
+ }
346
+
347
+ const windows =
348
+ options.platform === "win32" ||
349
+ options.platform === "windows" ||
350
+ (process.platform === "win32" && !options.platform);
351
+
352
+ return (
353
+ reachesEnforcementState(result.lexical, windows) ||
354
+ (result.canonical !== undefined && reachesEnforcementState(slash(result.canonical, windows), windows))
355
+ );
356
+ }
357
+
358
+ export function normalizePath(input, options = {}) {
359
+ return classifyPath(input, options).lexical;
360
+ }
361
+
362
+ export function isProtectedPath(input, options = {}) {
363
+ return classifyPath(input, options).protected;
364
+ }
365
+
366
+ export function pathDecision(input, options = {}) {
367
+ const result = classifyPath(input, options);
368
+
369
+ return result.protected
370
+ ? {
371
+ action: "deny",
372
+ severity: "critical",
373
+ category: "protected-path",
374
+ ruleIds: ["path.protected"],
375
+ leaves: [],
376
+ reason: "The requested path is protected.",
377
+ lockSession: !options.read,
378
+ }
379
+ : result.indeterminate
380
+ ? {
381
+ action: "ask",
382
+ severity: "high",
383
+ category: "protected-path",
384
+ ruleIds: ["path.canonicalization"],
385
+ leaves: [],
386
+ reason: "The requested path could not be safely canonicalized.",
387
+ lockSession: false,
388
+ }
389
+ : {
390
+ action: "allow",
391
+ severity: "low",
392
+ category: "filesystem",
393
+ ruleIds: [],
394
+ leaves: [],
395
+ reason: "The path is outside protected locations.",
396
+ lockSession: false,
397
+ };
398
+ }
@@ -0,0 +1,47 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ $maxInput = 131072
3
+ $maxTokens = 4096
4
+ $maxCommands = 128
5
+ $maxLiteral = 65536
6
+ $text = [Console]::In.ReadToEnd()
7
+ $edition = if ($PSVersionTable.PSEdition -eq 'Desktop') { 'Desktop' } else { 'Core' }
8
+ function Clip([string]$s, [int]$n = 512) { if ($null -eq $s) { return $null }; if ($s.Length -gt $n) { return $s.Substring(0, $n) }; return $s }
9
+ function Pos($e) { if ($null -eq $e) { return @{ start = 0; end = 0 } }; return @{ start = [int]$e.StartOffset; end = [int]$e.EndOffset } }
10
+ function DynamicNode($n) { return ($n -is [System.Management.Automation.Language.ScriptBlockExpressionAst] -or $n -is [System.Management.Automation.Language.SubExpressionAst] -or $n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -or $n -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) }
11
+ try {
12
+ if ($text.Length -gt $maxInput) { throw 'input limit exceeded' }
13
+ $tokens = $null; $errors = $null
14
+ $ast = [System.Management.Automation.Language.Parser]::ParseInput($text, [ref]$tokens, [ref]$errors)
15
+ $errorItems = @($errors | Select-Object -First 32 | ForEach-Object { $p = Pos $_.Extent; @{ errorId = Clip $_.ErrorId 96; start = $p.start; end = $p.end; message = Clip $_.Message 256 } })
16
+ $commands = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object {
17
+ $command = $_; $p = Pos $command.Extent; $name = $command.GetCommandName()
18
+ $pipeline = $command.Parent; while ($null -ne $pipeline -and -not ($pipeline -is [System.Management.Automation.Language.PipelineAst])) { $pipeline = $pipeline.Parent }
19
+ $pipelineStart = if ($null -ne $pipeline) { [int]$pipeline.Extent.StartOffset } else { $null }
20
+ $elements = @($command.CommandElements | Select-Object -First 256 | ForEach-Object {
21
+ $ep = Pos $_.Extent; $literal = $null
22
+ $literalTruncated = $false; $elementDynamic = [bool](DynamicNode $_)
23
+ if ($_ -is [System.Management.Automation.Language.CommandParameterAst]) {
24
+ $rawLiteral = '-' + $_.ParameterName
25
+ if ($null -ne $_.Argument) {
26
+ if ($_.Argument -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $_.Argument -is [System.Management.Automation.Language.ConstantExpressionAst]) { $rawLiteral += ':' + [string]$_.Argument.Value }
27
+ else { $elementDynamic = $true }
28
+ }
29
+ $literalTruncated = $rawLiteral.Length -gt $maxLiteral; $literal = Clip $rawLiteral $maxLiteral
30
+ } elseif ($_ -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $_ -is [System.Management.Automation.Language.ConstantExpressionAst]) { $rawLiteral = [string]$_.Value; $literalTruncated = $rawLiteral.Length -gt $maxLiteral; $literal = Clip $rawLiteral $maxLiteral }
31
+ $rawExtent = [string]$_.Extent.Text; $rawTruncated = $rawExtent.Length -gt $maxLiteral
32
+ @{ astType = $_.GetType().Name; start = $ep.start; end = $ep.end; literal = $literal; literalTruncated = $literalTruncated; raw = (Clip $rawExtent $maxLiteral); rawTruncated = $rawTruncated; dynamic = $elementDynamic }
33
+ })
34
+ $reds = @($command.Redirections | Select-Object -First 128 | ForEach-Object { $rp = Pos $_.Extent; $target = $null; $targetTruncated = $false; if ($_.Location -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $rawTarget = [string]$_.Location.Value; $target = Clip $rawTarget 4096; $targetTruncated = $rawTarget.Length -gt 4096 }; @{ astType = $_.GetType().Name; start = $rp.start; end = $rp.end; targetLiteral = $target; targetTruncated = [bool]$targetTruncated; dynamic = $null -eq $target } })
35
+ @{ start = $p.start; end = $p.end; pipelineStart = $pipelineStart; commandName = $name; invocationOperator = ([string]$command.InvocationOperator); elements = $elements; elementsTruncated = ($command.CommandElements.Count -gt 256); redirections = $reds; redirectionsTruncated = ($command.Redirections.Count -gt 128) }
36
+ })
37
+ $dynamic = @($ast.FindAll({ param($n) DynamicNode $n }, $true) | Select-Object -First 256 | ForEach-Object { $p = Pos $_.Extent; @{ kind = $_.GetType().Name; start = $p.start; end = $p.end } })
38
+ $stop = @($tokens | Where-Object { ([string]$_.Kind -eq 'StopParsing') -or $_.Text -eq '--%' } | Select-Object -First 128 | ForEach-Object { $p = Pos $_.Extent; @{ start = $p.start; end = $p.end } })
39
+ $limit = $null; if ($tokens.Count -gt $maxTokens) { $limit = 'tokens' }; if ($commands.Count -gt $maxCommands) { $limit = 'commands' }; if (@($commands | Where-Object { $_.elementsTruncated }).Count -gt 0) { $limit = 'elements' }; if (@($commands | Where-Object { $_.redirectionsTruncated }).Count -gt 0) { $limit = 'redirections' }
40
+ $out = @{ schema = 1; ok = ($errorItems.Count -eq 0 -and $null -eq $limit); parser = @{ edition = $edition; version = [string]$PSVersionTable.PSVersion }; tokenCount = [int]$tokens.Count; errors = $errorItems; commands = @($commands | Select-Object -First $maxCommands); dynamicConstructs = $dynamic; stopParsingTokens = $stop }
41
+ if ($null -ne $limit) { $out.limitExceeded = $limit }
42
+ [Console]::Out.Write((ConvertTo-Json $out -Compress -Depth 16))
43
+ } catch {
44
+ $out = @{ schema = 1; ok = $false; parser = @{ edition = $edition; version = [string]$PSVersionTable.PSVersion }; tokenCount = 0; errors = @(@{ errorId = 'GUARD_HELPER_FAILURE'; start = 0; end = 0; message = (Clip $_.Exception.Message 256) }); commands = @(); dynamicConstructs = @(); stopParsingTokens = @() }
45
+ [Console]::Out.Write((ConvertTo-Json $out -Compress -Depth 16))
46
+ exit 2
47
+ }