mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-13 13:17:55 +02:00
fix: harden local data boundaries
Bind the capabilities dashboard exclusively to loopback and reject untrusted Host and Origin values. Constrain project-configured agent data paths to the Cursor data root, and harden lifecycle repair/uninstall operations against state-file traversal, symlink swaps, unsafe sources, and forged install-state destinations.\n\nCloses #2506
This commit is contained in:
+109
-14
@@ -12,6 +12,26 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const {
|
||||
LOOPBACK_HOSTNAMES,
|
||||
buildAllowedHostnames,
|
||||
isAllowedHostHeader,
|
||||
isAllowedOrigin,
|
||||
} = require('./lib/loopback-guard');
|
||||
|
||||
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);
|
||||
@@ -19,6 +39,7 @@ function parsePort(v) {
|
||||
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) {
|
||||
@@ -786,21 +807,95 @@ 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 createDashboardServer({ root = ROOT, host = HOST } = {}) {
|
||||
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') {
|
||||
return sendJson(res, 200, loadDashboardData(root));
|
||||
}
|
||||
return sendHtml(res, 200, renderHTML(loadDashboardData(root)));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { parsePort, readFrontmatter, readSkill, loadAgents, loadSkills, loadCommands, loadRules, loadMcps, loadHooks, renderHTML, LANG, LANG_KEYS, server };
|
||||
function listenDashboardServer(
|
||||
dashboardServer,
|
||||
{ port = PORT, host = HOST, onListening } = {}
|
||||
) {
|
||||
const resolvedHost = resolveDashboardHost({ ECC_DASHBOARD_HOST: host });
|
||||
return dashboardServer.listen(port, resolvedHost, onListening);
|
||||
}
|
||||
|
||||
const server = createDashboardServer();
|
||||
|
||||
if (require.main === module) {
|
||||
listenDashboardServer(server, { port: PORT, host: HOST, onListening: () => {
|
||||
const displayHost = HOST.includes(':') ? `[${HOST}]` : HOST;
|
||||
const dashboardUrl = `http://${displayHost}:${PORT}`;
|
||||
console.log(`\n ECC Capabilities → ${dashboardUrl}\n`);
|
||||
try { const { spawn } = require('child_process'); const p = process.platform; const c = p === 'darwin' ? 'open' : p === 'win32' ? 'start' : 'xdg-open'; if (c === 'start') spawn('cmd', ['/c', 'start', dashboardUrl], { stdio: 'ignore' }); else spawn(c, [dashboardUrl], { stdio: 'ignore' }); } catch { /* best-effort auto-open */ }
|
||||
} });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_HOST,
|
||||
HOST,
|
||||
LANG,
|
||||
LANG_KEYS,
|
||||
createDashboardServer,
|
||||
listenDashboardServer,
|
||||
loadAgents,
|
||||
loadCommands,
|
||||
loadHooks,
|
||||
loadMcps,
|
||||
loadRules,
|
||||
loadSkills,
|
||||
parsePort,
|
||||
readFrontmatter,
|
||||
readSkill,
|
||||
renderHTML,
|
||||
resolveDashboardHost,
|
||||
server,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
|
||||
const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME';
|
||||
const DEFAULT_CLAUDE_DIR_NAME = '.claude';
|
||||
@@ -94,6 +95,21 @@ 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 data directory. 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 readProjectConfigAt(configPath) {
|
||||
if (!configPath || typeof configPath !== 'string') return null;
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
@@ -103,8 +119,22 @@ 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);
|
||||
try {
|
||||
return assertWithinTrustedRoot(
|
||||
resolved,
|
||||
getDefaultCursorAgentDataHome(),
|
||||
'use project agent data home'
|
||||
);
|
||||
} catch {
|
||||
warnUnsafeProjectConfig();
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}`
|
||||
|
||||
+530
-105
@@ -4,7 +4,7 @@ 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 { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
|
||||
@@ -79,32 +79,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);
|
||||
}
|
||||
@@ -189,12 +219,193 @@ 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 writeFileNoFollow(filePath, content, mode) {
|
||||
const flags = fs.constants.O_WRONLY
|
||||
| fs.constants.O_CREAT
|
||||
| fs.constants.O_TRUNC
|
||||
| (fs.constants.O_NOFOLLOW || 0);
|
||||
const fileDescriptor = fs.openSync(filePath, flags, mode);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fileDescriptor, content);
|
||||
if (mode !== undefined) {
|
||||
fs.fchmodSync(fileDescriptor, mode);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fileDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function readFileNoFollow(filePath, encoding) {
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
||||
const fileDescriptor = fs.openSync(filePath, flags);
|
||||
|
||||
try {
|
||||
if (!fs.fstatSync(fileDescriptor).isFile()) {
|
||||
throw new Error(`Refusing to read non-file path: ${filePath}`);
|
||||
}
|
||||
return fs.readFileSync(fileDescriptor, encoding);
|
||||
} finally {
|
||||
fs.closeSync(fileDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return finalDestination;
|
||||
}
|
||||
|
||||
function copyContainedFile(sourcePath, destinationPath, trustedRoot, action) {
|
||||
const sourceStat = fs.statSync(sourcePath);
|
||||
const sourceContent = fs.readFileSync(sourcePath);
|
||||
return writeContainedFile(
|
||||
destinationPath,
|
||||
sourceContent,
|
||||
trustedRoot,
|
||||
action,
|
||||
sourceStat.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) {
|
||||
@@ -313,17 +524,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') {
|
||||
@@ -332,9 +540,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') {
|
||||
@@ -343,21 +550,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}`);
|
||||
@@ -365,28 +577,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: []
|
||||
@@ -395,33 +609,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: []
|
||||
@@ -430,15 +647,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: []
|
||||
@@ -450,18 +671,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: []
|
||||
@@ -471,8 +698,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: []
|
||||
@@ -481,8 +707,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: []
|
||||
@@ -498,7 +723,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 {
|
||||
@@ -507,8 +732,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,
|
||||
@@ -523,7 +769,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,
|
||||
@@ -532,22 +791,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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -555,7 +813,7 @@ function inspectManagedOperation(repoRoot, operation) {
|
||||
status: 'ok',
|
||||
operation,
|
||||
destinationPath,
|
||||
sourcePath
|
||||
sourcePath: copySourcePath
|
||||
};
|
||||
}
|
||||
|
||||
@@ -569,7 +827,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,
|
||||
@@ -595,7 +861,7 @@ function inspectManagedOperation(repoRoot, operation) {
|
||||
}
|
||||
|
||||
try {
|
||||
const currentValue = readJsonFile(destinationPath);
|
||||
const currentValue = readJsonNoFollow(inspectedPath);
|
||||
if (!jsonContainsSubset(currentValue, payload)) {
|
||||
return {
|
||||
status: 'drifted',
|
||||
@@ -625,16 +891,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);
|
||||
}
|
||||
@@ -644,11 +914,23 @@ 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 buildDiscoveryRecord(adapter, context) {
|
||||
const installTargetInput = {
|
||||
homeDir: context.homeDir,
|
||||
@@ -782,9 +1064,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`, {
|
||||
@@ -951,6 +1257,43 @@ function createRepairPlanFromRecord(record, context, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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 repairInstalledStates(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const manifests = loadInstallManifests({ repoRoot });
|
||||
@@ -991,7 +1334,33 @@ function repairInstalledStates(options = {}) {
|
||||
const desiredPlan = createRepairPlanFromRecord(record, context, {
|
||||
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
|
||||
});
|
||||
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations);
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
desiredPlan.operations
|
||||
);
|
||||
if (operationHealth.unsafeDestination.length > 0) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: false,
|
||||
error: getUnsafeManagedDestinationError(operationHealth)
|
||||
};
|
||||
}
|
||||
if (operationHealth.unsafeSource.length > 0) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: false,
|
||||
error: createUnsafeRepairSourceError().message
|
||||
};
|
||||
}
|
||||
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
||||
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
|
||||
|
||||
@@ -1022,7 +1391,35 @@ function repairInstalledStates(options = {}) {
|
||||
}
|
||||
|
||||
const desiredPlan = createRepairPlanFromRecord(record, context);
|
||||
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations);
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
desiredPlan.operations
|
||||
);
|
||||
|
||||
if (operationHealth.unsafeDestination.length > 0) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: false,
|
||||
error: getUnsafeManagedDestinationError(operationHealth)
|
||||
};
|
||||
}
|
||||
|
||||
if (operationHealth.unsafeSource.length > 0) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: false,
|
||||
error: createUnsafeRepairSourceError().message
|
||||
};
|
||||
}
|
||||
|
||||
if (operationHealth.missingSource.length > 0) {
|
||||
return {
|
||||
@@ -1052,20 +1449,24 @@ function repairInstalledStates(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
if (repairOperations.length > 0) {
|
||||
for (const operation of repairOperations) {
|
||||
executeRepairOperation(context.repoRoot, operation, record.targetRoot);
|
||||
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
|
||||
for (const operation of repairOperations) {
|
||||
const repairedPath = executeRepairOperation(
|
||||
context.repoRoot,
|
||||
operation,
|
||||
record.targetRoot
|
||||
);
|
||||
if (repairedPath) {
|
||||
repairedPaths.push(repairedPath);
|
||||
}
|
||||
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
||||
} else {
|
||||
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
||||
}
|
||||
writeRefreshedInstallState(record, desiredPlan.statePreview);
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: (repairOperations.length > 0 || needsOpencodeBuild) ? 'repaired' : 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: plannedRepairs,
|
||||
repairedPaths,
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: true,
|
||||
error: null
|
||||
@@ -1106,22 +1507,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,7 +1563,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 {
|
||||
@@ -1169,15 +1590,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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+40
-14
@@ -13,11 +13,15 @@ const path = require('path');
|
||||
* root - never trusted from the state file itself (GHSA-hfpv-w6mp-5g95).
|
||||
*/
|
||||
|
||||
function safeRealpath(target) {
|
||||
function pathEntryExists(target) {
|
||||
try {
|
||||
return fs.realpathSync(path.resolve(target));
|
||||
} catch {
|
||||
return path.resolve(target);
|
||||
fs.lstatSync(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +33,7 @@ function safeRealpath(target) {
|
||||
function realpathNearestExisting(target) {
|
||||
let current = path.resolve(target);
|
||||
const tail = [];
|
||||
while (!fs.existsSync(current)) {
|
||||
while (!pathEntryExists(current)) {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
@@ -37,7 +41,7 @@ function realpathNearestExisting(target) {
|
||||
tail.unshift(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
const real = safeRealpath(current);
|
||||
const real = fs.realpathSync(current);
|
||||
return tail.length > 0 ? path.join(real, ...tail) : real;
|
||||
}
|
||||
|
||||
@@ -45,17 +49,32 @@ function realpathNearestExisting(target) {
|
||||
* True when `target` resolves to `root` itself or a path beneath it, with
|
||||
* symlinks resolved on both sides.
|
||||
*/
|
||||
function resolveContainment(target, root) {
|
||||
const realRoot = realpathNearestExisting(root);
|
||||
const realTarget = realpathNearestExisting(target);
|
||||
const relativePath = path.relative(realRoot, realTarget);
|
||||
const contained = relativePath === ''
|
||||
|| (
|
||||
relativePath !== '..'
|
||||
&& !relativePath.startsWith(`..${path.sep}`)
|
||||
&& !path.isAbsolute(relativePath)
|
||||
);
|
||||
return {
|
||||
contained,
|
||||
realRoot,
|
||||
realTarget
|
||||
};
|
||||
}
|
||||
|
||||
function isWithinRoot(target, root) {
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
const realRoot = safeRealpath(root);
|
||||
const realTarget = realpathNearestExisting(target);
|
||||
if (realTarget === realRoot) {
|
||||
return true;
|
||||
try {
|
||||
return resolveContainment(target, root).contained;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,10 +88,17 @@ function assertWithinTrustedRoot(target, root, action = 'write') {
|
||||
if (!root) {
|
||||
throw new Error(`Refusing to ${action} '${target}': no trusted install root resolved.`);
|
||||
}
|
||||
if (!isWithinRoot(target, root)) {
|
||||
|
||||
let containment;
|
||||
try {
|
||||
containment = resolveContainment(target, root);
|
||||
} catch {
|
||||
containment = null;
|
||||
}
|
||||
if (!containment || !containment.contained) {
|
||||
throw new Error(`Refusing to ${action} outside the install root: '${target}' is not within '${root}'.`);
|
||||
}
|
||||
return realpathNearestExisting(target);
|
||||
return containment.realTarget;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -68,6 +68,20 @@ function withIsolatedCwd(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function captureConsoleErrors(fn) {
|
||||
const originalError = console.error;
|
||||
const messages = [];
|
||||
console.error = (...args) => {
|
||||
messages.push(args.join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
return { result: fn(), messages };
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing agent-data-home.js ===\n');
|
||||
let passed = 0;
|
||||
@@ -148,10 +162,11 @@ function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reads project ecc-agent-data.json config file', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `ecc-agent-data-home-read-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const configPath = path.join(tmpDir, 'ecc-agent-data.json');
|
||||
const customHome = path.join(tmpDir, 'data-root');
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-read-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-user-'));
|
||||
const configPath = path.join(tmpDir, '.cursor', 'ecc-agent-data.json');
|
||||
const customHome = path.join(homeDir, '.cursor', 'ecc', 'custom');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ agentDataHome: customHome }),
|
||||
@@ -162,27 +177,31 @@ 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('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 +215,155 @@ 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 Cursor data root', () => {
|
||||
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',
|
||||
];
|
||||
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('allows a non-existent project config destination beneath the Cursor data root', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-safe-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-safe-user-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
const safeHome = path.join(homeDir, '.cursor', 'ecc', 'profiles', 'work');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: safeHome }), 'utf8');
|
||||
|
||||
try {
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
assert.strictEqual(
|
||||
agentDataHome.readProjectConfigAt(configPath),
|
||||
path.join(fs.realpathSync(homeDir), '.cursor', 'ecc', 'profiles', 'work')
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects a project config destination that escapes through a symlink', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-user-'));
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-outside-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
const cursorRoot = path.join(homeDir, '.cursor', 'ecc');
|
||||
const linkPath = path.join(cursorRoot, 'redirect');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(cursorRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(outsideDir, linkPath, 'dir');
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
const candidate = path.join(linkPath, 'session-data');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
|
||||
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
const { result, messages } = captureConsoleErrors(
|
||||
() => agentDataHome.readProjectConfigAt(configPath)
|
||||
);
|
||||
assert.strictEqual(result, null);
|
||||
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
|
||||
assert.ok(messages.every(message => !message.includes(candidate)));
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
const { getInstallTargetAdapter } = require('../../scripts/lib/install-targets/registry');
|
||||
const {
|
||||
createInstallState,
|
||||
readInstallState,
|
||||
writeInstallState,
|
||||
} = require('../../scripts/lib/install-state');
|
||||
|
||||
@@ -1017,6 +1018,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-');
|
||||
@@ -1317,6 +1474,202 @@ 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) {
|
||||
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 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);
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'ok');
|
||||
assert.ok(fs.existsSync(adapterStatePath));
|
||||
assert.ok(!fs.existsSync(recordedStatePath));
|
||||
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-');
|
||||
@@ -1565,6 +1918,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-');
|
||||
@@ -1597,6 +1984,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-');
|
||||
@@ -1802,6 +2225,107 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall removes an in-root final symlink without deleting its victim', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const victimPath = path.join(targetRoot, 'victim.md');
|
||||
const destinationPath = path.join(targetRoot, 'managed.md');
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
fs.writeFileSync(victimPath, 'victim sentinel\n');
|
||||
try {
|
||||
fs.symlinkSync(victimPath, destinationPath);
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'uninstalled');
|
||||
assert.ok(!fs.existsSync(destinationPath));
|
||||
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim sentinel\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall rejects a symlink inserted after initial destination validation', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
const outsideRoot = createTempDir('install-lifecycle-outside-');
|
||||
const targetRoot = path.join(projectRoot, '.cursor');
|
||||
const destinationParent = path.join(targetRoot, 'late-parent');
|
||||
const backupParent = path.join(targetRoot, 'late-parent-backup');
|
||||
const destinationPath = path.join(destinationParent, 'managed.md');
|
||||
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
|
||||
const originalExistsSync = fs.existsSync;
|
||||
let canonicalDestinationParent;
|
||||
let canonicalDestinationPath;
|
||||
let insertedSymlink = false;
|
||||
let result;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
fs.writeFileSync(destinationPath, 'managed\n');
|
||||
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
canonicalDestinationPath = fs.realpathSync(destinationPath);
|
||||
canonicalDestinationParent = path.dirname(canonicalDestinationPath);
|
||||
|
||||
fs.existsSync = function existsSyncWithLateSymlink(candidatePath) {
|
||||
if (!insertedSymlink && path.resolve(candidatePath) === canonicalDestinationPath) {
|
||||
fs.renameSync(canonicalDestinationParent, backupParent);
|
||||
fs.symlinkSync(
|
||||
outsideRoot,
|
||||
canonicalDestinationParent,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
insertedSymlink = true;
|
||||
}
|
||||
return originalExistsSync.call(fs, candidatePath);
|
||||
};
|
||||
|
||||
result = uninstallInstalledStates({
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['cursor'],
|
||||
});
|
||||
} finally {
|
||||
fs.existsSync = originalExistsSync;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.strictEqual(insertedSymlink, true);
|
||||
assert.strictEqual(result.results[0].status, 'error');
|
||||
assert.ok(result.results[0].error.includes('outside the install root'));
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(outsideDestinationPath, 'utf8'),
|
||||
'outside sentinel\n'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uninstall restores previous JSON snapshots for template and remove operations', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
@@ -60,6 +60,13 @@ function runTests() {
|
||||
assert.strictEqual(parseHostHeader('bad:host:extra'), null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects ports outside the valid TCP range', () => {
|
||||
assert.strictEqual(parseHostHeader('localhost:65536'), null);
|
||||
assert.strictEqual(parseHostHeader('localhost:99999'), null);
|
||||
assert.strictEqual(parseHostHeader('[::1]:65536'), null);
|
||||
assert.strictEqual(parseHostHeader('localhost:65535'), 'localhost');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log('\nbuildAllowedHostnames:');
|
||||
|
||||
if (test('always includes loopback names', () => {
|
||||
|
||||
@@ -10,7 +10,11 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { assertWithinTrustedRoot, isWithinRoot } = require('../../scripts/lib/path-safety');
|
||||
const {
|
||||
assertWithinTrustedRoot,
|
||||
isWithinRoot,
|
||||
realpathNearestExisting
|
||||
} = require('../../scripts/lib/path-safety');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -43,12 +47,89 @@ try {
|
||||
assert.strictEqual(isWithinRoot(root, root), true);
|
||||
});
|
||||
|
||||
test('allows a non-existent destination beneath a non-existent trusted root', () => {
|
||||
const futureRoot = path.join(root, 'future-root');
|
||||
const futureDestination = path.join(futureRoot, 'session-data', 'session.json');
|
||||
assert.strictEqual(isWithinRoot(futureDestination, futureRoot), true);
|
||||
assert.strictEqual(
|
||||
assertWithinTrustedRoot(futureDestination, futureRoot, 'write'),
|
||||
realpathNearestExisting(futureDestination)
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes the nearest existing ancestor for a non-existent trusted root', () => {
|
||||
const realParent = fs.mkdtempSync(path.join(os.tmpdir(), 'path-safety-real-'));
|
||||
const linkedParent = path.join(
|
||||
os.tmpdir(),
|
||||
`path-safety-link-${process.pid}-${Date.now()}`
|
||||
);
|
||||
|
||||
try {
|
||||
fs.symlinkSync(realParent, linkedParent, 'dir');
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
fs.rmSync(realParent, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const futureRoot = path.join(linkedParent, '.cursor', 'ecc');
|
||||
const futureDestination = path.join(futureRoot, 'session-data', 'session.json');
|
||||
assert.strictEqual(isWithinRoot(futureDestination, futureRoot), true);
|
||||
assert.strictEqual(
|
||||
assertWithinTrustedRoot(futureDestination, futureRoot, 'write'),
|
||||
path.join(
|
||||
fs.realpathSync(realParent),
|
||||
'.cursor',
|
||||
'ecc',
|
||||
'session-data',
|
||||
'session.json'
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(linkedParent, { force: true });
|
||||
fs.rmSync(realParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('returns the same canonical destination that was checked for containment', () => {
|
||||
const destination = path.join(root, 'single-canonicalization.txt');
|
||||
const originalRealpathSync = fs.realpathSync;
|
||||
let destinationCanonicalizations = 0;
|
||||
fs.writeFileSync(destination, 'safe\n');
|
||||
|
||||
fs.realpathSync = function countedRealpathSync(candidatePath, options) {
|
||||
if (path.resolve(candidatePath) === path.resolve(destination)) {
|
||||
destinationCanonicalizations += 1;
|
||||
}
|
||||
return originalRealpathSync.call(fs, candidatePath, options);
|
||||
};
|
||||
|
||||
try {
|
||||
assert.strictEqual(
|
||||
assertWithinTrustedRoot(destination, root, 'write'),
|
||||
originalRealpathSync(destination)
|
||||
);
|
||||
} finally {
|
||||
fs.realpathSync = originalRealpathSync;
|
||||
}
|
||||
|
||||
assert.strictEqual(destinationCanonicalizations, 1);
|
||||
});
|
||||
|
||||
test('refuses an absolute path outside the root', () => {
|
||||
const evil = path.join(outside, 'PWNED.txt');
|
||||
assert.throws(() => assertWithinTrustedRoot(evil, root, 'repair'), /outside the install root/);
|
||||
assert.strictEqual(isWithinRoot(evil, root), false);
|
||||
});
|
||||
|
||||
test('refuses an escape from a non-existent trusted root', () => {
|
||||
const futureRoot = path.join(root, 'future-root');
|
||||
const evil = path.join(futureRoot, '..', 'escape.txt');
|
||||
assert.throws(() => assertWithinTrustedRoot(evil, futureRoot, 'write'), /outside the install root/);
|
||||
assert.strictEqual(isWithinRoot(evil, futureRoot), false);
|
||||
});
|
||||
|
||||
test('refuses a ../ traversal escape', () => {
|
||||
const evil = path.join(root, '..', 'escape.txt');
|
||||
assert.throws(() => assertWithinTrustedRoot(evil, root, 'uninstall'), /outside the install root/);
|
||||
@@ -67,6 +148,23 @@ try {
|
||||
assert.throws(() => assertWithinTrustedRoot(evil, root, 'repair'), /outside the install root/);
|
||||
});
|
||||
|
||||
test('refuses a dangling symlinked intermediate directory', () => {
|
||||
const danglingTarget = path.join(outside, 'missing-target');
|
||||
const linkDir = path.join(root, 'dangling-link');
|
||||
try {
|
||||
fs.symlinkSync(danglingTarget, linkDir, 'dir');
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
const evil = path.join(linkDir, 'session-data', 'session.json');
|
||||
assert.strictEqual(isWithinRoot(evil, root), false);
|
||||
assert.throws(
|
||||
() => assertWithinTrustedRoot(evil, root, 'write'),
|
||||
/outside the install root/
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses when no trusted root is resolved', () => {
|
||||
assert.throws(() => assertWithinTrustedRoot(path.join(root, 'x'), null, 'repair'), /no trusted install root/);
|
||||
});
|
||||
|
||||
@@ -7,12 +7,14 @@ 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 = [];
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
@@ -28,6 +30,10 @@ function test(name, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function asyncTest(name, fn) {
|
||||
asyncTests.push({ name, fn });
|
||||
}
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
@@ -42,6 +48,81 @@ function writeFile(rootDir, relativePath, content) {
|
||||
fs.writeFileSync(targetPath, content);
|
||||
}
|
||||
|
||||
function requestDashboard(port, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const 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('end', () => {
|
||||
resolve({
|
||||
body,
|
||||
headers: response.headers,
|
||||
statusCode: response.statusCode,
|
||||
});
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function requestDashboardWithoutHost(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
let raw = '';
|
||||
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();
|
||||
}
|
||||
resolve({ body, headers, statusCode });
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function withDashboardServer(fn) {
|
||||
const { createDashboardServer } = require(SCRIPT);
|
||||
const testServer = createDashboardServer({ host: '127.0.0.1' });
|
||||
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', () => {
|
||||
@@ -661,49 +742,145 @@ test('renderHTML includes the dashboard title and footer', () => {
|
||||
|
||||
// ===================== Server / HTTP =====================
|
||||
|
||||
test('server returns HTML on GET /', (done) => {
|
||||
const { server } = require(SCRIPT);
|
||||
// Server may or may not be listening — we start it on a random port
|
||||
const testServer = http.createServer(server._events.request);
|
||||
testServer.listen(0, () => {
|
||||
const port = testServer.address().port;
|
||||
http.get(`http://localhost:${port}/`, (res) => {
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8');
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
assert.ok(body.includes('<!DOCTYPE html>'));
|
||||
assert.ok(body.includes('ECC Capabilities'));
|
||||
testServer.close();
|
||||
done();
|
||||
});
|
||||
});
|
||||
test('resolveDashboardHost defaults to IPv4 loopback', () => {
|
||||
const { resolveDashboardHost } = require(SCRIPT);
|
||||
assert.strictEqual(resolveDashboardHost({}), '127.0.0.1');
|
||||
assert.strictEqual(resolveDashboardHost({ ECC_DASHBOARD_HOST: '' }), '127.0.0.1');
|
||||
});
|
||||
|
||||
test('resolveDashboardHost accepts only normalized loopback hosts', () => {
|
||||
const { resolveDashboardHost } = require(SCRIPT);
|
||||
assert.strictEqual(
|
||||
resolveDashboardHost({ ECC_DASHBOARD_HOST: ' LOCALHOST ' }),
|
||||
'localhost'
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveDashboardHost({ ECC_DASHBOARD_HOST: '::1' }),
|
||||
'::1'
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveDashboardHost({ ECC_DASHBOARD_HOST: '[::1]' }),
|
||||
'::1'
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDashboardHost rejects wildcard, LAN, and arbitrary hosts', () => {
|
||||
const { resolveDashboardHost } = require(SCRIPT);
|
||||
for (const host of ['0.0.0.0', '::', '192.168.1.10', 'dashboard.internal', '127.0.0.1:3456']) {
|
||||
assert.throws(
|
||||
() => resolveDashboardHost({ ECC_DASHBOARD_HOST: host }),
|
||||
/ECC_DASHBOARD_HOST must be loopback-only/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('listenDashboardServer always passes an explicit loopback host to listen', () => {
|
||||
const { listenDashboardServer } = require(SCRIPT);
|
||||
const calls = [];
|
||||
const fakeServer = {
|
||||
listen(...args) {
|
||||
calls.push(args);
|
||||
return this;
|
||||
},
|
||||
};
|
||||
const onListening = () => {};
|
||||
|
||||
assert.strictEqual(
|
||||
listenDashboardServer(fakeServer, {
|
||||
host: '127.0.0.1',
|
||||
onListening,
|
||||
port: 3456,
|
||||
}),
|
||||
fakeServer
|
||||
);
|
||||
assert.deepStrictEqual(calls, [[3456, '127.0.0.1', onListening]]);
|
||||
assert.throws(
|
||||
() => listenDashboardServer(fakeServer, { host: '0.0.0.0', port: 3456 }),
|
||||
/ECC_DASHBOARD_HOST must be loopback-only/
|
||||
);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
asyncTest('server returns no-store HTML on GET /', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const response = await requestDashboard(port);
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
assert.strictEqual(response.headers['content-type'], 'text/html; charset=utf-8');
|
||||
assert.strictEqual(response.headers['cache-control'], 'no-store');
|
||||
assert.ok(response.body.includes('<!DOCTYPE html>'));
|
||||
assert.ok(response.body.includes('ECC Capabilities'));
|
||||
});
|
||||
});
|
||||
|
||||
test('server returns JSON on GET /api/data', (done) => {
|
||||
const { server } = require(SCRIPT);
|
||||
const testServer = http.createServer(server._events.request);
|
||||
testServer.listen(0, () => {
|
||||
const port = testServer.address().port;
|
||||
http.get(`http://localhost:${port}/api/data`, (res) => {
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.headers['content-type'], 'application/json');
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
const parsed = JSON.parse(body);
|
||||
assert.ok(Array.isArray(parsed.agents));
|
||||
assert.ok(Array.isArray(parsed.skills));
|
||||
assert.ok(Array.isArray(parsed.commands));
|
||||
assert.ok(Array.isArray(parsed.rules));
|
||||
assert.ok(Array.isArray(parsed.mcps));
|
||||
assert.ok(Array.isArray(parsed.hooks));
|
||||
testServer.close();
|
||||
done();
|
||||
});
|
||||
asyncTest('server returns no-store JSON on GET /api/data', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const response = await requestDashboard(port, { path: '/api/data' });
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
assert.strictEqual(response.headers['content-type'], 'application/json');
|
||||
assert.strictEqual(response.headers['cache-control'], 'no-store');
|
||||
const parsed = JSON.parse(response.body);
|
||||
assert.ok(Array.isArray(parsed.agents));
|
||||
assert.ok(Array.isArray(parsed.skills));
|
||||
assert.ok(Array.isArray(parsed.commands));
|
||||
assert.ok(Array.isArray(parsed.rules));
|
||||
assert.ok(Array.isArray(parsed.mcps));
|
||||
assert.ok(Array.isArray(parsed.hooks));
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -793,5 +970,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();
|
||||
|
||||
Reference in New Issue
Block a user