Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion .copilot-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
7 changes: 6 additions & 1 deletion tests/benchmark/test_large_repo_benchmark.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
});

Expand Down
192 changes: 192 additions & 0 deletions tests/skill/understand/test_run_python.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
100 changes: 99 additions & 1 deletion tests/skill/understand/test_skill_security_snippets.test.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 = [
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion understand-anything-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion understand-anything-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion understand-anything-plugin/packages/viewer/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading