diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 779bd0107..76488d719 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.9.4", + "version": "2.9.5", "author": { "name": "Egonex" }, diff --git a/.copilot-plugin/plugin.json b/.copilot-plugin/plugin.json index 7841d335e..955c0456a 100644 --- a/.copilot-plugin/plugin.json +++ b/.copilot-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.9.4", + "version": "2.9.5", "author": { "name": "Egonex" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 5dcbccd66..316f820f4 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "understand-anything", "displayName": "Understand Anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.9.4", + "version": "2.9.5", "author": { "name": "Egonex" }, diff --git a/tests/benchmark/test_large_repo_benchmark.test.mjs b/tests/benchmark/test_large_repo_benchmark.test.mjs index 58c3fafb9..97610de47 100644 --- a/tests/benchmark/test_large_repo_benchmark.test.mjs +++ b/tests/benchmark/test_large_repo_benchmark.test.mjs @@ -1026,7 +1026,12 @@ describe('Git metadata probes', () => { afterEach(() => { for (const path of cleanup.splice(0)) { - rmSync(path, { recursive: true, force: true }); + rmSync(path, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); } }); diff --git a/tests/skill/understand/test_run_python.test.mjs b/tests/skill/understand/test_run_python.test.mjs new file mode 100644 index 000000000..bd85550b0 --- /dev/null +++ b/tests/skill/understand/test_run_python.test.mjs @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + pythonCandidates, + resolvePython3, + runPythonScript, +} from '../../../understand-anything-plugin/scripts/run-python.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '../../..'); +const tempDirs = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('portable Python 3 runner', () => { + it('uses platform-appropriate interpreter candidates', () => { + expect(pythonCandidates('win32')).toEqual([ + { command: 'python', prefixArgs: [] }, + { command: 'py', prefixArgs: ['-3'] }, + { command: 'python3', prefixArgs: [] }, + ]); + expect(pythonCandidates('linux')).toEqual([ + { command: 'python3', prefixArgs: [] }, + { command: 'python', prefixArgs: [] }, + ]); + }); + + it('rejects an incompatible Python and resolves the next interpreter path', () => { + const calls = []; + const spawnSyncImpl = (command, args, options) => { + calls.push({ command, args, options }); + return command === 'py' + ? { status: 0, stdout: 'C:\\Python312\\python.exe\r\n' } + : { status: 1, stdout: '' }; + }; + + expect(resolvePython3({ + platform: 'win32', + probeCwd: 'C:\\trusted-plugin\\scripts', + spawnSyncImpl, + })).toEqual({ + command: 'C:\\Python312\\python.exe', + prefixArgs: [], + }); + expect(calls.map(call => call.command)).toEqual(['python', 'py']); + expect(calls[1].args.slice(0, 4)).toEqual(['-3', '-I', '-S', '-c']); + expect(calls[1].args.at(-1)).toContain('sys.version_info >= (3, 10)'); + expect(calls[1].options.cwd).toBe('C:\\trusted-plugin\\scripts'); + expect(calls[1].options.shell).toBe(false); + expect(calls[1].options.timeout).toBe(5000); + }); + + it('continues when an interpreter candidate is missing from PATH', () => { + const calls = []; + const executable = process.platform === 'win32' + ? 'C:\\Python312\\python.exe' + : '/usr/bin/python3'; + const missing = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }); + const results = [ + { error: missing, status: null, stdout: '' }, + { status: 0, stdout: `${executable}\n` }, + ]; + + expect(resolvePython3({ + spawnSyncImpl: (command, args, options) => { + calls.push({ command, args, options }); + return results.shift(); + }, + })).toEqual({ command: executable, prefixArgs: [] }); + expect(calls.map(call => call.command)).toEqual( + pythonCandidates(process.platform).slice(0, 2).map(candidate => candidate.command), + ); + }); + + it('returns usage without probing Python when no helper is provided', () => { + const spawnSyncImpl = vi.fn(); + const stderr = { write: vi.fn() }; + + expect(runPythonScript([], { spawnSyncImpl, stderr })).toBe(64); + expect(spawnSyncImpl).not.toHaveBeenCalled(); + expect(stderr.write).toHaveBeenCalledWith(expect.stringContaining('Usage:')); + }); + + it('returns a clear error when Python 3 is unavailable', () => { + const stderr = { write: vi.fn() }; + const status = runPythonScript( + ['helper.py'], + { + platform: 'linux', + spawnSyncImpl: () => ({ status: 1, stdout: '' }), + stderr, + }, + ); + + expect(status).toBe(127); + expect(stderr.write).toHaveBeenCalledWith(expect.stringContaining('Python 3.10 or newer')); + }); + + it('reports a failure to start the resolved interpreter', () => { + const stderr = { write: vi.fn() }; + const startError = Object.assign(new Error('access denied'), { code: 'EACCES' }); + const results = [ + { status: 0, stdout: `${resolve('python')}\n` }, + { error: startError, status: null }, + ]; + + expect(runPythonScript( + ['helper.py'], + { + spawnSyncImpl: () => results.shift(), + stderr, + }, + )).toBe(1); + expect(stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Failed to start Python 3: access denied'), + ); + }); + + it('maps child signals to conventional shell exit statuses', () => { + const calls = []; + const results = [ + { status: 0, stdout: `${resolve('python')}\n` }, + { status: null, signal: 'SIGINT' }, + ]; + expect(runPythonScript( + ['helper.py'], + { + spawnSyncImpl: (command, args, options) => { + calls.push({ command, args, options }); + return results.shift(); + }, + }, + )).toBe(130); + expect(calls[1].command).toBe(resolve('python')); + expect(calls[1].options.shell).toBe(false); + }); + + it('runs through a linked plugin path and propagates the script exit status', () => { + const root = mkdtempSync(join(tmpdir(), 'ua-python-runner-')); + tempDirs.push(root); + const spacedDir = join(root, 'path with spaces'); + mkdirSync(spacedDir); + const pluginLink = join(root, 'linked plugin'); + symlinkSync( + resolve(repoRoot, 'understand-anything-plugin'), + pluginLink, + process.platform === 'win32' ? 'junction' : 'dir', + ); + const linkedRunnerPath = join(pluginLink, 'scripts', 'run-python.mjs'); + const scriptPath = join(spacedDir, 'helper.py'); + writeFileSync( + scriptPath, + [ + 'import sys', + 'if sys.argv[1] == "exit":', + ' raise SystemExit(7)', + 'print("|".join(sys.argv[1:]))', + '', + ].join('\n'), + 'utf8', + ); + + const success = spawnSync( + process.execPath, + [linkedRunnerPath, scriptPath, 'hello world', 'second'], + { encoding: 'utf8' }, + ); + expect(success.status).toBe(0); + expect(success.stdout.trim()).toBe('hello world|second'); + + const failure = spawnSync( + process.execPath, + [linkedRunnerPath, scriptPath, 'exit'], + { encoding: 'utf8' }, + ); + expect(failure.status).toBe(7); + }); +}); diff --git a/tests/skill/understand/test_skill_security_snippets.test.mjs b/tests/skill/understand/test_skill_security_snippets.test.mjs index 00d478e6b..ffea0077d 100644 --- a/tests/skill/understand/test_skill_security_snippets.test.mjs +++ b/tests/skill/understand/test_skill_security_snippets.test.mjs @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -10,6 +10,33 @@ function readRepoFile(relPath) { return readFileSync(resolve(repoRoot, relPath), 'utf-8'); } +const POSIX_SHELL_FENCE_LANGUAGES = new Set([ + '', + 'bash', + 'sh', + 'shell', + 'zsh', +]); + +function shellCommandLines(markdown) { + const lines = []; + const fencedBlock = /^[ \t]*```([^\r\n]*)\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/gm; + + for (const match of markdown.matchAll(fencedBlock)) { + const language = match[1].trim().toLowerCase(); + if (!POSIX_SHELL_FENCE_LANGUAGES.has(language)) continue; + + const logicalLines = match[2] + .replace(/\\\r?\n[ \t]*/g, ' ') + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + lines.push(...logicalLines); + } + + return lines; +} + describe('skill command hardening', () => { it('quotes PROJECT_ROOT in shell command snippets', () => { const files = [ @@ -59,6 +86,77 @@ describe('skill command hardening', () => { expect(content).toMatch(/npx --yes "\$VIEWER_URL" "\$PROJECT_DIR"/); }); + it('rejects bare python and pins audited helpers to portable commands', () => { + const skillsDir = resolve(repoRoot, 'understand-anything-plugin/skills'); + const skillFiles = readdirSync(skillsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => `understand-anything-plugin/skills/${entry.name}/SKILL.md`) + .filter(relPath => existsSync(resolve(repoRoot, relPath))); + + expect(skillFiles.length).toBeGreaterThan(0); + + // Inspect logical commands only: prose and fenced Python examples are not + // executable shell snippets. The pattern covers quoted command tokens, + // compact shell separators, subshells, and backslash-continued commands. + const barePythonCommand = + /(?:^|(?:&&|\|\||[;|]|\()[ \t]*)(?:["']python["']|python)(?=[ \t]+(?:["'./<$~-]|[^ \t\r\n]+\.py\b))/; + + const unsafeExamples = [ + 'python ./tool.py', + "'python' ./tool.py", + '"python" ./tool.py', + 'true &&python tool.py', + '(python tool.py)', + 'python \\\n ./tool.py', + 'python \\\r\n ./tool.py', + ]; + for (const command of unsafeExamples) { + const [logicalCommand] = shellCommandLines(`\`\`\`bash\n${command}\n\`\`\``); + expect(logicalCommand, command).toMatch(barePythonCommand); + } + const [unlabelledCommand] = shellCommandLines('```\npython ./tool.py\n```'); + expect(unlabelledCommand).toMatch(barePythonCommand); + + const nonCommandMarkdown = [ + 'Avoid bare python', + '- use python3 instead', + '```python', + '"./tool.py"', + '```', + ].join('\n'); + expect(shellCommandLines(nonCommandMarkdown)).toEqual([]); + + for (const relPath of skillFiles) { + for (const command of shellCommandLines(readRepoFile(relPath))) { + expect( + command, + `${relPath} should not invoke a bundled helper with bare python`, + ).not.toMatch(barePythonCommand); + } + } + + const runner = 'node "$PLUGIN_ROOT/scripts/run-python.mjs"'; + const domainCommands = shellCommandLines( + readRepoFile('understand-anything-plugin/skills/understand-domain/SKILL.md'), + ); + expect(domainCommands.filter(command => command.includes('extract-domain-context.py'))).toEqual([ + `${runner} "$PLUGIN_ROOT/skills/understand-domain/extract-domain-context.py" "$PROJECT_ROOT"`, + ]); + + const understandCommands = shellCommandLines( + readRepoFile('understand-anything-plugin/skills/understand/SKILL.md'), + ); + expect(understandCommands.filter(command => command.includes('merge-subdomain-graphs.py'))) + .toEqual([ + `${runner} "$PLUGIN_ROOT/skills/understand/merge-subdomain-graphs.py" "$PROJECT_ROOT"`, + ]); + expect(understandCommands.filter(command => command.includes('merge-batch-graphs.py'))) + .toEqual([ + `${runner} "$PLUGIN_ROOT/skills/understand/merge-batch-graphs.py" "$PROJECT_ROOT"`, + `${runner} "$PLUGIN_ROOT/skills/understand/merge-batch-graphs.py" "$PROJECT_ROOT"`, + ]); + }); + it('marks project-controlled context as untrusted data', () => { const understand = readRepoFile('understand-anything-plugin/skills/understand/SKILL.md'); const knowledge = readRepoFile('understand-anything-plugin/skills/understand-knowledge/SKILL.md'); diff --git a/understand-anything-plugin/.claude-plugin/plugin.json b/understand-anything-plugin/.claude-plugin/plugin.json index 779bd0107..76488d719 100644 --- a/understand-anything-plugin/.claude-plugin/plugin.json +++ b/understand-anything-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.9.4", + "version": "2.9.5", "author": { "name": "Egonex" }, diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index c27d6b170..560101b7e 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@understand-anything/skill", - "version": "2.9.4", + "version": "2.9.5", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/understand-anything-plugin/packages/viewer/package.json b/understand-anything-plugin/packages/viewer/package.json index a16a5b3cc..63a45967d 100644 --- a/understand-anything-plugin/packages/viewer/package.json +++ b/understand-anything-plugin/packages/viewer/package.json @@ -1,6 +1,6 @@ { "name": "understand-anything-viewer", - "version": "2.9.4", + "version": "2.9.5", "description": "Standalone read-only viewer for Understand-Anything knowledge graphs — no Claude Code or LLM required.", "type": "module", "license": "MIT", diff --git a/understand-anything-plugin/scripts/run-python.mjs b/understand-anything-plugin/scripts/run-python.mjs new file mode 100644 index 000000000..a49650667 --- /dev/null +++ b/understand-anything-plugin/scripts/run-python.mjs @@ -0,0 +1,138 @@ +/** + * Run a bundled Python helper with an available Python 3 interpreter. + * + * Keep this module shebang-free: skill commands always invoke it through + * `node`, and Vitest must also be able to import CRLF checkouts on Windows. + * + * Skill instructions are consumed on macOS, Linux, and Windows, where the + * interpreter may be exposed as `python3`, `python`, or the Windows `py -3` + * launcher. Keep that platform detail here so every skill command can invoke + * the same shell-neutral Node entry point. + * + * Usage: + * node run-python.mjs [args...] + */ + +import { spawnSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { constants as osConstants } from 'node:os'; +import { dirname, posix, resolve, win32 } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PYTHON3_PROBE = + 'import os, sys; ' + + 'print(os.path.realpath(sys.executable)) ' + + 'if sys.version_info >= (3, 10) else sys.exit(1)'; +const PROBE_TIMEOUT_MS = 5000; +const RUNNER_DIR = dirname(fileURLToPath(import.meta.url)); + +export function pythonCandidates(platform = process.platform) { + if (platform === 'win32') { + // Respect an activated virtual environment before falling back to the + // Windows launcher. Some Windows installs also expose `python3`. + return [ + { command: 'python', prefixArgs: [] }, + { command: 'py', prefixArgs: ['-3'] }, + { command: 'python3', prefixArgs: [] }, + ]; + } + + return [ + { command: 'python3', prefixArgs: [] }, + { command: 'python', prefixArgs: [] }, + ]; +} + +export function resolvePython3({ + platform = process.platform, + probeCwd = RUNNER_DIR, + spawnSyncImpl = spawnSync, +} = {}) { + const pathFlavor = platform === 'win32' ? win32 : posix; + + for (const candidate of pythonCandidates(platform)) { + const probe = spawnSyncImpl( + candidate.command, + [...candidate.prefixArgs, '-I', '-S', '-c', PYTHON3_PROBE], + { + cwd: probeCwd, + encoding: 'utf8', + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: PROBE_TIMEOUT_MS, + windowsHide: true, + }, + ); + + const executable = typeof probe.stdout === 'string' ? probe.stdout.trim() : ''; + if (!probe.error && probe.status === 0 && pathFlavor.isAbsolute(executable)) { + return { command: executable, prefixArgs: [] }; + } + } + + return null; +} + +export function runPythonScript( + argv, + { + platform = process.platform, + spawnSyncImpl = spawnSync, + stderr = process.stderr, + } = {}, +) { + const [scriptPath, ...scriptArgs] = argv; + if (!scriptPath) { + stderr.write('Usage: node run-python.mjs [args...]\n'); + return 64; + } + + const python = resolvePython3({ platform, spawnSyncImpl }); + if (!python) { + stderr.write( + 'Error: Python 3.10 or newer is required. ' + + 'Install it and ensure python3, python, or py is on PATH.\n', + ); + return 127; + } + + const result = spawnSyncImpl( + python.command, + [...python.prefixArgs, scriptPath, ...scriptArgs], + { + shell: false, + stdio: 'inherit', + windowsHide: false, + }, + ); + + if (result.error) { + stderr.write(`Error: Failed to start Python 3: ${result.error.message}\n`); + return 1; + } + + if (result.signal) { + const signalNumber = osConstants.signals[result.signal]; + return typeof signalNumber === 'number' ? 128 + signalNumber : 1; + } + + return typeof result.status === 'number' ? result.status : 1; +} + +export function isDirectExecution({ + entryPath = process.argv[1], + moduleUrl = import.meta.url, + realpathSyncImpl = realpathSync, +} = {}) { + if (!entryPath) return false; + + try { + return realpathSyncImpl(resolve(entryPath)) === realpathSyncImpl(fileURLToPath(moduleUrl)); + } catch { + return false; + } +} + +if (isDirectExecution()) { + process.exitCode = runPythonScript(process.argv.slice(2)); +} diff --git a/understand-anything-plugin/skills/understand-domain/SKILL.md b/understand-anything-plugin/skills/understand-domain/SKILL.md index 0c42eeea2..5886c2ee0 100644 --- a/understand-anything-plugin/skills/understand-domain/SKILL.md +++ b/understand-anything-plugin/skills/understand-domain/SKILL.md @@ -90,7 +90,7 @@ if [ -z "$PLUGIN_ROOT" ]; then fi ``` -Use `$PLUGIN_ROOT` for every reference to agent definitions in subsequent phases. +Use `$PLUGIN_ROOT` for every reference to agent definitions and bundled scripts in subsequent phases. ### Phase 1: Detect Existing Graph @@ -116,9 +116,9 @@ Use `$PLUGIN_ROOT` for every reference to agent definitions in subsequent phases The preprocessing script does NOT produce a domain graph — it produces **raw material** (file tree, entry points, exports/imports) so the domain-analyzer agent can focus on the actual domain analysis instead of spending dozens of tool calls exploring the codebase. Think of it as a cheat sheet: cheap Python preprocessing → expensive LLM gets a clean, small input → better results for less cost. -1. Run the preprocessing script bundled with this skill, passing `$PROJECT_ROOT` from Phase 0: - ``` - python ./extract-domain-context.py "$PROJECT_ROOT" +1. Run the preprocessing script bundled with this skill through the plugin's portable Python 3 launcher, using `$PLUGIN_ROOT` and `$PROJECT_ROOT` from Phase 0: + ```bash + node "$PLUGIN_ROOT/scripts/run-python.mjs" "$PLUGIN_ROOT/skills/understand-domain/extract-domain-context.py" "$PROJECT_ROOT" ``` This outputs `$UA_DIR/intermediate/domain-context.json` containing: - File tree (respecting `.gitignore`) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index f13012e0e..7351b21fb 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -171,9 +171,9 @@ Determine whether to run a full analysis or incremental update. - **Note:** Newly added `--exclude` patterns require a `--full` scan to take effect. 4. **Check for subdomain knowledge graphs to merge:** - List all `*knowledge-graph*.json` files in `$UA_DIR/` **excluding** `knowledge-graph.json` itself (e.g. `frontend-knowledge-graph.json`, `backend-knowledge-graph.json`). If any subdomain graphs exist, run the merge script bundled with this skill (located next to this SKILL.md file — use the skill directory path, not the project root): + List all `*knowledge-graph*.json` files in `$UA_DIR/` **excluding** `knowledge-graph.json` itself (e.g. `frontend-knowledge-graph.json`, `backend-knowledge-graph.json`). If any subdomain graphs exist, run the bundled merge script through the plugin's portable Python 3 launcher: ```bash - python "/merge-subdomain-graphs.py" "$PROJECT_ROOT" + node "$PLUGIN_ROOT/scripts/run-python.mjs" "$PLUGIN_ROOT/skills/understand/merge-subdomain-graphs.py" "$PROJECT_ROOT" ``` The script discovers subdomain graphs, loads the existing `knowledge-graph.json` as a base (if present), and merges everything into `knowledge-graph.json` (deduplicating nodes and edges). Report the merge summary to the user, then continue with the merged graph. @@ -338,9 +338,9 @@ Dispatch prompt template (fill in batch-specific values from `batches.json[i]`): After ALL batches complete, report to the user: `Phase 2 complete. All batches analyzed.` -Run the merge-and-normalize script bundled with this skill (located next to this SKILL.md file — use the skill directory path, not the project root): +Run the bundled merge-and-normalize script through the plugin's portable Python 3 launcher: ```bash -python "/merge-batch-graphs.py" "$PROJECT_ROOT" +node "$PLUGIN_ROOT/scripts/run-python.mjs" "$PLUGIN_ROOT/skills/understand/merge-batch-graphs.py" "$PROJECT_ROOT" ``` This script reads all `batch-*.json` files (including `batch--part-.json` produced by file-analyzers that split their output) from `$UA_DIR/intermediate/`, then in one pass: @@ -379,9 +379,9 @@ After batches complete: 1. Remove old nodes whose `filePath` matches any changed file from the existing graph 2. Remove old edges whose `source` or `target` references a removed node 3. Write the pruned existing nodes/edges as `batch-existing.json` in the intermediate directory -4. Run the same merge script — it will combine `batch-existing.json` with the fresh `batch-*.json` files: +4. Run the same merge script through the portable Python 3 launcher — it will combine `batch-existing.json` with the fresh `batch-*.json` files: ```bash - python "/merge-batch-graphs.py" "$PROJECT_ROOT" + node "$PLUGIN_ROOT/scripts/run-python.mjs" "$PLUGIN_ROOT/skills/understand/merge-batch-graphs.py" "$PROJECT_ROOT" ``` ---