@technicalshree/auto-fix 1.2.2 → 1.2.4

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/cli.js CHANGED
@@ -78,15 +78,20 @@ function parseArgs(argv) {
78
78
  if (hasHelp)
79
79
  return { command: "run", flags: defaultFlags(), help: true };
80
80
  const [first, ...rest] = argv;
81
- const command = first === "doctor" ||
82
- first === "plan" ||
83
- first === "report" ||
84
- first === "undo" ||
85
- first === "clear-npm-cache" ||
86
- first === "clear-yarn-cache" ||
87
- first === "clear-pnpm-cache"
88
- ? first
89
- : "run";
81
+ const knownCommands = ["doctor", "plan", "report", "undo", "clear-npm-cache", "clear-yarn-cache", "clear-pnpm-cache", "run"];
82
+ let command;
83
+ if (first === "doctor" || first === "plan" || first === "report" || first === "undo" ||
84
+ first === "clear-npm-cache" || first === "clear-yarn-cache" || first === "clear-pnpm-cache") {
85
+ command = first;
86
+ }
87
+ else if (first === "run" || first.startsWith("-")) {
88
+ command = "run";
89
+ }
90
+ else {
91
+ // REL-001: Unknown command — fail explicitly instead of silent fallback to `run`
92
+ console.error(`Unknown command: "${first}"\nAvailable commands: ${knownCommands.join(", ")}\nRun 'auto-fix --help' for usage information.`);
93
+ process.exit(1);
94
+ }
90
95
  const args = command === "run" ? argv : rest;
91
96
  const flags = defaultFlags();
92
97
  for (let i = 0; i < args.length; i += 1) {
@@ -34,11 +34,49 @@ async function findConfigPath(cwd) {
34
34
  current = parent;
35
35
  }
36
36
  }
37
+ /** Allowlist pattern for safe characters in config command/path strings */
38
+ const SAFE_CHARS_RE = /^[a-zA-Z0-9._\-/@:=*\s]+$/;
39
+ /** Known dev tool binaries that are safe to auto-execute */
40
+ const KNOWN_TOOL_BINARIES = new Set([
41
+ "ruff", "black", "autopep8", "yapf", "isort", "pyink",
42
+ "flake8", "pylint", "pyflakes", "pydocstyle", "pycodestyle", "bandit", "vulture",
43
+ "mypy", "pyright", "pytype", "pyre",
44
+ "pytest", "unittest", "nose2", "tox", "nox", "coverage",
45
+ "pip", "uv", "poetry", "pipenv", "pdm",
46
+ "python", "python3", "pre-commit", "sphinx-build",
47
+ "npm", "npx", "pnpm", "yarn",
48
+ ]);
49
+ function isSafeConfigCommand(cmd) {
50
+ if (!SAFE_CHARS_RE.test(cmd))
51
+ return false;
52
+ const binary = cmd.trim().split(/\s+/)[0].replace(/^.*\//, "");
53
+ return KNOWN_TOOL_BINARIES.has(binary);
54
+ }
55
+ /**
56
+ * REL-004: Defense-in-depth — strip unsafe values from config at load time.
57
+ * Uses character allowlist + known binary allowlist to prevent arbitrary command execution.
58
+ */
59
+ function sanitizeConfig(config) {
60
+ // Sanitize python tool command arrays
61
+ if (config.python?.tools) {
62
+ config.python.tools.format = config.python.tools.format.filter(isSafeConfigCommand);
63
+ config.python.tools.lint = config.python.tools.lint.filter(isSafeConfigCommand);
64
+ config.python.tools.test = config.python.tools.test.filter(isSafeConfigCommand);
65
+ }
66
+ // Sanitize node cache directory names (path-only, no binary check needed)
67
+ if (config.node?.caches?.directories) {
68
+ config.node.caches.directories = config.node.caches.directories.filter((d) => SAFE_CHARS_RE.test(d) && !d.includes(".."));
69
+ }
70
+ return config;
71
+ }
37
72
  export async function loadConfig(cwd) {
38
73
  const cfgPath = await findConfigPath(cwd);
39
74
  if (!cfgPath)
40
75
  return { config: defaultConfig, path: null };
41
76
  const raw = await readFile(cfgPath, "utf8");
42
77
  const user = parse(raw);
43
- return { config: mergeDeep(defaultConfig, user), path: cfgPath };
78
+ if (!user || typeof user !== "object")
79
+ return { config: defaultConfig, path: cfgPath };
80
+ const merged = mergeDeep(defaultConfig, user);
81
+ return { config: sanitizeConfig(merged), path: cfgPath };
44
82
  }
package/dist/core/run.js CHANGED
@@ -94,7 +94,7 @@ export async function runAutoFix(ctx, config, reportDir, callbacks) {
94
94
  guarded.warnings.push("Added .autofix/ to .gitignore");
95
95
  if (!writable)
96
96
  guarded.warnings.push(`.autofix not writable; using temp snapshot dir: ${snapshotDir}`);
97
- if (config.docker.safe_down && guarded.steps.some((s) => s.checkKind === "test")) {
97
+ if (detection.docker.detected && config.docker.safe_down && guarded.steps.some((s) => s.checkKind === "test")) {
98
98
  guarded.warnings.push("Tests may require services; run `docker compose up -d` and re-run tests.");
99
99
  }
100
100
  const report = {
package/dist/core/undo.js CHANGED
@@ -33,7 +33,13 @@ export async function undoLatest(reportPath, cwd) {
33
33
  // Extract just the filename portion (after the stepId directory) and reverse the encoding.
34
34
  const base = path.basename(snap);
35
35
  const relativePath = base.replace(/_/g, path.sep);
36
- const target = path.join(cwd, relativePath);
36
+ const target = path.resolve(cwd, relativePath);
37
+ // SEC-002: Containment check — reject any path that escapes project root
38
+ const normalizedCwd = path.resolve(cwd) + path.sep;
39
+ if (!target.startsWith(normalizedCwd) && target !== path.resolve(cwd)) {
40
+ failed.push(`${target} (blocked: path traversal outside project root)`);
41
+ continue;
42
+ }
37
43
  try {
38
44
  const targetDir = path.dirname(target);
39
45
  await mkdir(targetDir, { recursive: true });
@@ -50,12 +50,71 @@ function nodeChecks(detection, selected) {
50
50
  }
51
51
  return steps;
52
52
  }
53
+ /**
54
+ * REL-004: Known tool binary allowlist.
55
+ * Only commands starting with a recognized dev tool will be auto-executed.
56
+ * Unrecognized binaries (touch, rm, curl, wget, etc.) are rejected.
57
+ */
58
+ const KNOWN_TOOL_BINARIES = new Set([
59
+ // Python formatters
60
+ "ruff", "black", "autopep8", "yapf", "isort", "pyink",
61
+ // Python linters
62
+ "flake8", "pylint", "pyflakes", "pydocstyle", "pycodestyle", "bandit", "vulture",
63
+ // Python type checkers
64
+ "mypy", "pyright", "pytype", "pyre",
65
+ // Python test runners
66
+ "pytest", "unittest", "nose2", "tox", "nox", "coverage",
67
+ // Python package managers (used as runner)
68
+ "pip", "uv", "poetry", "pipenv", "pdm",
69
+ // Python misc
70
+ "python", "python3", "pre-commit", "sphinx-build",
71
+ // Node tools (in case used in python context)
72
+ "npm", "npx", "pnpm", "yarn",
73
+ ]);
74
+ /**
75
+ * REL-004: Validate a config-sourced command string.
76
+ * Two checks:
77
+ * 1. Character allowlist — rejects shell metacharacters
78
+ * 2. Binary allowlist — first token must be a known dev tool
79
+ */
80
+ function isSafeCommand(cmd) {
81
+ if (!cmd || cmd.trim().length === 0)
82
+ return { safe: false, reason: "Empty command" };
83
+ // Check 1: Character-level safety (no shell metacharacters)
84
+ if (!/^[a-zA-Z0-9._\-/@:=*\s]+$/.test(cmd)) {
85
+ return { safe: false, reason: `contains shell metacharacters` };
86
+ }
87
+ // Check 2: First token must be a known tool binary
88
+ const binary = cmd.trim().split(/\s+/)[0].replace(/^.*\//, ""); // strip leading path
89
+ if (!KNOWN_TOOL_BINARIES.has(binary)) {
90
+ return { safe: false, reason: `unrecognized tool '${binary}'` };
91
+ }
92
+ return { safe: true, reason: "" };
93
+ }
53
94
  function pythonChecks(detection, config, selected) {
54
95
  if (!detection.python.detected)
55
96
  return [];
56
97
  const steps = [];
57
98
  if (selected.includes("format")) {
58
99
  for (const cmd of config.python.tools.format) {
100
+ const validation = isSafeCommand(cmd);
101
+ if (!validation.safe) {
102
+ steps.push({
103
+ id: `check-python-format-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
104
+ title: `SKIPPED: Rejected format command: ${cmd}`,
105
+ subsystem: "checks",
106
+ phase: "checks",
107
+ checkKind: "format",
108
+ rationale: `Config command rejected: ${validation.reason}.`,
109
+ commands: [],
110
+ destructive: false,
111
+ irreversible: false,
112
+ undoable: false,
113
+ status: "proposed",
114
+ proposedReason: `Command '${cmd}' was rejected (${validation.reason}). Only known dev tools are auto-executed. Run manually if needed.`,
115
+ });
116
+ continue;
117
+ }
59
118
  steps.push({
60
119
  id: `check-python-format-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
61
120
  title: `Run Python format: ${cmd}`,
@@ -73,6 +132,24 @@ function pythonChecks(detection, config, selected) {
73
132
  }
74
133
  if (selected.includes("lint")) {
75
134
  for (const cmd of config.python.tools.lint) {
135
+ const validation = isSafeCommand(cmd);
136
+ if (!validation.safe) {
137
+ steps.push({
138
+ id: `check-python-lint-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
139
+ title: `SKIPPED: Rejected lint command: ${cmd}`,
140
+ subsystem: "checks",
141
+ phase: "checks",
142
+ checkKind: "lint",
143
+ rationale: `Config command rejected: ${validation.reason}.`,
144
+ commands: [],
145
+ destructive: false,
146
+ irreversible: false,
147
+ undoable: false,
148
+ status: "proposed",
149
+ proposedReason: `Command '${cmd}' was rejected (${validation.reason}). Only known dev tools are auto-executed. Run manually if needed.`,
150
+ });
151
+ continue;
152
+ }
76
153
  steps.push({
77
154
  id: `check-python-lint-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
78
155
  title: `Run Python lint: ${cmd}`,
@@ -90,6 +167,24 @@ function pythonChecks(detection, config, selected) {
90
167
  }
91
168
  if (selected.includes("test")) {
92
169
  for (const cmd of config.python.tools.test) {
170
+ const validation = isSafeCommand(cmd);
171
+ if (!validation.safe) {
172
+ steps.push({
173
+ id: `check-python-test-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
174
+ title: `SKIPPED: Rejected test command: ${cmd}`,
175
+ subsystem: "checks",
176
+ phase: "checks",
177
+ checkKind: "test",
178
+ rationale: `Config command rejected: ${validation.reason}.`,
179
+ commands: [],
180
+ destructive: false,
181
+ irreversible: false,
182
+ undoable: false,
183
+ status: "proposed",
184
+ proposedReason: `Command '${cmd}' was rejected (${validation.reason}). Only known dev tools are auto-executed. Run manually if needed.`,
185
+ });
186
+ continue;
187
+ }
93
188
  steps.push({
94
189
  id: `check-python-test-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
95
190
  title: `Run Python tests: ${cmd}`,
@@ -1,5 +1,10 @@
1
1
  import path from "node:path";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { shellQuote } from "../utils/process.js";
4
+ /** Strict env key sanitizer: only allow [A-Z0-9_] */
5
+ function sanitizeEnvKey(key) {
6
+ return key.replace(/[^A-Z0-9_]/gi, "");
7
+ }
3
8
  async function parseEnvKeys(filePath) {
4
9
  try {
5
10
  const raw = await readFile(filePath, "utf8");
@@ -10,7 +15,9 @@ async function parseEnvKeys(filePath) {
10
15
  continue;
11
16
  const idx = trimmed.indexOf("=");
12
17
  if (idx > 0) {
13
- keys.push(trimmed.substring(0, idx).trim());
18
+ const key = sanitizeEnvKey(trimmed.substring(0, idx).trim());
19
+ if (key)
20
+ keys.push(key);
14
21
  }
15
22
  }
16
23
  return keys;
@@ -37,20 +44,20 @@ export async function buildEnvSteps(cwd, detection, config, flags) {
37
44
  subsystem: "environment",
38
45
  phase: "environment",
39
46
  rationale: ".env is missing but .env.example exists.",
40
- commands: [`cp ${envExamplePath} ${envPath}`],
47
+ commands: [`cp ${shellQuote(envExamplePath)} ${shellQuote(envPath)}`],
41
48
  destructive: false,
42
49
  irreversible: false,
43
50
  undoable: true,
44
- snapshotPaths: [".env.example"], // Snapshot the example file as breadcrumb
45
- undoHints: [{ action: "Delete .env", command: `rm -f ${envPath}` }],
51
+ snapshotPaths: [".env.example"],
52
+ undoHints: [{ action: "Delete .env", command: `rm -f ${shellQuote(envPath)}` }],
46
53
  status: "planned",
47
54
  });
48
55
  }
49
56
  else {
50
57
  const missingKeys = await findMissingKeys(envPath, envExamplePath);
51
58
  if (missingKeys.length > 0) {
52
- // Create a small shell script command to append missing keys safely
53
- const appends = missingKeys.map((k) => `echo "${k}=" >> ${envPath}`).join(" && ");
59
+ // Keys are already sanitized to [A-Z0-9_] only safe for shell interpolation
60
+ const appends = missingKeys.map((k) => `echo ${shellQuote(`${k}=`)} >> ${shellQuote(envPath)}`).join(" && ");
54
61
  steps.push({
55
62
  id: "env-sync-append-missing",
56
63
  title: `Append ${missingKeys.length} missing key(s) to .env`,
@@ -1,6 +1,10 @@
1
+ import { isSafePath, shellQuote } from "../utils/process.js";
2
+ const ALLOWED_PMS = new Set(["npm", "pnpm", "yarn"]);
1
3
  function choosePm(detected, configured) {
2
- if (configured !== "auto")
3
- return configured;
4
+ if (configured !== "auto") {
5
+ // SEC-001: Validate package_manager from config against strict allowlist
6
+ return ALLOWED_PMS.has(configured) ? configured : "npm";
7
+ }
4
8
  if (detected === "unknown")
5
9
  return "npm";
6
10
  return detected;
@@ -91,13 +95,29 @@ export function buildNodeSteps(detection, config, flags) {
91
95
  });
92
96
  }
93
97
  for (const cacheDir of config.node.caches.directories) {
98
+ if (!isSafePath(cacheDir)) {
99
+ steps.push({
100
+ id: `node-clean-cache-${cacheDir.replace(/[^a-z0-9]/gi, "-")}`,
101
+ title: `SKIPPED: Unsafe cache directory name: ${cacheDir}`,
102
+ subsystem: "node",
103
+ phase: "node",
104
+ rationale: "Cache directory name contains suspicious characters and was rejected for safety.",
105
+ commands: [],
106
+ destructive: false,
107
+ irreversible: false,
108
+ undoable: false,
109
+ status: "proposed",
110
+ proposedReason: `Directory name '${cacheDir}' contains shell metacharacters. Rename it or clean manually.`,
111
+ });
112
+ continue;
113
+ }
94
114
  steps.push({
95
115
  id: `node-clean-cache-${cacheDir.replace(/[^a-z0-9]/gi, "-")}`,
96
116
  title: `Clean cache directory ${cacheDir}`,
97
117
  subsystem: "node",
98
118
  phase: "node",
99
119
  rationale: "Configured cache directory cleanup.",
100
- commands: [`rm -rf ${cacheDir}`],
120
+ commands: [`rm -rf ${shellQuote(cacheDir)}`],
101
121
  destructive: false,
102
122
  irreversible: false,
103
123
  undoable: false,
@@ -1,3 +1,4 @@
1
+ import { shellQuote } from "../utils/process.js";
1
2
  function installCommand(prefer) {
2
3
  switch (prefer) {
3
4
  case "uv":
@@ -16,6 +17,7 @@ export function buildPythonSteps(detection, config, flags) {
16
17
  if (!detection.python.detected)
17
18
  return [];
18
19
  const steps = [];
20
+ const quotedVenv = shellQuote(config.python.venv_path);
19
21
  if (!detection.python.venvExists) {
20
22
  steps.push({
21
23
  id: "python-create-venv",
@@ -23,7 +25,7 @@ export function buildPythonSteps(detection, config, flags) {
23
25
  subsystem: "python",
24
26
  phase: "python",
25
27
  rationale: "Python project detected without configured virtual environment.",
26
- commands: [`python3 -m venv ${config.python.venv_path}`],
28
+ commands: [`python3 -m venv ${quotedVenv}`],
27
29
  destructive: false,
28
30
  irreversible: false,
29
31
  undoable: false,
@@ -51,7 +53,7 @@ export function buildPythonSteps(detection, config, flags) {
51
53
  subsystem: "python",
52
54
  phase: "python",
53
55
  rationale: "Deep cleanup for persistent Python environment drift.",
54
- commands: [`rm -rf ${config.python.venv_path}`, `python3 -m venv ${config.python.venv_path}`],
56
+ commands: [`rm -rf ${quotedVenv}`, `python3 -m venv ${quotedVenv}`],
55
57
  destructive: true,
56
58
  irreversible: true,
57
59
  irreversibleReason: "cannot restore environment state fully",
@@ -62,8 +64,10 @@ export function buildPythonSteps(detection, config, flags) {
62
64
  }
63
65
  // PRD v1.2: IDE Integration Auto-Configuration for VS Code
64
66
  // Only sync when .venv exists or is being created by a preceding step
67
+ // REL-002: Skip write if existing settings.json can't be parsed (JSONC) to avoid clobbering
65
68
  const venvWillExist = detection.python.venvExists || steps.some((s) => s.id === "python-create-venv");
66
69
  if (venvWillExist) {
70
+ const escapedVenv = config.python.venv_path.replace(/'/g, "\\'");
67
71
  steps.push({
68
72
  id: "python-vscode-sync",
69
73
  title: "Sync Python virtual environment with VS Code",
@@ -71,7 +75,7 @@ export function buildPythonSteps(detection, config, flags) {
71
75
  phase: "python",
72
76
  rationale: "VS Code needs to know the correct virtual environment path to prevent linting errors.",
73
77
  commands: [
74
- `mkdir -p .vscode && node -e "const fs=require('fs');const p='.vscode/settings.json';let s={};try{s=JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){}s['python.defaultInterpreterPath']='${config.python.venv_path}';fs.writeFileSync(p,JSON.stringify(s,null,2))"`
78
+ `mkdir -p .vscode && node -e "const fs=require('fs');const p='.vscode/settings.json';let s;try{s=JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){if(fs.existsSync(p)){console.log('SKIP: settings.json exists but is not valid JSON (may contain comments). Skipping to preserve your config.');process.exit(0)}s={}}s['python.defaultInterpreterPath']='${escapedVenv}';fs.writeFileSync(p,JSON.stringify(s,null,2))"`
75
79
  ],
76
80
  destructive: false,
77
81
  irreversible: false,
@@ -1,7 +1,14 @@
1
- import { exec } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
+ /**
3
+ * Run a command safely through the system shell.
4
+ * Uses execFile with explicit ["-c", command] to avoid direct shell string injection
5
+ * while still supporting piped/chained commands that subsystems require.
6
+ */
2
7
  export function runShellCommand(command, cwd) {
8
+ const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
9
+ const shellArgs = process.platform === "win32" ? ["/c", command] : ["-c", command];
3
10
  return new Promise((resolve) => {
4
- exec(command, { cwd, maxBuffer: 1024 * 1024 * 4 }, (error, stdout, stderr) => {
11
+ execFile(shell, shellArgs, { cwd, maxBuffer: 1024 * 1024 * 4, encoding: "utf8" }, (error, stdout, stderr) => {
5
12
  if (error) {
6
13
  resolve({
7
14
  success: false,
@@ -15,3 +22,18 @@ export function runShellCommand(command, cwd) {
15
22
  });
16
23
  });
17
24
  }
25
+ /**
26
+ * Shell-quote a string value for safe interpolation into shell commands.
27
+ * Wraps in single quotes and escapes embedded single quotes.
28
+ */
29
+ export function shellQuote(value) {
30
+ return `'${value.replace(/'/g, "'\\''")}'`;
31
+ }
32
+ /**
33
+ * Validate a string against a strict safe-path pattern.
34
+ * Only allows alphanumeric, dots, dashes, underscores, and forward slashes.
35
+ * Rejects shell metacharacters like ;|&`$(){}
36
+ */
37
+ export function isSafePath(value) {
38
+ return /^[a-zA-Z0-9._\-/]+$/.test(value) && !value.includes("..");
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technicalshree/auto-fix",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Detect, diagnose, and safely fix common local dev environment issues",
5
5
  "main": "dist/cli.js",
6
6
  "repository": {