mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
fix: harden local dashboard and data boundaries (#2585)
* fix: harden local data boundaries Bind the capabilities dashboard exclusively to loopback and reject untrusted Host and Origin values. Constrain project-configured agent data paths to the Cursor data root, and harden lifecycle repair/uninstall operations against state-file traversal, symlink swaps, unsafe sources, and forged install-state destinations.\n\nCloses #2506 * fix: eliminate repair source read race Read source bytes and mode from one no-follow file descriptor so a path replacement cannot mix metadata from one inode with content from another. Add a regression that rejects separate path-based source metadata lookup. * fix: close dashboard hardening review gaps
This commit is contained in:
parent
4da6deac18
commit
382060905e
10 changed files with 2320 additions and 193 deletions
|
|
@ -12,14 +12,35 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const {
|
||||
LOOPBACK_HOSTNAMES,
|
||||
buildAllowedHostnames,
|
||||
isAllowedHostHeader,
|
||||
isAllowedOrigin,
|
||||
} = require('./lib/loopback-guard');
|
||||
const { normalizeAgentTools } = require('./lib/agent-tools');
|
||||
|
||||
const DEFAULT_HOST = '127.0.0.1';
|
||||
|
||||
function resolveDashboardHost(env = process.env) {
|
||||
const configured = String(env.ECC_DASHBOARD_HOST || '').trim().toLowerCase();
|
||||
if (!configured) return DEFAULT_HOST;
|
||||
if (!LOOPBACK_HOSTNAMES.has(configured)) {
|
||||
throw new Error(
|
||||
'[ECC] ECC_DASHBOARD_HOST must be loopback-only ' +
|
||||
'(127.0.0.1, localhost, or ::1).'
|
||||
);
|
||||
}
|
||||
return configured === '[::1]' ? '::1' : configured;
|
||||
}
|
||||
|
||||
function parsePort(v) {
|
||||
const n = parseInt(String(v), 10);
|
||||
if (isNaN(n) || n < 1 || n > 65535) { console.error('[ECC] Invalid port: ' + v + ' — using 3456'); return 3456; }
|
||||
return n;
|
||||
}
|
||||
const PORT = parsePort(process.argv[2] || process.env.ECC_DASHBOARD_PORT || '3456');
|
||||
const HOST = resolveDashboardHost();
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
function readFrontmatter(p) {
|
||||
|
|
@ -791,21 +812,140 @@ handleRoute();
|
|||
/* eslint-enable no-useless-escape */
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
if (url.pathname === '/api/data') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ agents: loadAgents(), skills: loadSkills(), commands: loadCommands(), rules: loadRules(), mcps: loadMcps(), hooks: loadHooks() }));
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(renderHTML({ agents: loadAgents(), skills: loadSkills(), commands: loadCommands(), rules: loadRules(), mcps: loadMcps(), hooks: loadHooks() }));
|
||||
});
|
||||
function sendJson(res, statusCode, payload) {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`\n ECC Capabilities → http://localhost:${PORT}\n`);
|
||||
try { const { spawn } = require('child_process'); const p = process.platform; const c = p === 'darwin' ? 'open' : p === 'win32' ? 'start' : 'xdg-open'; if (c === 'start') spawn('cmd', ['/c', 'start', `http://localhost:${PORT}`], { stdio: 'ignore' }); else spawn(c, [`http://localhost:${PORT}`], { stdio: 'ignore' }); } catch { /* best-effort auto-open */ }
|
||||
function sendHtml(res, statusCode, html) {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
function loadDashboardData(root) {
|
||||
return {
|
||||
agents: loadAgents(root),
|
||||
skills: loadSkills(root),
|
||||
commands: loadCommands(root),
|
||||
rules: loadRules(root),
|
||||
mcps: loadMcps(root),
|
||||
hooks: loadHooks(root),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultReportError(message, error) {
|
||||
console.error(message, error);
|
||||
}
|
||||
|
||||
function reportDashboardFailure(reportError, message, error) {
|
||||
try {
|
||||
reportError(message, error);
|
||||
} catch {
|
||||
// Error reporting must never prevent the generic HTTP response.
|
||||
}
|
||||
}
|
||||
|
||||
function createDashboardServer({
|
||||
root = ROOT,
|
||||
host = HOST,
|
||||
loadData = loadDashboardData,
|
||||
render = renderHTML,
|
||||
reportError = defaultReportError,
|
||||
} = {}) {
|
||||
const resolvedHost = resolveDashboardHost({ ECC_DASHBOARD_HOST: host });
|
||||
const allowedHostnames = buildAllowedHostnames(resolvedHost);
|
||||
|
||||
return http.createServer((req, res) => {
|
||||
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
|
||||
return sendJson(res, 421, { error: 'Misdirected request' });
|
||||
}
|
||||
if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
|
||||
return sendJson(res, 403, { error: 'Forbidden origin' });
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, `http://${DEFAULT_HOST}`);
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Bad request' });
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/data') {
|
||||
let data;
|
||||
try {
|
||||
data = loadData(root);
|
||||
} catch (error) {
|
||||
reportDashboardFailure(
|
||||
reportError,
|
||||
'[ECC] Failed to load dashboard data:',
|
||||
error
|
||||
);
|
||||
return sendJson(res, 500, { error: 'Internal server error' });
|
||||
}
|
||||
return sendJson(res, 200, data);
|
||||
}
|
||||
|
||||
let html;
|
||||
try {
|
||||
html = render(loadData(root));
|
||||
} catch (error) {
|
||||
reportDashboardFailure(
|
||||
reportError,
|
||||
'[ECC] Failed to render dashboard:',
|
||||
error
|
||||
);
|
||||
return sendHtml(
|
||||
res,
|
||||
500,
|
||||
'<!DOCTYPE html><p>Dashboard unavailable.</p>'
|
||||
);
|
||||
}
|
||||
return sendHtml(res, 200, html);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { parsePort, readFrontmatter, readSkill, loadAgents, loadSkills, loadCommands, loadRules, loadMcps, loadHooks, renderHTML, LANG, LANG_KEYS, server };
|
||||
function listenDashboardServer(
|
||||
dashboardServer,
|
||||
{ port = PORT, host = HOST, onListening } = {}
|
||||
) {
|
||||
const resolvedHost = resolveDashboardHost({ ECC_DASHBOARD_HOST: host });
|
||||
return dashboardServer.listen(port, resolvedHost, onListening);
|
||||
}
|
||||
|
||||
const server = createDashboardServer();
|
||||
|
||||
if (require.main === module) {
|
||||
listenDashboardServer(server, { port: PORT, host: HOST, onListening: () => {
|
||||
const displayHost = HOST.includes(':') ? `[${HOST}]` : HOST;
|
||||
const dashboardUrl = `http://${displayHost}:${PORT}`;
|
||||
console.log(`\n ECC Capabilities → ${dashboardUrl}\n`);
|
||||
try { const { spawn } = require('child_process'); const p = process.platform; const c = p === 'darwin' ? 'open' : p === 'win32' ? 'start' : 'xdg-open'; if (c === 'start') spawn('cmd', ['/c', 'start', dashboardUrl], { stdio: 'ignore' }); else spawn(c, [dashboardUrl], { stdio: 'ignore' }); } catch { /* best-effort auto-open */ }
|
||||
} });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_HOST,
|
||||
HOST,
|
||||
LANG,
|
||||
LANG_KEYS,
|
||||
createDashboardServer,
|
||||
listenDashboardServer,
|
||||
loadAgents,
|
||||
loadCommands,
|
||||
loadHooks,
|
||||
loadMcps,
|
||||
loadRules,
|
||||
loadSkills,
|
||||
parsePort,
|
||||
readFrontmatter,
|
||||
readSkill,
|
||||
renderHTML,
|
||||
resolveDashboardHost,
|
||||
server,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
|
||||
const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME';
|
||||
const DEFAULT_CLAUDE_DIR_NAME = '.claude';
|
||||
|
|
@ -94,6 +95,41 @@ function getDefaultClaudeAgentDataHome() {
|
|||
return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME);
|
||||
}
|
||||
|
||||
function warnUnsafeProjectConfig() {
|
||||
console.error(
|
||||
'[ECC] Ignoring unsafe agent data project config: agentDataHome must stay ' +
|
||||
'within the default Cursor or Claude data directories. Use ' +
|
||||
'ECC_AGENT_DATA_HOME for an explicit trusted override.'
|
||||
);
|
||||
}
|
||||
|
||||
function isSafeProjectConfigSyntax(candidate) {
|
||||
const trimmed = candidate.trim();
|
||||
const isUserAnchored = trimmed.startsWith('~') || path.isAbsolute(trimmed);
|
||||
const hasParentTraversal = trimmed.split(/[/\\]+/).includes('..');
|
||||
return isUserAnchored && !hasParentTraversal;
|
||||
}
|
||||
|
||||
function resolveAllowedProjectConfigHome(candidate) {
|
||||
const allowedRoots = [
|
||||
getDefaultCursorAgentDataHome(),
|
||||
getDefaultClaudeAgentDataHome(),
|
||||
];
|
||||
|
||||
for (const allowedRoot of allowedRoots) {
|
||||
try {
|
||||
return assertWithinTrustedRoot(
|
||||
candidate,
|
||||
allowedRoot,
|
||||
'use project agent data home'
|
||||
);
|
||||
} catch {
|
||||
// Try the next explicitly allowed default root.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readProjectConfigAt(configPath) {
|
||||
if (!configPath || typeof configPath !== 'string') return null;
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
|
|
@ -103,8 +139,18 @@ function readProjectConfigAt(configPath) {
|
|||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const candidate = parsed.agentDataHome || parsed.ECC_AGENT_DATA_HOME;
|
||||
if (typeof candidate !== 'string' || !candidate.trim()) return null;
|
||||
if (!isSafeProjectConfigSyntax(candidate)) {
|
||||
warnUnsafeProjectConfig();
|
||||
return null;
|
||||
}
|
||||
const projectRoot = resolveProjectRootFromConfigPath(configPath);
|
||||
return expandHomePath(candidate, projectRoot);
|
||||
const resolved = expandHomePath(candidate, projectRoot);
|
||||
const allowedHome = resolveAllowedProjectConfigHome(resolved);
|
||||
if (!allowedHome) {
|
||||
warnUnsafeProjectConfig();
|
||||
return null;
|
||||
}
|
||||
return allowedHome;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}`
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,8 +15,12 @@ function parseHostHeader(value) {
|
|||
if (!value || typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
|
||||
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/);
|
||||
if (!match) return null;
|
||||
if (match[2] !== undefined) {
|
||||
const port = Number(match[2]);
|
||||
if (!Number.isInteger(port) || port > 65535) return null;
|
||||
}
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,15 @@ const path = require('path');
|
|||
* root - never trusted from the state file itself (GHSA-hfpv-w6mp-5g95).
|
||||
*/
|
||||
|
||||
function safeRealpath(target) {
|
||||
function pathEntryExists(target) {
|
||||
try {
|
||||
return fs.realpathSync(path.resolve(target));
|
||||
} catch {
|
||||
return path.resolve(target);
|
||||
fs.lstatSync(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +33,7 @@ function safeRealpath(target) {
|
|||
function realpathNearestExisting(target) {
|
||||
let current = path.resolve(target);
|
||||
const tail = [];
|
||||
while (!fs.existsSync(current)) {
|
||||
while (!pathEntryExists(current)) {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
|
|
@ -37,7 +41,7 @@ function realpathNearestExisting(target) {
|
|||
tail.unshift(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
const real = safeRealpath(current);
|
||||
const real = fs.realpathSync(current);
|
||||
return tail.length > 0 ? path.join(real, ...tail) : real;
|
||||
}
|
||||
|
||||
|
|
@ -45,17 +49,32 @@ function realpathNearestExisting(target) {
|
|||
* True when `target` resolves to `root` itself or a path beneath it, with
|
||||
* symlinks resolved on both sides.
|
||||
*/
|
||||
function resolveContainment(target, root) {
|
||||
const realRoot = realpathNearestExisting(root);
|
||||
const realTarget = realpathNearestExisting(target);
|
||||
const relativePath = path.relative(realRoot, realTarget);
|
||||
const contained = relativePath === ''
|
||||
|| (
|
||||
relativePath !== '..'
|
||||
&& !relativePath.startsWith(`..${path.sep}`)
|
||||
&& !path.isAbsolute(relativePath)
|
||||
);
|
||||
return {
|
||||
contained,
|
||||
realRoot,
|
||||
realTarget
|
||||
};
|
||||
}
|
||||
|
||||
function isWithinRoot(target, root) {
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
const realRoot = safeRealpath(root);
|
||||
const realTarget = realpathNearestExisting(target);
|
||||
if (realTarget === realRoot) {
|
||||
return true;
|
||||
try {
|
||||
return resolveContainment(target, root).contained;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -69,10 +88,17 @@ function assertWithinTrustedRoot(target, root, action = 'write') {
|
|||
if (!root) {
|
||||
throw new Error(`Refusing to ${action} '${target}': no trusted install root resolved.`);
|
||||
}
|
||||
if (!isWithinRoot(target, root)) {
|
||||
|
||||
let containment;
|
||||
try {
|
||||
containment = resolveContainment(target, root);
|
||||
} catch {
|
||||
containment = null;
|
||||
}
|
||||
if (!containment || !containment.contained) {
|
||||
throw new Error(`Refusing to ${action} outside the install root: '${target}' is not within '${root}'.`);
|
||||
}
|
||||
return realpathNearestExisting(target);
|
||||
return containment.realTarget;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,20 @@ function withIsolatedCwd(fn) {
|
|||
}
|
||||
}
|
||||
|
||||
function captureConsoleErrors(fn) {
|
||||
const originalError = console.error;
|
||||
const messages = [];
|
||||
console.error = (...args) => {
|
||||
messages.push(args.join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
return { result: fn(), messages };
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing agent-data-home.js ===\n');
|
||||
let passed = 0;
|
||||
|
|
@ -148,10 +162,11 @@ function runTests() {
|
|||
})) passed++; else failed++;
|
||||
|
||||
if (test('reads project ecc-agent-data.json config file', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `ecc-agent-data-home-read-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const configPath = path.join(tmpDir, 'ecc-agent-data.json');
|
||||
const customHome = path.join(tmpDir, 'data-root');
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-read-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-user-'));
|
||||
const configPath = path.join(tmpDir, '.cursor', 'ecc-agent-data.json');
|
||||
const customHome = path.join(homeDir, '.cursor', 'ecc', 'custom');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ agentDataHome: customHome }),
|
||||
|
|
@ -162,27 +177,74 @@ function runTests() {
|
|||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
CURSOR_VERSION: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
assert.strictEqual(
|
||||
agentDataHome.readProjectConfigAt(configPath),
|
||||
path.resolve(customHome)
|
||||
path.join(fs.realpathSync(homeDir), '.cursor', 'ecc', 'custom')
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('resolves relative agentDataHome against project root, not cwd', () => {
|
||||
if (test('allows the documented ~/.claude project sharing root and its descendants', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-user-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
|
||||
|
||||
try {
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
const cases = [
|
||||
{
|
||||
candidate: '~/.claude',
|
||||
expected: path.join(fs.realpathSync(homeDir), '.claude'),
|
||||
},
|
||||
{
|
||||
candidate: '~/.claude/shared',
|
||||
expected: path.join(fs.realpathSync(homeDir), '.claude', 'shared'),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { candidate, expected } of cases) {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ agentDataHome: candidate }),
|
||||
'utf8'
|
||||
);
|
||||
assert.strictEqual(
|
||||
agentDataHome.readProjectConfigAt(configPath),
|
||||
expected
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects a relative agentDataHome that redirects into the project', () => {
|
||||
const stamp = Date.now();
|
||||
const projectDir = path.join(os.tmpdir(), `ecc-agent-data-home-relative-${stamp}`);
|
||||
const cursorDir = path.join(projectDir, '.cursor');
|
||||
const otherCwd = path.join(os.tmpdir(), `ecc-agent-data-home-other-cwd-${stamp}`);
|
||||
const homeDir = path.join(os.tmpdir(), `ecc-agent-data-home-relative-user-${stamp}`);
|
||||
fs.mkdirSync(cursorDir, { recursive: true });
|
||||
fs.mkdirSync(otherCwd, { recursive: true });
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
const configPath = path.join(cursorDir, 'ecc-agent-data.json');
|
||||
const expectedHome = path.join(projectDir, '.ecc-data');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ agentDataHome: '.ecc-data' }),
|
||||
|
|
@ -196,18 +258,200 @@ function runTests() {
|
|||
ECC_AGENT_DATA_HOME: undefined,
|
||||
CURSOR_VERSION: undefined,
|
||||
CURSOR_PROJECT_DIR: projectDir,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
assert.strictEqual(agentDataHome.readProjectConfigAt(configPath), expectedHome);
|
||||
const { result, messages } = captureConsoleErrors(
|
||||
() => agentDataHome.readProjectConfigAt(configPath)
|
||||
);
|
||||
assert.strictEqual(result, null);
|
||||
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
|
||||
assert.ok(messages.every(message => !message.includes('.ecc-data')));
|
||||
assert.strictEqual(
|
||||
agentDataHome.resolveAgentDataHome({ projectDir }),
|
||||
expectedHome
|
||||
captureConsoleErrors(
|
||||
() => agentDataHome.resolveAgentDataHome({ projectDir, preferCursorDefault: true })
|
||||
).result,
|
||||
path.join(homeDir, '.cursor', 'ecc')
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(otherCwd, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects relative project config paths even when the project is beneath the trusted root', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-nested-user-'));
|
||||
const projectDir = path.join(homeDir, '.cursor', 'ecc', 'checked-out-project');
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
const candidate = '.repo-data';
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
|
||||
|
||||
try {
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
const { result, messages } = captureConsoleErrors(
|
||||
() => agentDataHome.readProjectConfigAt(configPath)
|
||||
);
|
||||
assert.strictEqual(result, null);
|
||||
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
|
||||
assert.ok(messages.every(message => !message.includes(candidate)));
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects traversal and absolute project config paths outside the allowed data roots', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-unsafe-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-unsafe-user-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
|
||||
try {
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
const unsafeCandidates = [
|
||||
'../../repo-data',
|
||||
path.join(projectDir, 'absolute-data'),
|
||||
'~/.cursor/ecc/profiles/../traversed-data',
|
||||
'~/.claude/profiles/../traversed-data',
|
||||
'~/.claude-other',
|
||||
'~/.config/ecc',
|
||||
'~',
|
||||
path.join(homeDir, 'arbitrary-agent-data'),
|
||||
];
|
||||
for (const candidate of unsafeCandidates) {
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
|
||||
const { result, messages } = captureConsoleErrors(
|
||||
() => agentDataHome.readProjectConfigAt(configPath)
|
||||
);
|
||||
assert.strictEqual(result, null);
|
||||
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
|
||||
assert.ok(messages.every(message => !message.includes(candidate)));
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects a Claude project config destination that escapes through a symlink', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-user-'));
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-outside-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const linkPath = path.join(claudeRoot, 'redirect');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(claudeRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(outsideDir, linkPath, 'dir');
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
const candidate = path.join(linkPath, 'session-data');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
|
||||
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
const { result, messages } = captureConsoleErrors(
|
||||
() => agentDataHome.readProjectConfigAt(configPath)
|
||||
);
|
||||
assert.strictEqual(result, null);
|
||||
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
|
||||
assert.ok(messages.every(message => !message.includes(candidate)));
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('allows a non-existent project config destination beneath the Cursor data root', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-safe-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-safe-user-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
const safeHome = path.join(homeDir, '.cursor', 'ecc', 'profiles', 'work');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: safeHome }), 'utf8');
|
||||
|
||||
try {
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
assert.strictEqual(
|
||||
agentDataHome.readProjectConfigAt(configPath),
|
||||
path.join(fs.realpathSync(homeDir), '.cursor', 'ecc', 'profiles', 'work')
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects a project config destination that escapes through a symlink', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-user-'));
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-outside-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
const cursorRoot = path.join(homeDir, '.cursor', 'ecc');
|
||||
const linkPath = path.join(cursorRoot, 'redirect');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(cursorRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(outsideDir, linkPath, 'dir');
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
const candidate = path.join(linkPath, 'session-data');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
|
||||
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
const { result, messages } = captureConsoleErrors(
|
||||
() => agentDataHome.readProjectConfigAt(configPath)
|
||||
);
|
||||
assert.strictEqual(result, null);
|
||||
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
|
||||
assert.ok(messages.every(message => !message.includes(candidate)));
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const {
|
|||
const { getInstallTargetAdapter } = require('../../scripts/lib/install-targets/registry');
|
||||
const {
|
||||
createInstallState,
|
||||
readInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
|
|
@ -731,6 +732,93 @@ function runTests() {
|
|||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Claude repair migration derives roots from the adapter and removes only the managed legacy file', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(homeDir, '.claude');
|
||||
const adapterStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const recordedStatePath = path.join(outsideRoot, 'recorded-state.json');
|
||||
const flatSkillPath = path.join(targetRoot, 'skills', 'tdd-workflow', 'SKILL.md');
|
||||
const legacySkillPath = path.join(
|
||||
targetRoot,
|
||||
'skills',
|
||||
'ecc',
|
||||
'tdd-workflow',
|
||||
'SKILL.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(legacySkillPath), { recursive: true });
|
||||
fs.writeFileSync(legacySkillPath, '# Previously managed nested skill\n');
|
||||
|
||||
writeState(adapterStatePath, {
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
targetRoot: outsideRoot,
|
||||
installStatePath: recordedStatePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['workflow-quality'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs', 'workflow-quality'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'workflow-quality',
|
||||
sourcePath: path.join(REPO_ROOT, 'skills', 'tdd-workflow', 'SKILL.md'),
|
||||
sourceRelativePath: path.join('skills', 'tdd-workflow', 'SKILL.md'),
|
||||
destinationPath: legacySkillPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
}],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(recordedStatePath, 'outside sentinel\n');
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['claude'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'repaired');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(flatSkillPath, 'utf8'),
|
||||
fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'skills', 'tdd-workflow', 'SKILL.md'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
assert.ok(!fs.existsSync(legacySkillPath));
|
||||
assert.strictEqual(fs.readFileSync(recordedStatePath, 'utf8'), 'outside sentinel\n');
|
||||
const refreshedState = readInstallState(adapterStatePath);
|
||||
assert.strictEqual(refreshedState.target.root, targetRoot);
|
||||
assert.strictEqual(refreshedState.target.installStatePath, adapterStatePath);
|
||||
assert.ok(refreshedState.operations.some(operation => (
|
||||
operation.destinationPath === flatSkillPath
|
||||
)));
|
||||
assert.ok(!refreshedState.operations.some(operation => (
|
||||
operation.destinationPath === legacySkillPath
|
||||
)));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair copies missing managed files from recorded source paths', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
|
@ -763,6 +851,47 @@ function runTests() {
|
|||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair reads source content and mode from one no-follow descriptor', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const sourcePath = path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md');
|
||||
const originalStatSync = fs.statSync;
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const destinationPath = path.join(targetRoot, 'rules', 'coding-style.md');
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, {
|
||||
sourceRelativePath: 'rules/common/coding-style.md',
|
||||
strategy: 'copy-file',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
fs.statSync = function rejectSeparateSourceMetadataLookup(candidatePath, ...args) {
|
||||
if (path.resolve(candidatePath) === path.resolve(sourcePath)) {
|
||||
throw new Error('source metadata must come from the opened descriptor');
|
||||
}
|
||||
return originalStatSync.call(fs, candidatePath, ...args);
|
||||
};
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'repaired');
|
||||
assert.ok(fs.readFileSync(destinationPath).equals(fs.readFileSync(sourcePath)));
|
||||
} finally {
|
||||
fs.statSync = originalStatSync;
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair reports invalid states, missing sources, unsupported operations, and no-op refreshes', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const invalidProjectRoot = createTempDir('install-lifecycle-invalid-');
|
||||
|
|
@ -1114,6 +1243,162 @@ function runTests() {
|
|||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair rejects absolute and parent-relative source metadata outside the repository', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-source-outside-');
|
||||
const outsideSourcePath = path.join(outsideRoot, 'secret.txt');
|
||||
fs.writeFileSync(outsideSourcePath, 'outside secret\n');
|
||||
|
||||
try {
|
||||
const unsafeSources = [
|
||||
outsideSourcePath,
|
||||
path.relative(REPO_ROOT, outsideSourcePath),
|
||||
];
|
||||
|
||||
for (const sourceRelativePath of unsafeSources) {
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
try {
|
||||
const destinationPath = path.join(projectRoot, '.cursor', 'copied-secret.txt');
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, {
|
||||
sourceRelativePath,
|
||||
strategy: 'copy-file',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const doctor = buildDoctorReport({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(doctor.results[0].status, 'error');
|
||||
assert.ok(
|
||||
doctor.results[0].issues.some(
|
||||
issue => issue.code === 'unsafe-repair-source'
|
||||
)
|
||||
);
|
||||
assert.strictEqual(result.results[0].status, 'error');
|
||||
assert.ok(result.results[0].error.includes('unsafe repair source metadata'));
|
||||
assert.ok(!result.results[0].error.includes(outsideSourcePath));
|
||||
assert.ok(!fs.existsSync(destinationPath));
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('doctor and repair reject unsafe destinations before health inspection reads them', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-destination-outside-');
|
||||
const copySource = fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'),
|
||||
'utf8'
|
||||
);
|
||||
const cases = [
|
||||
{
|
||||
name: 'matching copy',
|
||||
kind: 'copy-file',
|
||||
content: copySource,
|
||||
overrides: { strategy: 'copy-file' },
|
||||
},
|
||||
{
|
||||
name: 'drifted copy',
|
||||
kind: 'copy-file',
|
||||
content: 'outside drift\n',
|
||||
overrides: { strategy: 'copy-file' },
|
||||
},
|
||||
{
|
||||
name: 'rendered template',
|
||||
kind: 'render-template',
|
||||
content: 'managed template\n',
|
||||
overrides: {
|
||||
renderedContent: 'managed template\n',
|
||||
strategy: 'render-template',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'merged JSON',
|
||||
kind: 'merge-json',
|
||||
content: '{"managed":true,"outside":"sentinel"}\n',
|
||||
overrides: {
|
||||
mergePayload: { managed: true },
|
||||
strategy: 'merge-json',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
for (const testCase of cases) {
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const destinationPath = path.join(outsideRoot, `${testCase.name}.txt`);
|
||||
const originalExistsSync = fs.existsSync;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(destinationPath, testCase.content);
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation(testCase.kind, destinationPath, testCase.overrides),
|
||||
],
|
||||
});
|
||||
|
||||
fs.existsSync = function existsSyncWithoutOutsideInspection(candidatePath) {
|
||||
if (path.resolve(candidatePath) === path.resolve(destinationPath)) {
|
||||
throw new Error(`unsafe destination inspected: ${testCase.name}`);
|
||||
}
|
||||
return originalExistsSync.call(fs, candidatePath);
|
||||
};
|
||||
|
||||
const doctor = buildDoctorReport({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
const repair = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(doctor.results[0].status, 'error');
|
||||
assert.ok(
|
||||
doctor.results[0].issues.some(
|
||||
issue => issue.code === 'unsafe-managed-destination'
|
||||
)
|
||||
);
|
||||
assert.strictEqual(repair.results[0].status, 'error');
|
||||
assert.ok(repair.results[0].error.includes('unsafe managed destination'));
|
||||
assert.strictEqual(
|
||||
originalExistsSync.call(fs, destinationPath),
|
||||
true,
|
||||
`${testCase.name} destination should remain untouched`
|
||||
);
|
||||
} finally {
|
||||
fs.existsSync = originalExistsSync;
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('doctor reports drifted managed files as a warning', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
|
@ -1414,6 +1699,293 @@ function runTests() {
|
|||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair rejects a symlink inserted while creating a missing destination parent', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const destinationParent = path.join(targetRoot, 'late-parent');
|
||||
const destinationPath = path.join(destinationParent, 'managed.md');
|
||||
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
|
||||
const originalMkdirSync = fs.mkdirSync;
|
||||
let canonicalDestinationParent;
|
||||
let insertedSymlink = false;
|
||||
let result;
|
||||
|
||||
try {
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
canonicalDestinationParent = path.join(
|
||||
fs.realpathSync(targetRoot),
|
||||
path.basename(destinationParent)
|
||||
);
|
||||
|
||||
fs.mkdirSync = function mkdirSyncWithLateSymlink(directoryPath, options) {
|
||||
if (!insertedSymlink && path.resolve(directoryPath) === canonicalDestinationParent) {
|
||||
originalMkdirSync.call(fs, path.dirname(canonicalDestinationParent), { recursive: true });
|
||||
fs.symlinkSync(
|
||||
outsideRoot,
|
||||
canonicalDestinationParent,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
insertedSymlink = true;
|
||||
return undefined;
|
||||
}
|
||||
return originalMkdirSync.call(fs, directoryPath, options);
|
||||
};
|
||||
|
||||
result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
} finally {
|
||||
fs.mkdirSync = originalMkdirSync;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.strictEqual(insertedSymlink, true);
|
||||
assert.strictEqual(result.results[0].status, 'error');
|
||||
assert.ok(result.results[0].error.includes('outside the install root'));
|
||||
assert.ok(!fs.existsSync(outsideDestinationPath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair rejects an in-root final symlink without overwriting its victim', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const victimPath = path.join(targetRoot, 'victim.md');
|
||||
const destinationPath = path.join(targetRoot, 'managed.md');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
fs.writeFileSync(victimPath, 'victim sentinel\n');
|
||||
try {
|
||||
fs.symlinkSync(victimPath, destinationPath);
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('render-template', destinationPath, {
|
||||
renderedContent: 'managed replacement\n',
|
||||
strategy: 'render-template',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'error');
|
||||
assert.ok(result.results[0].error.includes('final symlink'));
|
||||
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim sentinel\n');
|
||||
assert.strictEqual(fs.lstatSync(destinationPath).isSymbolicLink(), true);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair uses no-follow writes when a final destination becomes a symlink', () => {
|
||||
if (!fs.constants.O_NOFOLLOW) {
|
||||
console.log(' (O_NOFOLLOW unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const destinationPath = path.join(targetRoot, 'managed.md');
|
||||
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
|
||||
const originalOpenSync = fs.openSync;
|
||||
let canonicalDestinationPath;
|
||||
let insertedSymlink = false;
|
||||
let result;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
canonicalDestinationPath = path.join(
|
||||
fs.realpathSync(targetRoot),
|
||||
path.basename(destinationPath)
|
||||
);
|
||||
|
||||
fs.openSync = function openSyncWithLateSymlink(filePath, flags, mode) {
|
||||
if (!insertedSymlink && path.resolve(filePath) === canonicalDestinationPath) {
|
||||
fs.symlinkSync(outsideDestinationPath, canonicalDestinationPath);
|
||||
insertedSymlink = true;
|
||||
}
|
||||
return originalOpenSync.call(fs, filePath, flags, mode);
|
||||
};
|
||||
|
||||
result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
} finally {
|
||||
fs.openSync = originalOpenSync;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.strictEqual(insertedSymlink, true);
|
||||
assert.strictEqual(result.results[0].status, 'error');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(outsideDestinationPath, 'utf8'),
|
||||
'outside sentinel\n'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair revalidates a pinned write before a swapped parent can truncate outside files', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const destinationParent = path.join(targetRoot, 'late-parent');
|
||||
const backupParent = path.join(targetRoot, 'late-parent-backup');
|
||||
const destinationPath = path.join(destinationParent, 'managed.md');
|
||||
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
|
||||
const originalOpenSync = fs.openSync;
|
||||
let canonicalDestinationPath;
|
||||
let insertedSymlink = false;
|
||||
let result;
|
||||
|
||||
const symlinkProbe = path.join(targetRoot, 'parent-symlink-probe');
|
||||
try {
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
outsideRoot,
|
||||
symlinkProbe,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
fs.rmSync(symlinkProbe, { force: true });
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
fs.writeFileSync(destinationPath, 'drifted managed content\n');
|
||||
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
|
||||
canonicalDestinationPath = fs.realpathSync(destinationPath);
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
|
||||
fs.openSync = function openSyncWithLateParentSwap(filePath, flags, mode) {
|
||||
const isDestinationWrite = path.resolve(filePath) === canonicalDestinationPath
|
||||
&& typeof flags === 'number'
|
||||
&& (flags & fs.constants.O_WRONLY) === fs.constants.O_WRONLY;
|
||||
if (!insertedSymlink && isDestinationWrite) {
|
||||
fs.renameSync(destinationParent, backupParent);
|
||||
fs.symlinkSync(
|
||||
outsideRoot,
|
||||
destinationParent,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
insertedSymlink = true;
|
||||
}
|
||||
return originalOpenSync.call(fs, filePath, flags, mode);
|
||||
};
|
||||
|
||||
result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
} finally {
|
||||
fs.openSync = originalOpenSync;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.strictEqual(insertedSymlink, true);
|
||||
assert.strictEqual(result.results[0].status, 'error');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(outsideDestinationPath, 'utf8'),
|
||||
'outside sentinel\n'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(backupParent, 'managed.md'), 'utf8'),
|
||||
'drifted managed content\n'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair refreshes only the adapter-derived install-state path', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const adapterStatePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
const recordedStatePath = path.join(outsideRoot, 'recorded-state.json');
|
||||
const stateOptions = createCursorStateOptions(projectRoot, {
|
||||
installStatePath: recordedStatePath,
|
||||
});
|
||||
writeState(adapterStatePath, stateOptions);
|
||||
fs.writeFileSync(recordedStatePath, 'outside sentinel\n');
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'ok');
|
||||
assert.ok(fs.existsSync(adapterStatePath));
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(recordedStatePath, 'utf8'),
|
||||
'outside sentinel\n'
|
||||
);
|
||||
const refreshedState = readInstallState(adapterStatePath);
|
||||
assert.strictEqual(refreshedState.target.root, targetRoot);
|
||||
assert.strictEqual(refreshedState.target.installStatePath, adapterStatePath);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall restores JSON merged files from recorded previous content', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
|
@ -1662,6 +2234,40 @@ function runTests() {
|
|||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall removes only the adapter-derived install-state path', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const adapterStatePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
const recordedStatePath = path.join(outsideRoot, 'recorded-state.json');
|
||||
const stateOptions = createCursorStateOptions(projectRoot, {
|
||||
installStatePath: recordedStatePath,
|
||||
});
|
||||
writeState(adapterStatePath, stateOptions);
|
||||
fs.writeFileSync(recordedStatePath, 'outside sentinel\n');
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||
assert.ok(!fs.existsSync(adapterStatePath));
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(recordedStatePath, 'utf8'),
|
||||
'outside sentinel\n'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall removes copied files and cleans empty parent directories', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
|
@ -1694,6 +2300,42 @@ function runTests() {
|
|||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall cleanup stops at the adapter-derived target root', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const cleanupBoundaryRoot = createTempDir('install-lifecycle-boundary-');
|
||||
const projectRoot = path.join(cleanupBoundaryRoot, 'project');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const adapterStatePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
const destinationPath = path.join(targetRoot, 'rules', 'nested', 'managed.md');
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, 'managed\n');
|
||||
const stateOptions = createCursorStateOptions(projectRoot, {
|
||||
targetRoot: cleanupBoundaryRoot,
|
||||
installStatePath: adapterStatePath,
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
writeState(adapterStatePath, stateOptions);
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||
assert.ok(fs.existsSync(projectRoot));
|
||||
assert.ok(fs.existsSync(targetRoot));
|
||||
assert.ok(!fs.existsSync(destinationPath));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(cleanupBoundaryRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall handles merge-json subset removal and full-file deletion', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const partialProjectRoot = createTempDir('install-lifecycle-partial-');
|
||||
|
|
@ -1899,6 +2541,107 @@ function runTests() {
|
|||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall removes an in-root final symlink without deleting its victim', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const victimPath = path.join(targetRoot, 'victim.md');
|
||||
const destinationPath = path.join(targetRoot, 'managed.md');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
fs.writeFileSync(victimPath, 'victim sentinel\n');
|
||||
try {
|
||||
fs.symlinkSync(victimPath, destinationPath);
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||
assert.ok(!fs.existsSync(destinationPath));
|
||||
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim sentinel\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall rejects a symlink inserted after initial destination validation', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const destinationParent = path.join(targetRoot, 'late-parent');
|
||||
const backupParent = path.join(targetRoot, 'late-parent-backup');
|
||||
const destinationPath = path.join(destinationParent, 'managed.md');
|
||||
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
|
||||
const originalExistsSync = fs.existsSync;
|
||||
let canonicalDestinationParent;
|
||||
let canonicalDestinationPath;
|
||||
let insertedSymlink = false;
|
||||
let result;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
fs.writeFileSync(destinationPath, 'managed\n');
|
||||
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
canonicalDestinationPath = fs.realpathSync(destinationPath);
|
||||
canonicalDestinationParent = path.dirname(canonicalDestinationPath);
|
||||
|
||||
fs.existsSync = function existsSyncWithLateSymlink(candidatePath) {
|
||||
if (!insertedSymlink && path.resolve(candidatePath) === canonicalDestinationPath) {
|
||||
fs.renameSync(canonicalDestinationParent, backupParent);
|
||||
fs.symlinkSync(
|
||||
outsideRoot,
|
||||
canonicalDestinationParent,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
insertedSymlink = true;
|
||||
}
|
||||
return originalExistsSync.call(fs, candidatePath);
|
||||
};
|
||||
|
||||
result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
} finally {
|
||||
fs.existsSync = originalExistsSync;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.strictEqual(insertedSymlink, true);
|
||||
assert.strictEqual(result.results[0].status, 'error');
|
||||
assert.ok(result.results[0].error.includes('outside the install root'));
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(outsideDestinationPath, 'utf8'),
|
||||
'outside sentinel\n'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall restores previous JSON snapshots for template and remove operations', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ function runTests() {
|
|||
assert.strictEqual(parseHostHeader('bad:host:extra'), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects ports outside the valid TCP range', () => {
|
||||
assert.strictEqual(parseHostHeader('localhost:65536'), null);
|
||||
assert.strictEqual(parseHostHeader('localhost:99999'), null);
|
||||
assert.strictEqual(parseHostHeader('[::1]:65536'), null);
|
||||
assert.strictEqual(parseHostHeader('localhost:65535'), 'localhost');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nbuildAllowedHostnames:');
|
||||
|
||||
if (test('always includes loopback names', () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ const fs = require('fs');
|
|||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { assertWithinTrustedRoot, isWithinRoot } = require('../../scripts/lib/path-safety');
|
||||
const {
|
||||
assertWithinTrustedRoot,
|
||||
isWithinRoot,
|
||||
realpathNearestExisting
|
||||
} = require('../../scripts/lib/path-safety');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
|
@ -43,12 +47,89 @@ try {
|
|||
assert.strictEqual(isWithinRoot(root, root), true);
|
||||
});
|
||||
|
||||
test('allows a non-existent destination beneath a non-existent trusted root', () => {
|
||||
const futureRoot = path.join(root, 'future-root');
|
||||
const futureDestination = path.join(futureRoot, 'session-data', 'session.json');
|
||||
assert.strictEqual(isWithinRoot(futureDestination, futureRoot), true);
|
||||
assert.strictEqual(
|
||||
assertWithinTrustedRoot(futureDestination, futureRoot, 'write'),
|
||||
realpathNearestExisting(futureDestination)
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes the nearest existing ancestor for a non-existent trusted root', () => {
|
||||
const realParent = fs.mkdtempSync(path.join(os.tmpdir(), 'path-safety-real-'));
|
||||
const linkedParent = path.join(
|
||||
os.tmpdir(),
|
||||
`path-safety-link-${process.pid}-${Date.now()}`
|
||||
);
|
||||
|
||||
try {
|
||||
fs.symlinkSync(realParent, linkedParent, 'dir');
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
fs.rmSync(realParent, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const futureRoot = path.join(linkedParent, '.cursor', 'ecc');
|
||||
const futureDestination = path.join(futureRoot, 'session-data', 'session.json');
|
||||
assert.strictEqual(isWithinRoot(futureDestination, futureRoot), true);
|
||||
assert.strictEqual(
|
||||
assertWithinTrustedRoot(futureDestination, futureRoot, 'write'),
|
||||
path.join(
|
||||
fs.realpathSync(realParent),
|
||||
'.cursor',
|
||||
'ecc',
|
||||
'session-data',
|
||||
'session.json'
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(linkedParent, { force: true });
|
||||
fs.rmSync(realParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('returns the same canonical destination that was checked for containment', () => {
|
||||
const destination = path.join(root, 'single-canonicalization.txt');
|
||||
const originalRealpathSync = fs.realpathSync;
|
||||
let destinationCanonicalizations = 0;
|
||||
fs.writeFileSync(destination, 'safe\n');
|
||||
|
||||
fs.realpathSync = function countedRealpathSync(candidatePath, options) {
|
||||
if (path.resolve(candidatePath) === path.resolve(destination)) {
|
||||
destinationCanonicalizations += 1;
|
||||
}
|
||||
return originalRealpathSync.call(fs, candidatePath, options);
|
||||
};
|
||||
|
||||
try {
|
||||
assert.strictEqual(
|
||||
assertWithinTrustedRoot(destination, root, 'write'),
|
||||
originalRealpathSync(destination)
|
||||
);
|
||||
} finally {
|
||||
fs.realpathSync = originalRealpathSync;
|
||||
}
|
||||
|
||||
assert.strictEqual(destinationCanonicalizations, 1);
|
||||
});
|
||||
|
||||
test('refuses an absolute path outside the root', () => {
|
||||
const evil = path.join(outside, 'PWNED.txt');
|
||||
assert.throws(() => assertWithinTrustedRoot(evil, root, 'repair'), /outside the install root/);
|
||||
assert.strictEqual(isWithinRoot(evil, root), false);
|
||||
});
|
||||
|
||||
test('refuses an escape from a non-existent trusted root', () => {
|
||||
const futureRoot = path.join(root, 'future-root');
|
||||
const evil = path.join(futureRoot, '..', 'escape.txt');
|
||||
assert.throws(() => assertWithinTrustedRoot(evil, futureRoot, 'write'), /outside the install root/);
|
||||
assert.strictEqual(isWithinRoot(evil, futureRoot), false);
|
||||
});
|
||||
|
||||
test('refuses a ../ traversal escape', () => {
|
||||
const evil = path.join(root, '..', 'escape.txt');
|
||||
assert.throws(() => assertWithinTrustedRoot(evil, root, 'uninstall'), /outside the install root/);
|
||||
|
|
@ -67,6 +148,23 @@ try {
|
|||
assert.throws(() => assertWithinTrustedRoot(evil, root, 'repair'), /outside the install root/);
|
||||
});
|
||||
|
||||
test('refuses a dangling symlinked intermediate directory', () => {
|
||||
const danglingTarget = path.join(outside, 'missing-target');
|
||||
const linkDir = path.join(root, 'dangling-link');
|
||||
try {
|
||||
fs.symlinkSync(danglingTarget, linkDir, 'dir');
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
const evil = path.join(linkDir, 'session-data', 'session.json');
|
||||
assert.strictEqual(isWithinRoot(evil, root), false);
|
||||
assert.throws(
|
||||
() => assertWithinTrustedRoot(evil, root, 'write'),
|
||||
/outside the install root/
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses when no trusted root is resolved', () => {
|
||||
assert.throws(() => assertWithinTrustedRoot(path.join(root, 'x'), null, 'repair'), /no trusted install root/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ const fs = require('fs');
|
|||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const net = require('net');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'dashboard-web.js');
|
||||
|
||||
let testRoot;
|
||||
let testPassed = 0;
|
||||
let testFailed = 0;
|
||||
const asyncTests = [];
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
|
|
@ -28,6 +31,10 @@ function test(name, fn) {
|
|||
}
|
||||
}
|
||||
|
||||
function asyncTest(name, fn) {
|
||||
asyncTests.push({ name, fn });
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
|
@ -65,6 +72,121 @@ function writeFile(rootDir, relativePath, content) {
|
|||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function requestDashboard(port, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let request;
|
||||
const settle = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
callback(value);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
const error = new Error(
|
||||
`Dashboard request timed out after ${REQUEST_TIMEOUT_MS}ms`
|
||||
);
|
||||
if (request) request.destroy();
|
||||
settle(reject, error);
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
request = http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
method: options.method || 'GET',
|
||||
path: options.path || '/',
|
||||
headers: options.headers || {},
|
||||
setHost: options.setHost !== false,
|
||||
}, (response) => {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('error', error => settle(reject, error));
|
||||
response.on('end', () => {
|
||||
settle(resolve, {
|
||||
body,
|
||||
headers: response.headers,
|
||||
statusCode: response.statusCode,
|
||||
});
|
||||
});
|
||||
});
|
||||
request.on('error', error => settle(reject, error));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function requestDashboardWithoutHost(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
let raw = '';
|
||||
let settled = false;
|
||||
const settle = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
callback(value);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
const error = new Error(
|
||||
`Host-less request timed out after ${REQUEST_TIMEOUT_MS}ms`
|
||||
);
|
||||
socket.destroy();
|
||||
settle(reject, error);
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('connect', () => {
|
||||
socket.write('GET / HTTP/1.0\r\n\r\n');
|
||||
});
|
||||
socket.on('data', (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
socket.on('end', () => {
|
||||
const [head, body = ''] = raw.split('\r\n\r\n');
|
||||
const lines = head.split('\r\n');
|
||||
const statusCode = Number.parseInt(lines[0].split(' ')[1], 10);
|
||||
const headers = {};
|
||||
for (const line of lines.slice(1)) {
|
||||
const separator = line.indexOf(':');
|
||||
if (separator < 1) continue;
|
||||
headers[line.slice(0, separator).toLowerCase()] = line.slice(separator + 1).trim();
|
||||
}
|
||||
settle(resolve, { body, headers, statusCode });
|
||||
});
|
||||
socket.on('error', error => settle(reject, error));
|
||||
socket.on('close', hadError => {
|
||||
if (!hadError && !settled) {
|
||||
settle(reject, new Error('Host-less request closed before completion'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function withDashboardServer(fn, serverOptions = {}) {
|
||||
const { createDashboardServer } = require(SCRIPT);
|
||||
const testServer = createDashboardServer({
|
||||
host: '127.0.0.1',
|
||||
...serverOptions,
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
testServer.once('error', reject);
|
||||
testServer.listen(0, '127.0.0.1', () => {
|
||||
testServer.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await fn(testServer.address().port);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
testServer.close(error => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== parsePort =====================
|
||||
|
||||
test('parsePort returns 3456 for undefined', () => {
|
||||
|
|
@ -721,49 +843,228 @@ test('renderHTML includes the dashboard title and footer', () => {
|
|||
|
||||
// ===================== Server / HTTP =====================
|
||||
|
||||
test('server returns HTML on GET /', (done) => {
|
||||
const { server } = require(SCRIPT);
|
||||
// Server may or may not be listening — we start it on a random port
|
||||
const testServer = http.createServer(server._events.request);
|
||||
testServer.listen(0, () => {
|
||||
const port = testServer.address().port;
|
||||
http.get(`http://localhost:${port}/`, (res) => {
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8');
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
assert.ok(body.includes('<!DOCTYPE html>'));
|
||||
assert.ok(body.includes('ECC Capabilities'));
|
||||
testServer.close();
|
||||
done();
|
||||
});
|
||||
});
|
||||
test('resolveDashboardHost defaults to IPv4 loopback', () => {
|
||||
const { resolveDashboardHost } = require(SCRIPT);
|
||||
assert.strictEqual(resolveDashboardHost({}), '127.0.0.1');
|
||||
assert.strictEqual(resolveDashboardHost({ ECC_DASHBOARD_HOST: '' }), '127.0.0.1');
|
||||
});
|
||||
|
||||
test('resolveDashboardHost accepts only normalized loopback hosts', () => {
|
||||
const { resolveDashboardHost } = require(SCRIPT);
|
||||
assert.strictEqual(
|
||||
resolveDashboardHost({ ECC_DASHBOARD_HOST: ' LOCALHOST ' }),
|
||||
'localhost'
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveDashboardHost({ ECC_DASHBOARD_HOST: '::1' }),
|
||||
'::1'
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveDashboardHost({ ECC_DASHBOARD_HOST: '[::1]' }),
|
||||
'::1'
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDashboardHost rejects wildcard, LAN, and arbitrary hosts', () => {
|
||||
const { resolveDashboardHost } = require(SCRIPT);
|
||||
for (const host of ['0.0.0.0', '::', '192.168.1.10', 'dashboard.internal', '127.0.0.1:3456']) {
|
||||
assert.throws(
|
||||
() => resolveDashboardHost({ ECC_DASHBOARD_HOST: host }),
|
||||
/ECC_DASHBOARD_HOST must be loopback-only/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('listenDashboardServer always passes an explicit loopback host to listen', () => {
|
||||
const { listenDashboardServer } = require(SCRIPT);
|
||||
const calls = [];
|
||||
const fakeServer = {
|
||||
listen(...args) {
|
||||
calls.push(args);
|
||||
return this;
|
||||
},
|
||||
};
|
||||
const onListening = () => {};
|
||||
|
||||
assert.strictEqual(
|
||||
listenDashboardServer(fakeServer, {
|
||||
host: '127.0.0.1',
|
||||
onListening,
|
||||
port: 3456,
|
||||
}),
|
||||
fakeServer
|
||||
);
|
||||
assert.deepStrictEqual(calls, [[3456, '127.0.0.1', onListening]]);
|
||||
assert.throws(
|
||||
() => listenDashboardServer(fakeServer, { host: '0.0.0.0', port: 3456 }),
|
||||
/ECC_DASHBOARD_HOST must be loopback-only/
|
||||
);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
asyncTest('server returns no-store HTML on GET /', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const response = await requestDashboard(port);
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
assert.strictEqual(response.headers['content-type'], 'text/html; charset=utf-8');
|
||||
assert.strictEqual(response.headers['cache-control'], 'no-store');
|
||||
assert.ok(response.body.includes('<!DOCTYPE html>'));
|
||||
assert.ok(response.body.includes('ECC Capabilities'));
|
||||
});
|
||||
});
|
||||
|
||||
test('server returns JSON on GET /api/data', (done) => {
|
||||
const { server } = require(SCRIPT);
|
||||
const testServer = http.createServer(server._events.request);
|
||||
testServer.listen(0, () => {
|
||||
const port = testServer.address().port;
|
||||
http.get(`http://localhost:${port}/api/data`, (res) => {
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.headers['content-type'], 'application/json');
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
const parsed = JSON.parse(body);
|
||||
assert.ok(Array.isArray(parsed.agents));
|
||||
assert.ok(Array.isArray(parsed.skills));
|
||||
assert.ok(Array.isArray(parsed.commands));
|
||||
assert.ok(Array.isArray(parsed.rules));
|
||||
assert.ok(Array.isArray(parsed.mcps));
|
||||
assert.ok(Array.isArray(parsed.hooks));
|
||||
testServer.close();
|
||||
done();
|
||||
});
|
||||
asyncTest('server returns no-store JSON on GET /api/data', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const response = await requestDashboard(port, { path: '/api/data' });
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
assert.strictEqual(response.headers['content-type'], 'application/json');
|
||||
assert.strictEqual(response.headers['cache-control'], 'no-store');
|
||||
const parsed = JSON.parse(response.body);
|
||||
assert.ok(Array.isArray(parsed.agents));
|
||||
assert.ok(Array.isArray(parsed.skills));
|
||||
assert.ok(Array.isArray(parsed.commands));
|
||||
assert.ok(Array.isArray(parsed.rules));
|
||||
assert.ok(Array.isArray(parsed.mcps));
|
||||
assert.ok(Array.isArray(parsed.hooks));
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server returns a generic no-store 500 for data failures and remains usable', async () => {
|
||||
let loadCount = 0;
|
||||
const loggedErrors = [];
|
||||
const emptyData = {
|
||||
agents: [],
|
||||
skills: [],
|
||||
commands: [],
|
||||
rules: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
};
|
||||
|
||||
await withDashboardServer(async (port) => {
|
||||
const failedResponse = await requestDashboard(port, { path: '/api/data' });
|
||||
assert.strictEqual(failedResponse.statusCode, 500);
|
||||
assert.strictEqual(failedResponse.headers['cache-control'], 'no-store');
|
||||
assert.deepStrictEqual(JSON.parse(failedResponse.body), {
|
||||
error: 'Internal server error',
|
||||
});
|
||||
assert.ok(!failedResponse.body.includes('sensitive loader detail'));
|
||||
|
||||
const followUpResponse = await requestDashboard(port, { path: '/api/data' });
|
||||
assert.strictEqual(followUpResponse.statusCode, 200);
|
||||
assert.deepStrictEqual(JSON.parse(followUpResponse.body), emptyData);
|
||||
assert.strictEqual(loggedErrors.length, 1);
|
||||
assert.strictEqual(loggedErrors[0].error.message, 'sensitive loader detail');
|
||||
}, {
|
||||
loadData: () => {
|
||||
loadCount++;
|
||||
if (loadCount === 1) {
|
||||
throw new Error('sensitive loader detail');
|
||||
}
|
||||
return emptyData;
|
||||
},
|
||||
reportError: (message, error) => {
|
||||
loggedErrors.push({ error, message });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server returns a generic no-store 500 for render failures and remains usable', async () => {
|
||||
const { renderHTML } = require(SCRIPT);
|
||||
let renderCount = 0;
|
||||
const loggedErrors = [];
|
||||
const emptyData = {
|
||||
agents: [],
|
||||
skills: [],
|
||||
commands: [],
|
||||
rules: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
};
|
||||
|
||||
await withDashboardServer(async (port) => {
|
||||
const failedResponse = await requestDashboard(port);
|
||||
assert.strictEqual(failedResponse.statusCode, 500);
|
||||
assert.strictEqual(failedResponse.headers['cache-control'], 'no-store');
|
||||
assert.strictEqual(
|
||||
failedResponse.body,
|
||||
'<!DOCTYPE html><p>Dashboard unavailable.</p>'
|
||||
);
|
||||
assert.ok(!failedResponse.body.includes('sensitive render detail'));
|
||||
|
||||
const followUpResponse = await requestDashboard(port);
|
||||
assert.strictEqual(followUpResponse.statusCode, 200);
|
||||
assert.ok(followUpResponse.body.includes('ECC Capabilities'));
|
||||
assert.strictEqual(loggedErrors.length, 1);
|
||||
assert.strictEqual(loggedErrors[0].error.message, 'sensitive render detail');
|
||||
}, {
|
||||
loadData: () => emptyData,
|
||||
render: (data) => {
|
||||
renderCount++;
|
||||
if (renderCount === 1) {
|
||||
throw new Error('sensitive render detail');
|
||||
}
|
||||
return renderHTML(data);
|
||||
},
|
||||
reportError: (message, error) => {
|
||||
loggedErrors.push({ error, message });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server rejects a missing or DNS-rebinding Host before routing', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const missingHost = await requestDashboardWithoutHost(port);
|
||||
assert.strictEqual(missingHost.statusCode, 421);
|
||||
assert.strictEqual(missingHost.headers['cache-control'], 'no-store');
|
||||
|
||||
const reboundHost = await requestDashboard(port, {
|
||||
headers: { Host: 'dashboard.attacker.example' },
|
||||
path: '/api/data',
|
||||
});
|
||||
assert.strictEqual(reboundHost.statusCode, 421);
|
||||
assert.strictEqual(reboundHost.headers['cache-control'], 'no-store');
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server rejects an allowed hostname with an invalid port without crashing', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const response = await requestDashboard(port, {
|
||||
headers: { Host: 'localhost:99999' },
|
||||
path: '/api/data',
|
||||
});
|
||||
assert.strictEqual(response.statusCode, 421);
|
||||
assert.strictEqual(response.headers['cache-control'], 'no-store');
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server returns a generic no-store 400 for a malformed absolute request target and remains usable', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const malformedResponse = await requestDashboard(port, {
|
||||
path: 'http://attacker.example:99999/',
|
||||
});
|
||||
assert.strictEqual(malformedResponse.statusCode, 400);
|
||||
assert.strictEqual(malformedResponse.headers['cache-control'], 'no-store');
|
||||
assert.deepStrictEqual(JSON.parse(malformedResponse.body), {
|
||||
error: 'Bad request',
|
||||
});
|
||||
|
||||
const followUpResponse = await requestDashboard(port, {
|
||||
path: '/api/data',
|
||||
});
|
||||
assert.strictEqual(followUpResponse.statusCode, 200);
|
||||
assert.strictEqual(followUpResponse.headers['cache-control'], 'no-store');
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server rejects cross-origin requests before routing', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const response = await requestDashboard(port, {
|
||||
headers: { Origin: 'https://attacker.example' },
|
||||
path: '/api/data',
|
||||
});
|
||||
assert.strictEqual(response.statusCode, 403);
|
||||
assert.strictEqual(response.headers['cache-control'], 'no-store');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -853,5 +1154,21 @@ test('loadMcps handles empty mcp-configs directory', () => {
|
|||
|
||||
// ===================== Results =====================
|
||||
|
||||
console.log(`\nResults: Passed: ${testPassed}, Failed: ${testFailed}`);
|
||||
process.exit(testFailed > 0 ? 1 : 0);
|
||||
async function runAsyncTests() {
|
||||
for (const { name, fn } of asyncTests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
testPassed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
testFailed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${testPassed}, Failed: ${testFailed}`);
|
||||
process.exitCode = testFailed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
runAsyncTests();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue