|
15 | 15 | import { readdir, readFile, stat } from "node:fs/promises"; |
16 | 16 | import { join } from "node:path"; |
17 | 17 | import { homedir } from "node:os"; |
| 18 | +import { isRiskFlag, normalize } from "./normalize.mjs"; |
18 | 19 |
|
19 | 20 | const args = process.argv.slice(2); |
20 | 21 |
|
@@ -299,127 +300,7 @@ function classify(command) { |
299 | 300 | return { tier: "unknown" }; |
300 | 301 | } |
301 | 302 |
|
302 | | -// ── Normalization ────────────────────────────────────────────────────────── |
303 | | - |
304 | | -// Risk-modifying flags that must NOT be collapsed into wildcards. |
305 | | -// Global flags are always preserved; context-specific flags only matter |
306 | | -// for certain base commands. |
307 | | -const GLOBAL_RISK_FLAGS = new Set([ |
308 | | - "--force", "--hard", "-rf", "--privileged", "--no-verify", |
309 | | - "--system", "--force-with-lease", "-D", "--force-if-includes", |
310 | | - "--volumes", "--rmi", "--rewrite", "--delete", |
311 | | -]); |
312 | | - |
313 | | -// Flags that are only risky for specific base commands. |
314 | | -// -f means force-push in git, force-remove in docker, but pattern-file in grep. |
315 | | -// -v means remove-volumes in docker-compose, but verbose everywhere else. |
316 | | -const CONTEXTUAL_RISK_FLAGS = { |
317 | | - "-f": new Set(["git", "docker", "rm"]), |
318 | | - "-v": new Set(["docker", "docker-compose"]), |
319 | | -}; |
320 | | - |
321 | | -function isRiskFlag(token, base) { |
322 | | - if (GLOBAL_RISK_FLAGS.has(token)) return true; |
323 | | - // Check context-specific flags |
324 | | - const contexts = CONTEXTUAL_RISK_FLAGS[token]; |
325 | | - if (contexts && base && contexts.has(base)) return true; |
326 | | - // Combined short flags containing risk chars: -rf, -fr, -fR, etc. |
327 | | - if (/^-[a-zA-Z]*[rf][a-zA-Z]*$/.test(token) && token.length <= 4) return true; |
328 | | - return false; |
329 | | -} |
330 | | - |
331 | | -function normalize(command) { |
332 | | - // Don't normalize shell injection patterns |
333 | | - if (/\|\s*(sh|bash|zsh)\b/.test(command)) return command; |
334 | | - // Don't normalize sudo -- keep as-is |
335 | | - if (/^sudo\s/.test(command)) return "sudo *"; |
336 | | - |
337 | | - // Handle pnpm --filter <pkg> <subcommand> specially |
338 | | - const pnpmFilter = command.match(/^pnpm\s+--filter\s+\S+\s+(\S+)/); |
339 | | - if (pnpmFilter) return "pnpm --filter * " + pnpmFilter[1] + " *"; |
340 | | - |
341 | | - // Handle sed specially -- preserve the mode flag to keep safe patterns narrow. |
342 | | - // sed -i (in-place) is destructive; sed -n, sed -e, bare sed are read-only. |
343 | | - if (/^sed\s/.test(command)) { |
344 | | - if (/\s-i\b/.test(command)) return "sed -i *"; |
345 | | - const sedFlag = command.match(/^sed\s+(-[a-zA-Z])\s/); |
346 | | - return sedFlag ? "sed " + sedFlag[1] + " *" : "sed *"; |
347 | | - } |
348 | | - |
349 | | - // Handle ast-grep specially -- preserve --rewrite flag. |
350 | | - if (/^(ast-grep|sg)\s/.test(command)) { |
351 | | - const base = command.startsWith("sg") ? "sg" : "ast-grep"; |
352 | | - return /\s--rewrite\b/.test(command) ? base + " --rewrite *" : base + " *"; |
353 | | - } |
354 | | - |
355 | | - // Handle find specially -- preserve key action flags. |
356 | | - // find -delete and find -exec rm are destructive; find -name/-type are safe. |
357 | | - if (/^find\s/.test(command)) { |
358 | | - if (/\s-delete\b/.test(command)) return "find -delete *"; |
359 | | - if (/\s-exec\s/.test(command)) return "find -exec *"; |
360 | | - // Extract the first predicate flag for a narrower safe pattern |
361 | | - const findFlag = command.match(/\s(-(?:name|type|path|iname))\s/); |
362 | | - return findFlag ? "find " + findFlag[1] + " *" : "find *"; |
363 | | - } |
364 | | - |
365 | | - // Handle git -C <dir> <subcommand> -- strip the -C <dir> and normalize the git subcommand |
366 | | - const gitC = command.match(/^git\s+-C\s+\S+\s+(.+)$/); |
367 | | - if (gitC) return normalize("git " + gitC[1]); |
368 | | - |
369 | | - // Split on compound operators -- normalize the first command only |
370 | | - const compoundMatch = command.match(/^(.+?)\s*(&&|\|\||;)\s*(.+)$/); |
371 | | - if (compoundMatch) { |
372 | | - return normalize(compoundMatch[1].trim()); |
373 | | - } |
374 | | - |
375 | | - // Strip trailing pipe chains for normalization (e.g., `cmd | tail -5`) |
376 | | - // but preserve pipe-to-shell (already handled by shell injection check above) |
377 | | - const pipeMatch = command.match(/^(.+?)\s*\|\s*(.+)$/); |
378 | | - if (pipeMatch) { |
379 | | - return normalize(pipeMatch[1].trim()); |
380 | | - } |
381 | | - |
382 | | - // Strip trailing redirections (2>&1, > file, >> file) |
383 | | - const cleaned = command.replace(/\s*[12]?>>?\s*\S+\s*$/, "").replace(/\s*2>&1\s*$/, "").trim(); |
384 | | - |
385 | | - const parts = cleaned.split(/\s+/); |
386 | | - if (parts.length === 0) return command; |
387 | | - |
388 | | - const base = parts[0]; |
389 | | - |
390 | | - // For git/docker/gh/npm etc, include the subcommand |
391 | | - const multiWordBases = ["git", "docker", "docker-compose", "gh", "npm", "bun", |
392 | | - "pnpm", "yarn", "cargo", "pip", "pip3", "bundle", "systemctl", "kubectl"]; |
393 | | - |
394 | | - let prefix = base; |
395 | | - let argStart = 1; |
396 | | - |
397 | | - if (multiWordBases.includes(base) && parts.length > 1) { |
398 | | - prefix = base + " " + parts[1]; |
399 | | - argStart = 2; |
400 | | - } |
401 | | - |
402 | | - // Preserve risk-modifying flags in the remaining args |
403 | | - const preservedFlags = []; |
404 | | - for (let i = argStart; i < parts.length; i++) { |
405 | | - if (isRiskFlag(parts[i], base)) { |
406 | | - preservedFlags.push(parts[i]); |
407 | | - } |
408 | | - } |
409 | | - |
410 | | - // Build the normalized pattern |
411 | | - if (parts.length <= argStart && preservedFlags.length === 0) { |
412 | | - return prefix; // no args, no flags: e.g., "git status" |
413 | | - } |
414 | | - |
415 | | - const flagStr = preservedFlags.length > 0 ? " " + preservedFlags.join(" ") : ""; |
416 | | - const hasVaryingArgs = parts.length > argStart + preservedFlags.length; |
417 | | - |
418 | | - if (hasVaryingArgs) { |
419 | | - return prefix + flagStr + " *"; |
420 | | - } |
421 | | - return prefix + flagStr; |
422 | | -} |
| 303 | +// ── Normalization (see ./normalize.mjs) ──────────────────────────────────── |
423 | 304 |
|
424 | 305 | // ── Session file scanning ────────────────────────────────────────────────── |
425 | 306 |
|
|
0 commit comments