diff --git a/scripts/dashboard-web.js b/scripts/dashboard-web.js index 271cc696..044a20fd 100644 --- a/scripts/dashboard-web.js +++ b/scripts/dashboard-web.js @@ -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, + '
Dashboard unavailable.
' + ); + } + 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, +}; diff --git a/scripts/lib/agent-data-home.js b/scripts/lib/agent-data-home.js index 32da5563..7302bd57 100644 --- a/scripts/lib/agent-data-home.js +++ b/scripts/lib/agent-data-home.js @@ -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}` diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 23ecdf4d..69a623ed 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -4,12 +4,11 @@ const os = require('os'); const path = require('path'); const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests'); -const { readInstallState, writeInstallState } = require('./install-state'); +const { readInstallState, validateInstallState } = require('./install-state'); const { assertWithinTrustedRoot } = require('./path-safety'); const { createManifestInstallPlan } = require('./install-executor'); const { prepareClaudeSkillMigration, - removeLegacyClaudeSkillFiles, } = require('./install/claude-skill-migration'); const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry'); const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist'); @@ -83,32 +82,62 @@ function getManagedOperations(state) { return Array.isArray(state && state.operations) ? state.operations.filter(operation => operation.ownership === 'managed') : []; } +function createUnsafeRepairSourceError() { + return new Error( + 'Refusing unsafe repair source metadata: sources must stay within the repository.' + ); +} + +function assertSafeRepairSourcePath(sourcePath, repoRoot) { + try { + return assertWithinTrustedRoot(sourcePath, repoRoot, 'read repair source'); + } catch { + throw createUnsafeRepairSourceError(); + } +} + function resolveOperationSourcePath(repoRoot, operation) { if (operation.sourceRelativePath) { - return path.join(repoRoot, operation.sourceRelativePath); + if (typeof operation.sourceRelativePath !== 'string') { + throw createUnsafeRepairSourceError(); + } + + const sourceRelativePath = operation.sourceRelativePath; + const hasParentTraversal = sourceRelativePath + .split(/[/\\]+/) + .includes('..'); + const isAbsolute = path.isAbsolute(sourceRelativePath) + || path.win32.isAbsolute(sourceRelativePath); + if (isAbsolute || hasParentTraversal) { + throw createUnsafeRepairSourceError(); + } + + return assertSafeRepairSourcePath( + path.resolve(repoRoot, sourceRelativePath), + repoRoot + ); } - return operation.sourcePath || null; + if (!operation.sourcePath) { + return null; + } + if ( + typeof operation.sourcePath !== 'string' + || !path.isAbsolute(operation.sourcePath) + ) { + throw createUnsafeRepairSourceError(); + } + return assertSafeRepairSourcePath(operation.sourcePath, repoRoot); } function areFilesEqual(leftPath, rightPath) { try { - const leftStat = fs.statSync(leftPath); - const rightStat = fs.statSync(rightPath); - if (!leftStat.isFile() || !rightStat.isFile()) { - return false; - } - - return fs.readFileSync(leftPath).equals(fs.readFileSync(rightPath)); + return readFileNoFollow(leftPath).equals(readFileNoFollow(rightPath)); } catch (_error) { return false; } } -function readFileUtf8(filePath) { - return fs.readFileSync(filePath, 'utf8'); -} - function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } @@ -193,12 +222,261 @@ function formatJson(value) { return `${JSON.stringify(value, null, 2)}\n`; } -function readJsonFile(filePath) { - return JSON.parse(readFileUtf8(filePath)); +function getManagedDestination( + destinationPath, + trustedRoot, + action, + { allowFinalSymlink = false } = {} +) { + if (!destinationPath || typeof destinationPath !== 'string') { + throw new Error(`Refusing to ${action}: missing destination path.`); + } + + const canonicalRoot = assertWithinTrustedRoot(trustedRoot, trustedRoot, action); + const resolvedDestination = path.resolve(destinationPath); + const canonicalParent = assertWithinTrustedRoot( + path.dirname(resolvedDestination), + canonicalRoot, + action + ); + const managedPath = path.join(canonicalParent, path.basename(resolvedDestination)); + let stat = null; + + try { + stat = fs.lstatSync(managedPath); + } catch (error) { + if (!error || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) { + throw error; + } + } + + if (stat && stat.isSymbolicLink() && !allowFinalSymlink) { + const error = new Error( + `Refusing to ${action}: managed destination is a final symlink.` + ); + error.code = 'ECC_FINAL_DESTINATION_SYMLINK'; + throw error; + } + + return { + canonicalRoot, + exists: stat !== null, + isFinalSymlink: Boolean(stat && stat.isSymbolicLink()), + managedPath + }; } -function ensureParentDir(filePath) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); +function ensureContainedParentDir(destinationPath, trustedRoot, action) { + const initialDestination = getManagedDestination( + destinationPath, + trustedRoot, + action + ); + const { canonicalRoot, managedPath } = initialDestination; + const canonicalParent = path.dirname(managedPath); + const relativeParent = path.relative(canonicalRoot, canonicalParent); + const pathSegments = relativeParent + ? relativeParent.split(path.sep).filter(Boolean) + : []; + let currentPath = canonicalRoot; + + for (const segment of pathSegments) { + const validatedParent = assertWithinTrustedRoot(currentPath, canonicalRoot, action); + const nextPath = path.join(validatedParent, segment); + try { + fs.mkdirSync(nextPath); + } catch (error) { + if (!error || error.code !== 'EEXIST') { + throw error; + } + } + + const validatedNext = assertWithinTrustedRoot(nextPath, canonicalRoot, action); + const nextStat = fs.lstatSync(validatedNext); + if (!nextStat.isDirectory() || nextStat.isSymbolicLink()) { + throw new Error(`Refusing to ${action}: destination parent is not a trusted directory.`); + } + currentPath = validatedNext; + } + + return getManagedDestination(managedPath, canonicalRoot, action).managedPath; +} + +function prepareContainedWriteDestination(destinationPath, trustedRoot, action) { + return ensureContainedParentDir(destinationPath, trustedRoot, action); +} + +function getContainedExistingPath( + destinationPath, + trustedRoot, + action, + { allowFinalSymlink = false } = {} +) { + const initialDestination = getManagedDestination( + destinationPath, + trustedRoot, + action, + { allowFinalSymlink } + ); + const followsToExistingPath = fs.existsSync(initialDestination.managedPath); + if (!followsToExistingPath && !initialDestination.isFinalSymlink) { + return null; + } + + const finalDestination = getManagedDestination( + initialDestination.managedPath, + trustedRoot, + action, + { allowFinalSymlink } + ); + return finalDestination.exists ? finalDestination.managedPath : null; +} + +function hasSameFileIdentity(leftStat, rightStat) { + return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino; +} + +function createChangedDestinationError(action) { + return new Error( + `Refusing to ${action}: managed destination changed during the write.` + ); +} + +function getStableParentStat(filePath, action) { + const parentStat = fs.lstatSync(path.dirname(filePath)); + if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) { + throw createChangedDestinationError(action); + } + return parentStat; +} + +function assertPinnedWriteDestination( + filePath, + fileDescriptor, + expectedParentStat, + trustedRoot, + action +) { + const liveDestination = getManagedDestination(filePath, trustedRoot, action); + if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) { + throw createChangedDestinationError(action); + } + + const liveParentStat = getStableParentStat(filePath, action); + if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) { + throw createChangedDestinationError(action); + } + + const descriptorStat = fs.fstatSync(fileDescriptor); + const livePathStat = fs.lstatSync(liveDestination.managedPath); + if ( + !descriptorStat.isFile() + || !livePathStat.isFile() + || livePathStat.isSymbolicLink() + || !hasSameFileIdentity(descriptorStat, livePathStat) + ) { + throw createChangedDestinationError(action); + } +} + +function writeFileNoFollow(filePath, content, mode, trustedRoot, action) { + const expectedParentStat = getStableParentStat(filePath, action); + const flags = fs.constants.O_WRONLY + | fs.constants.O_CREAT + | (fs.constants.O_NOFOLLOW || 0); + const fileDescriptor = fs.openSync(filePath, flags, mode); + + try { + assertPinnedWriteDestination( + filePath, + fileDescriptor, + expectedParentStat, + trustedRoot, + action + ); + fs.ftruncateSync(fileDescriptor, 0); + fs.writeFileSync(fileDescriptor, content); + if (mode !== undefined) { + fs.fchmodSync(fileDescriptor, mode); + } + } finally { + fs.closeSync(fileDescriptor); + } +} + +function readFileWithMetadataNoFollow(filePath, encoding) { + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const fileDescriptor = fs.openSync(filePath, flags); + + try { + const stat = fs.fstatSync(fileDescriptor); + if (!stat.isFile()) { + throw new Error(`Refusing to read non-file path: ${filePath}`); + } + return { + content: fs.readFileSync(fileDescriptor, encoding), + mode: stat.mode, + }; + } finally { + fs.closeSync(fileDescriptor); + } +} + +function readFileNoFollow(filePath, encoding) { + return readFileWithMetadataNoFollow(filePath, encoding).content; +} + +function readJsonNoFollow(filePath) { + return JSON.parse(readFileNoFollow(filePath, 'utf8')); +} + +function writeContainedFile(destinationPath, content, trustedRoot, action, mode) { + const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action); + const finalDestination = getManagedDestination( + preparedDestination, + trustedRoot, + action + ).managedPath; + writeFileNoFollow( + finalDestination, + content, + mode, + trustedRoot, + action + ); + return finalDestination; +} + +function copyContainedFile(sourcePath, destinationPath, trustedRoot, action) { + const source = readFileWithMetadataNoFollow(sourcePath); + return writeContainedFile( + destinationPath, + source.content, + trustedRoot, + action, + source.mode & 0o777 + ); +} + +function removeContainedPath(destinationPath, trustedRoot, action, options = {}) { + const existingDestination = getContainedExistingPath( + destinationPath, + trustedRoot, + action, + { allowFinalSymlink: true } + ); + if (!existingDestination) { + return null; + } + + const finalDestination = getManagedDestination( + existingDestination, + trustedRoot, + action, + { allowFinalSymlink: true } + ).managedPath; + fs.rmSync(finalDestination, options); + return finalDestination; } function deepMergeJson(baseValue, patchValue) { @@ -317,17 +595,14 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) { // Install-state is attacker-controllable; never write/delete outside the // adapter-derived trusted root, regardless of what the state file claims // (GHSA-hfpv-w6mp-5g95). - assertWithinTrustedRoot(operation.destinationPath, trustedRoot, 'repair'); - if (operation.kind === 'copy-file') { const sourcePath = resolveOperationSourcePath(repoRoot, operation); if (!sourcePath || !fs.existsSync(sourcePath)) { throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`); } - ensureParentDir(operation.destinationPath); - fs.copyFileSync(sourcePath, operation.destinationPath); - return; + copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair'); + return operation.destinationPath; } if (operation.kind === 'render-template') { @@ -336,9 +611,8 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) { throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`); } - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, renderedContent); - return; + writeContainedFile(operation.destinationPath, renderedContent, trustedRoot, 'repair'); + return operation.destinationPath; } if (operation.kind === 'merge-json') { @@ -347,21 +621,26 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) { throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`); } - const currentValue = fs.existsSync(operation.destinationPath) ? readJsonFile(operation.destinationPath) : {}; + const existingDestination = getContainedExistingPath(operation.destinationPath, trustedRoot, 'repair'); + const currentValue = existingDestination + ? readJsonNoFollow( + getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath + ) + : {}; const mergedValue = deepMergeJson(currentValue, payload); - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, formatJson(mergedValue)); - return; + writeContainedFile(operation.destinationPath, formatJson(mergedValue), trustedRoot, 'repair'); + return operation.destinationPath; } if (operation.kind === 'remove') { - if (!fs.existsSync(operation.destinationPath)) { - return; - } - - fs.rmSync(operation.destinationPath, { recursive: true, force: true }); - return; + const removedPath = removeContainedPath( + operation.destinationPath, + trustedRoot, + 'repair', + { recursive: true, force: true } + ); + return removedPath ? operation.destinationPath : null; } throw new Error(`Unsupported repair operation kind: ${operation.kind}`); @@ -369,28 +648,30 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) { function executeUninstallOperation(operation, trustedRoot) { // Confine deletes to the trusted install root (GHSA-hfpv-w6mp-5g95). - assertWithinTrustedRoot(operation.destinationPath, trustedRoot, 'uninstall'); - if (operation.kind === 'copy-file') { - if (!fs.existsSync(operation.destinationPath)) { + const removedPath = removeContainedPath( + operation.destinationPath, + trustedRoot, + 'uninstall', + { force: true } + ); + if (!removedPath) { return { removedPaths: [], cleanupTargets: [] }; } - fs.rmSync(operation.destinationPath, { force: true }); return { removedPaths: [operation.destinationPath], - cleanupTargets: [operation.destinationPath] + cleanupTargets: [removedPath] }; } if (operation.kind === 'render-template') { const previousContent = getOperationPreviousContent(operation); if (previousContent !== null) { - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, previousContent); + writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); return { removedPaths: [], cleanupTargets: [] @@ -399,33 +680,36 @@ function executeUninstallOperation(operation, trustedRoot) { const previousJson = getOperationPreviousJson(operation); if (previousJson !== undefined) { - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, formatJson(previousJson)); + writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); return { removedPaths: [], cleanupTargets: [] }; } - if (!fs.existsSync(operation.destinationPath)) { + const removedPath = removeContainedPath( + operation.destinationPath, + trustedRoot, + 'uninstall', + { force: true } + ); + if (!removedPath) { return { removedPaths: [], cleanupTargets: [] }; } - fs.rmSync(operation.destinationPath, { force: true }); return { removedPaths: [operation.destinationPath], - cleanupTargets: [operation.destinationPath] + cleanupTargets: [removedPath] }; } if (operation.kind === 'merge-json') { const previousContent = getOperationPreviousContent(operation); if (previousContent !== null) { - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, previousContent); + writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); return { removedPaths: [], cleanupTargets: [] @@ -434,15 +718,19 @@ function executeUninstallOperation(operation, trustedRoot) { const previousJson = getOperationPreviousJson(operation); if (previousJson !== undefined) { - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, formatJson(previousJson)); + writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); return { removedPaths: [], cleanupTargets: [] }; } - if (!fs.existsSync(operation.destinationPath)) { + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'uninstall' + ); + if (!existingDestination) { return { removedPaths: [], cleanupTargets: [] @@ -454,18 +742,24 @@ function executeUninstallOperation(operation, trustedRoot) { throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`); } - const currentValue = readJsonFile(operation.destinationPath); + const currentValue = readJsonNoFollow( + getManagedDestination(existingDestination, trustedRoot, 'uninstall').managedPath + ); const nextValue = deepRemoveJsonSubset(currentValue, payload); if (nextValue === JSON_REMOVE_SENTINEL) { - fs.rmSync(operation.destinationPath, { force: true }); + const removedPath = removeContainedPath( + operation.destinationPath, + trustedRoot, + 'uninstall', + { force: true } + ); return { - removedPaths: [operation.destinationPath], - cleanupTargets: [operation.destinationPath] + removedPaths: removedPath ? [operation.destinationPath] : [], + cleanupTargets: removedPath ? [removedPath] : [] }; } - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, formatJson(nextValue)); + writeContainedFile(operation.destinationPath, formatJson(nextValue), trustedRoot, 'uninstall'); return { removedPaths: [], cleanupTargets: [] @@ -475,8 +769,7 @@ function executeUninstallOperation(operation, trustedRoot) { if (operation.kind === 'remove') { const previousContent = getOperationPreviousContent(operation); if (previousContent !== null) { - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, previousContent); + writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall'); return { removedPaths: [], cleanupTargets: [] @@ -485,8 +778,7 @@ function executeUninstallOperation(operation, trustedRoot) { const previousJson = getOperationPreviousJson(operation); if (previousJson !== undefined) { - ensureParentDir(operation.destinationPath); - fs.writeFileSync(operation.destinationPath, formatJson(previousJson)); + writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall'); return { removedPaths: [], cleanupTargets: [] @@ -502,7 +794,7 @@ function executeUninstallOperation(operation, trustedRoot) { throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`); } -function inspectManagedOperation(repoRoot, operation) { +function inspectManagedOperation(repoRoot, trustedRoot, operation) { const destinationPath = operation.destinationPath; if (!destinationPath) { return { @@ -511,8 +803,29 @@ function inspectManagedOperation(repoRoot, operation) { }; } + let managedDestination; + try { + managedDestination = getManagedDestination( + destinationPath, + trustedRoot, + 'inspect managed operation', + { allowFinalSymlink: operation.kind === 'remove' } + ); + } catch (error) { + return { + status: 'unsafe-destination', + operation, + destinationPath, + reason: error && error.code === 'ECC_FINAL_DESTINATION_SYMLINK' + ? 'final-symlink' + : 'outside-root' + }; + } + + const inspectedPath = managedDestination.managedPath; + if (operation.kind === 'remove') { - if (fs.existsSync(destinationPath)) { + if (managedDestination.exists) { return { status: 'drifted', operation, @@ -527,7 +840,20 @@ function inspectManagedOperation(repoRoot, operation) { }; } - if (!fs.existsSync(destinationPath)) { + let copySourcePath = null; + if (operation.kind === 'copy-file') { + try { + copySourcePath = resolveOperationSourcePath(repoRoot, operation); + } catch { + return { + status: 'unsafe-source', + operation, + destinationPath + }; + } + } + + if (!managedDestination.exists) { return { status: 'missing', operation, @@ -536,22 +862,21 @@ function inspectManagedOperation(repoRoot, operation) { } if (operation.kind === 'copy-file') { - const sourcePath = resolveOperationSourcePath(repoRoot, operation); - if (!sourcePath || !fs.existsSync(sourcePath)) { + if (!copySourcePath || !fs.existsSync(copySourcePath)) { return { status: 'missing-source', operation, destinationPath, - sourcePath + sourcePath: copySourcePath }; } - if (!areFilesEqual(sourcePath, destinationPath)) { + if (!areFilesEqual(copySourcePath, inspectedPath)) { return { status: 'drifted', operation, destinationPath, - sourcePath + sourcePath: copySourcePath }; } @@ -559,7 +884,7 @@ function inspectManagedOperation(repoRoot, operation) { status: 'ok', operation, destinationPath, - sourcePath + sourcePath: copySourcePath }; } @@ -573,7 +898,15 @@ function inspectManagedOperation(repoRoot, operation) { }; } - if (readFileUtf8(destinationPath) !== renderedContent) { + try { + if (readFileNoFollow(inspectedPath, 'utf8') !== renderedContent) { + return { + status: 'drifted', + operation, + destinationPath + }; + } + } catch { return { status: 'drifted', operation, @@ -599,7 +932,7 @@ function inspectManagedOperation(repoRoot, operation) { } try { - const currentValue = readJsonFile(destinationPath); + const currentValue = readJsonNoFollow(inspectedPath); if (!jsonContainsSubset(currentValue, payload)) { return { status: 'drifted', @@ -629,16 +962,20 @@ function inspectManagedOperation(repoRoot, operation) { }; } -function summarizeManagedOperationHealth(repoRoot, operations) { +function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) { return operations.reduce( (summary, operation) => { - const inspection = inspectManagedOperation(repoRoot, operation); + const inspection = inspectManagedOperation(repoRoot, trustedRoot, operation); if (inspection.status === 'missing') { summary.missing.push(inspection); } else if (inspection.status === 'drifted') { summary.drifted.push(inspection); } else if (inspection.status === 'missing-source') { summary.missingSource.push(inspection); + } else if (inspection.status === 'unsafe-source') { + summary.unsafeSource.push(inspection); + } else if (inspection.status === 'unsafe-destination') { + summary.unsafeDestination.push(inspection); } else if (inspection.status === 'unverified' || inspection.status === 'invalid-destination') { summary.unverified.push(inspection); } @@ -648,11 +985,44 @@ function summarizeManagedOperationHealth(repoRoot, operations) { missing: [], drifted: [], missingSource: [], + unsafeSource: [], + unsafeDestination: [], unverified: [] } ); } +function getUnsafeManagedDestinationError(operationHealth) { + const hasFinalSymlink = operationHealth.unsafeDestination.some( + inspection => inspection.reason === 'final-symlink' + ); + if (hasFinalSymlink) { + return 'Refusing unsafe managed destination: final symlink detected.'; + } + return 'Refusing unsafe managed destination outside adapter-derived install root.'; +} + +function getUnsafeOperationResult(record, operationHealth) { + const error = operationHealth.unsafeDestination.length > 0 + ? getUnsafeManagedDestinationError(operationHealth) + : operationHealth.unsafeSource.length > 0 + ? createUnsafeRepairSourceError().message + : null; + if (!error) { + return null; + } + + return { + adapter: record.adapter, + status: 'error', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs: [], + stateRefreshed: false, + error + }; +} + function buildDiscoveryRecord(adapter, context) { const installTargetInput = { homeDir: context.homeDir, @@ -786,9 +1156,33 @@ function analyzeRecord(record, context) { } const managedOperations = getManagedOperations(state); - const operationHealth = summarizeManagedOperationHealth(context.repoRoot, managedOperations); + const operationHealth = summarizeManagedOperationHealth( + context.repoRoot, + record.targetRoot, + managedOperations + ); const missingManagedOperations = operationHealth.missing; + if (operationHealth.unsafeDestination.length > 0) { + issues.push( + buildIssue( + 'error', + 'unsafe-managed-destination', + `${operationHealth.unsafeDestination.length} managed operation(s) target an unsafe destination` + ) + ); + } + + if (operationHealth.unsafeSource.length > 0) { + issues.push( + buildIssue( + 'error', + 'unsafe-repair-source', + `${operationHealth.unsafeSource.length} managed operation(s) reference unsafe repair source metadata` + ) + ); + } + if (missingManagedOperations.length > 0) { issues.push( buildIssue('error', 'missing-managed-files', `${missingManagedOperations.length} managed file(s) are missing`, { @@ -955,12 +1349,57 @@ function createRepairPlanFromRecord(record, context, options = {}) { }; } -function prepareRepairMigration(plan) { - const migration = prepareClaudeSkillMigration(plan); +function buildAdapterDerivedStatePreview(statePreview, record) { + return { + ...statePreview, + target: { + ...statePreview.target, + id: record.adapter.id, + target: record.adapter.target, + kind: record.adapter.kind, + root: record.targetRoot, + installStatePath: record.installStatePath + } + }; +} + +function assertValidInstallStateForWrite(state, label) { + const validation = validateInstallState(state); + if (validation.valid) { + return; + } + + const details = validation.errors + .map(error => `${error.instancePath || '/'} ${error.message}`) + .join('; '); + throw new Error(`Invalid install-state (${label}): ${details}`); +} + +function writeRefreshedInstallState(record, statePreview) { + const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record); + assertValidInstallStateForWrite(trustedStatePreview, record.installStatePath); + return writeContainedFile( + record.installStatePath, + formatJson(trustedStatePreview), + record.targetRoot, + 'repair' + ); +} + +function prepareRepairMigration(plan, record) { + const trustedPlan = { + ...plan, + adapter: record.adapter, + targetRoot: record.targetRoot, + installRoot: record.targetRoot, + installStatePath: record.installStatePath, + statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record), + }; + const migration = prepareClaudeSkillMigration(trustedPlan); return { migration, plan: { - ...plan, + ...trustedPlan, operations: migration.finalState.operations, statePreview: migration.finalState, warnings: [ @@ -1011,8 +1450,19 @@ function repairInstalledStates(options = {}) { const rawPlan = createRepairPlanFromRecord(record, context, { exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], }); - const { plan: desiredPlan } = prepareRepairMigration(rawPlan); - const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations); + const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record); + const operationHealth = summarizeManagedOperationHealth( + context.repoRoot, + record.targetRoot, + desiredPlan.operations + ); + const unsafeOperationResult = getUnsafeOperationResult( + record, + operationHealth + ); + if (unsafeOperationResult) { + return unsafeOperationResult; + } const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]; @@ -1047,8 +1497,20 @@ function repairInstalledStates(options = {}) { const { migration, plan: desiredPlan, - } = prepareRepairMigration(rawPlan); - const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations); + } = prepareRepairMigration(rawPlan, record); + const operationHealth = summarizeManagedOperationHealth( + context.repoRoot, + record.targetRoot, + desiredPlan.operations + ); + + const unsafeOperationResult = getUnsafeOperationResult( + record, + operationHealth + ); + if (unsafeOperationResult) { + return unsafeOperationResult; + } if (operationHealth.missingSource.length > 0) { return { @@ -1086,19 +1548,35 @@ function repairInstalledStates(options = {}) { } const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; + const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { - writeInstallState(desiredPlan.installStatePath, migration.bridgeState); + writeRefreshedInstallState(record, migration.bridgeState); } - if (repairOperations.length > 0) { - for (const operation of repairOperations) { - executeRepairOperation(context.repoRoot, operation, record.targetRoot); + for (const operation of repairOperations) { + const repairedPath = executeRepairOperation( + context.repoRoot, + operation, + record.targetRoot + ); + if (repairedPath) { + repairedPaths.push(repairedPath); } } if (hasLegacyMigration) { - removeLegacyClaudeSkillFiles(migration, desiredPlan.targetRoot); + for (const operation of migration.legacyOperationsToRemove) { + const removedPath = removeContainedPath( + operation.destinationPath, + record.targetRoot, + 'migrate managed Claude skill', + { force: true } + ); + if (removedPath) { + repairedPaths.push(removedPath); + } + } } - writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview); + writeRefreshedInstallState(record, desiredPlan.statePreview); return { adapter: record.adapter, @@ -1106,7 +1584,7 @@ function repairInstalledStates(options = {}) { ? 'repaired' : 'ok', installStatePath: record.installStatePath, - repairedPaths: plannedRepairs, + repairedPaths, plannedRepairs: [], stateRefreshed: true, warnings: desiredPlan.warnings, @@ -1148,22 +1626,39 @@ function repairInstalledStates(options = {}) { } function cleanupEmptyParentDirs(filePath, stopAt) { - let currentPath = path.dirname(filePath); - const normalizedStopAt = path.resolve(stopAt); + const trustedStopAt = assertWithinTrustedRoot(stopAt, stopAt, 'clean up'); + const trustedFilePath = assertWithinTrustedRoot(filePath, trustedStopAt, 'clean up'); + let currentPath = path.dirname(trustedFilePath); - while (currentPath && path.resolve(currentPath).startsWith(normalizedStopAt) && path.resolve(currentPath) !== normalizedStopAt) { - if (!fs.existsSync(currentPath)) { - currentPath = path.dirname(currentPath); - continue; - } - - const stat = fs.lstatSync(currentPath); - if (!stat.isDirectory() || fs.readdirSync(currentPath).length > 0) { + while (currentPath) { + const relativePath = path.relative(trustedStopAt, currentPath); + const isContained = relativePath !== '..' + && !relativePath.startsWith(`..${path.sep}`) + && !path.isAbsolute(relativePath); + if (!isContained || relativePath === '') { break; } - fs.rmdirSync(currentPath); - currentPath = path.dirname(currentPath); + let validatedPath = assertWithinTrustedRoot(currentPath, trustedStopAt, 'clean up'); + if (!fs.existsSync(validatedPath)) { + currentPath = path.dirname(validatedPath); + continue; + } + + validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up'); + const stat = fs.lstatSync(validatedPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + break; + } + + validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up'); + if (fs.readdirSync(validatedPath).length > 0) { + break; + } + + const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up'); + fs.rmdirSync(finalPath); + currentPath = path.dirname(finalPath); } } @@ -1187,7 +1682,10 @@ function uninstallInstalledStates(options = {}) { } const state = record.state; - const plannedRemovals = Array.from(new Set([...getManagedOperations(state).map(operation => operation.destinationPath), state.target.installStatePath])); + const plannedRemovals = Array.from(new Set([ + ...getManagedOperations(state).map(operation => operation.destinationPath), + record.installStatePath + ])); if (options.dryRun) { return { @@ -1211,15 +1709,19 @@ function uninstallInstalledStates(options = {}) { cleanupTargets.push(...outcome.cleanupTargets); } - if (fs.existsSync(state.target.installStatePath)) { - assertWithinTrustedRoot(state.target.installStatePath, record.targetRoot, 'uninstall'); - fs.rmSync(state.target.installStatePath, { force: true }); - removedPaths.push(state.target.installStatePath); - cleanupTargets.push(state.target.installStatePath); + const removedStatePath = removeContainedPath( + record.installStatePath, + record.targetRoot, + 'uninstall', + { force: true } + ); + if (removedStatePath) { + removedPaths.push(record.installStatePath); + cleanupTargets.push(removedStatePath); } for (const cleanupTarget of cleanupTargets) { - cleanupEmptyParentDirs(cleanupTarget, state.target.root); + cleanupEmptyParentDirs(cleanupTarget, record.targetRoot); } return { diff --git a/scripts/lib/loopback-guard.js b/scripts/lib/loopback-guard.js index cde3373a..c56366ea 100644 --- a/scripts/lib/loopback-guard.js +++ b/scripts/lib/loopback-guard.js @@ -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(); } diff --git a/scripts/lib/path-safety.js b/scripts/lib/path-safety.js index 3c63938e..7436bd80 100644 --- a/scripts/lib/path-safety.js +++ b/scripts/lib/path-safety.js @@ -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 = { diff --git a/tests/lib/agent-data-home.test.js b/tests/lib/agent-data-home.test.js index 21909b56..f5f72fc3 100644 --- a/tests/lib/agent-data-home.test.js +++ b/tests/lib/agent-data-home.test.js @@ -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++; diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 555b6af7..e8f1511d 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -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-'); diff --git a/tests/lib/loopback-guard.test.js b/tests/lib/loopback-guard.test.js index 61b01dd5..e6527f78 100644 --- a/tests/lib/loopback-guard.test.js +++ b/tests/lib/loopback-guard.test.js @@ -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', () => { diff --git a/tests/lib/path-safety.test.js b/tests/lib/path-safety.test.js index bbf38794..a89973c2 100644 --- a/tests/lib/path-safety.test.js +++ b/tests/lib/path-safety.test.js @@ -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/); }); diff --git a/tests/scripts/dashboard-web.test.js b/tests/scripts/dashboard-web.test.js index 88861b6c..d9a125db 100644 --- a/tests/scripts/dashboard-web.test.js +++ b/tests/scripts/dashboard-web.test.js @@ -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('')); - 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('')); + 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, + 'Dashboard unavailable.
' + ); + 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();