mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 19:57:57 +02:00
fix: harden unified memory boundaries
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const { TextDecoder } = require('util');
|
||||
|
||||
const MEMORY_SCHEMA_VERSION = 'ecc.memory.v1';
|
||||
const MEMORY_KINDS = Object.freeze([
|
||||
'context',
|
||||
@@ -42,6 +44,7 @@ const FRONTMATTER_FIELDS = Object.freeze([
|
||||
['updated_at', 'updatedAt'],
|
||||
]);
|
||||
const FRONTMATTER_KEYS = new Map(FRONTMATTER_FIELDS);
|
||||
const FATAL_UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
const SECRET_PATTERNS = Object.freeze([
|
||||
{ label: 'provider API key', pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/i },
|
||||
@@ -208,6 +211,14 @@ function serializeMemoryDocument(memory) {
|
||||
return `---\n${metadata}\n---${body}\n`;
|
||||
}
|
||||
|
||||
function decodeUtf8(buffer, label = 'text') {
|
||||
try {
|
||||
return FATAL_UTF8_DECODER.decode(buffer);
|
||||
} catch {
|
||||
throw new Error(`${label} must contain valid UTF-8 text.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontmatterLine(line, sourcePath, seen) {
|
||||
const separator = line.indexOf(':');
|
||||
if (separator <= 0) {
|
||||
@@ -230,19 +241,24 @@ function parseFrontmatterLine(line, sourcePath, seen) {
|
||||
}
|
||||
|
||||
function parseMemoryDocument(source, sourcePath = '<memory>') {
|
||||
if (typeof source !== 'string' || !source.startsWith('---\n')) {
|
||||
const openingMarker = typeof source === 'string'
|
||||
? /^---\r?\n/.exec(source)
|
||||
: null;
|
||||
if (!openingMarker) {
|
||||
throw new Error(`Memory document ${sourcePath} must start with --- frontmatter.`);
|
||||
}
|
||||
if (Buffer.byteLength(source, 'utf8') > MAX_DOCUMENT_BYTES) {
|
||||
throw new Error(`Memory document ${sourcePath} is too large.`);
|
||||
}
|
||||
|
||||
const closingIndex = source.indexOf('\n---', 4);
|
||||
if (closingIndex < 0) {
|
||||
const frontmatterStart = openingMarker[0].length;
|
||||
const remainder = source.slice(frontmatterStart);
|
||||
const closingMarker = /\r?\n---(?=\r?\n|$)/.exec(remainder);
|
||||
if (!closingMarker) {
|
||||
throw new Error(`Memory document ${sourcePath} has no closing frontmatter marker.`);
|
||||
}
|
||||
|
||||
const frontmatterSource = source.slice(4, closingIndex);
|
||||
const frontmatterSource = remainder.slice(0, closingMarker.index);
|
||||
const parsed = frontmatterSource.split(/\r?\n/).reduce((state, line) => {
|
||||
const next = parseFrontmatterLine(line, sourcePath, state.seen);
|
||||
return {
|
||||
@@ -258,7 +274,7 @@ function parseMemoryDocument(source, sourcePath = '<memory>') {
|
||||
throw new Error(`Memory document ${sourcePath} is missing fields: ${missing.join(', ')}.`);
|
||||
}
|
||||
|
||||
const afterMarker = source.slice(closingIndex + 4);
|
||||
const afterMarker = remainder.slice(closingMarker.index + closingMarker[0].length);
|
||||
const body = afterMarker.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
|
||||
return normalizeMemory({ ...parsed.values, body });
|
||||
}
|
||||
@@ -280,6 +296,7 @@ module.exports = {
|
||||
MEMORY_STATUSES,
|
||||
MEMORY_TRUST_STATES,
|
||||
asNonEmptyString,
|
||||
decodeUtf8,
|
||||
findPotentialSecrets,
|
||||
hasUnsafeControlCharacters,
|
||||
normalizeMemory,
|
||||
|
||||
+38
-13
@@ -5,7 +5,7 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
const { assertWithinTrustedRoot, realpathNearestExisting } = require('./path-safety');
|
||||
const {
|
||||
MAX_BODY_BYTES,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
MEMORY_STATUSES,
|
||||
MEMORY_TRUST_STATES,
|
||||
asNonEmptyString,
|
||||
decodeUtf8,
|
||||
findPotentialSecrets,
|
||||
hasUnsafeControlCharacters,
|
||||
normalizeMemory,
|
||||
@@ -35,7 +36,7 @@ const MAX_QUERY_CHARS = 500;
|
||||
const MAX_RESULTS = 100;
|
||||
const PROJECT_MEMORY_GITIGNORE = '*\n!.gitignore\n';
|
||||
|
||||
const VAULT_ROOT_POLICIES = new WeakMap();
|
||||
const VAULT_ROOT_BOUNDARIES = Symbol('vaultRootBoundaries');
|
||||
|
||||
function findNearestProjectRoot(cwd) {
|
||||
let current = path.resolve(cwd);
|
||||
@@ -74,23 +75,38 @@ function resolveVaultRoots(options = {}) {
|
||||
team: path.join(projectVault, 'team'),
|
||||
user: userVault,
|
||||
};
|
||||
VAULT_ROOT_POLICIES.set(roots, {
|
||||
project: env.ECC_MEMORY_PROJECT_ROOT ? null : projectRoot,
|
||||
team: env.ECC_MEMORY_PROJECT_ROOT ? null : projectRoot,
|
||||
user: env.ECC_MEMORY_USER_ROOT ? null : homeDir,
|
||||
Object.defineProperty(roots, VAULT_ROOT_BOUNDARIES, {
|
||||
value: Object.freeze({
|
||||
project: env.ECC_MEMORY_PROJECT_ROOT
|
||||
? realpathNearestExisting(projectVault)
|
||||
: projectRoot,
|
||||
team: env.ECC_MEMORY_PROJECT_ROOT
|
||||
? realpathNearestExisting(projectVault)
|
||||
: projectRoot,
|
||||
user: env.ECC_MEMORY_USER_ROOT
|
||||
? realpathNearestExisting(userVault)
|
||||
: homeDir,
|
||||
}),
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
return roots;
|
||||
return Object.freeze(roots);
|
||||
}
|
||||
|
||||
function assertMemoryRootSafe(roots, scope) {
|
||||
if (!roots || typeof roots !== 'object' || Array.isArray(roots)) {
|
||||
throw new Error('Memory roots must include a trusted boundary policy.');
|
||||
}
|
||||
const root = roots[scope];
|
||||
if (typeof root !== 'string' || root.length === 0) {
|
||||
throw new Error(`No memory root is configured for scope "${scope}".`);
|
||||
}
|
||||
const boundary = VAULT_ROOT_POLICIES.get(roots)?.[scope];
|
||||
if (boundary) {
|
||||
assertWithinTrustedRoot(root, boundary, 'access memory through a symlink');
|
||||
const boundary = roots[VAULT_ROOT_BOUNDARIES]?.[scope];
|
||||
if (typeof boundary !== 'string' || boundary.length === 0) {
|
||||
throw new Error(`No trusted boundary policy is configured for memory scope "${scope}".`);
|
||||
}
|
||||
assertWithinTrustedRoot(root, boundary, 'access memory through a symlink');
|
||||
if (fs.existsSync(root) && fs.lstatSync(root).isSymbolicLink()) {
|
||||
throw new Error(`Refusing to access memory through symlink root: ${root}`);
|
||||
}
|
||||
@@ -152,7 +168,7 @@ function readRegularTextFile(filePath, options = {}) {
|
||||
if (total > maxBytes) {
|
||||
throw new Error(`${label} is too large (maximum ${maxBytes} bytes).`);
|
||||
}
|
||||
return Buffer.concat(chunks, total).toString('utf8');
|
||||
return decodeUtf8(Buffer.concat(chunks, total), label);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
@@ -254,7 +270,7 @@ function initializeVault(options = {}) {
|
||||
return directory;
|
||||
});
|
||||
});
|
||||
return { scopes, roots: { ...roots }, directories };
|
||||
return { scopes, roots, directories };
|
||||
}
|
||||
|
||||
function defaultMemoryId(now = new Date()) {
|
||||
@@ -646,6 +662,11 @@ function readMemoryById(id, options = {}) {
|
||||
.filter(entry => entry.memory.links.includes(memoryId))
|
||||
.filter(entry => entry.memory.status === 'active')
|
||||
.map(entry => entry.memory)
|
||||
.filter(memory => (
|
||||
!targetHarness
|
||||
|| memory.targetHarnesses.includes('all')
|
||||
|| memory.targetHarnesses.includes(targetHarness)
|
||||
))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const backlinks = allBacklinks
|
||||
.slice(0, MAX_RESULTS)
|
||||
@@ -695,7 +716,7 @@ function doctorMemoryVault(options = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const brokenLinks = allBrokenLinks
|
||||
const brokenLinks = [...allBrokenLinks]
|
||||
.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
||||
const ok = loaded.invalidFileCount === 0
|
||||
&& allDuplicateIds.length === 0
|
||||
@@ -726,15 +747,19 @@ function doctorMemoryVault(options = {}) {
|
||||
module.exports = {
|
||||
DEFAULT_RECALL_SCOPES,
|
||||
MAX_BODY_BYTES,
|
||||
MAX_DIAGNOSTICS,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MAX_FILES,
|
||||
MAX_QUERY_CHARS,
|
||||
MAX_RESULTS,
|
||||
MAX_SCAN_BYTES,
|
||||
MEMORY_KINDS,
|
||||
MEMORY_SCHEMA_VERSION,
|
||||
MEMORY_SCOPES,
|
||||
MEMORY_STATUSES,
|
||||
MEMORY_TRUST_STATES,
|
||||
defaultMemoryId,
|
||||
decodeUtf8,
|
||||
doctorMemoryVault,
|
||||
findPotentialSecrets,
|
||||
findNearestProjectRoot,
|
||||
|
||||
@@ -187,11 +187,6 @@ function assertScopesAuthorized(scopes, security) {
|
||||
return requestedScopes;
|
||||
}
|
||||
|
||||
function isTargetVisible(memory, harness) {
|
||||
return memory.targetHarnesses.includes('all')
|
||||
|| memory.targetHarnesses.includes(harness);
|
||||
}
|
||||
|
||||
function textResult(payload) {
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
|
||||
@@ -307,7 +302,7 @@ function executeMemoryTool(name, rawArguments, options = {}) {
|
||||
});
|
||||
return textResult({
|
||||
memory: read.memory,
|
||||
backlinks: read.backlinks.filter(memory => isTargetVisible(memory, security.harness)),
|
||||
backlinks: read.backlinks,
|
||||
backlinksTruncated: read.backlinksTruncated,
|
||||
});
|
||||
}
|
||||
|
||||
+37
-3
@@ -6,6 +6,7 @@ const path = require('path');
|
||||
|
||||
const {
|
||||
MAX_BODY_BYTES,
|
||||
decodeUtf8,
|
||||
doctorMemoryVault,
|
||||
initializeVault,
|
||||
readMemoryById,
|
||||
@@ -36,6 +37,9 @@ const BOOLEAN_OPTIONS = new Map([
|
||||
['--json', 'json'],
|
||||
['--stdin', 'stdin'],
|
||||
]);
|
||||
const DEFAULT_STDIN_RETRY_DELAY_MS = 10;
|
||||
const MAX_STDIN_RETRY_WAIT_MS = 5_000;
|
||||
const STDIN_RETRY_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
function usage() {
|
||||
return `
|
||||
@@ -140,12 +144,42 @@ function oneValue(values, label, fallback = null) {
|
||||
return values[0];
|
||||
}
|
||||
|
||||
function readBoundedStdin(maxBytes) {
|
||||
function waitForStdinRetry(milliseconds) {
|
||||
Atomics.wait(STDIN_RETRY_SIGNAL, 0, 0, milliseconds);
|
||||
}
|
||||
|
||||
function readBoundedStdin(maxBytes, retryOptions = {}) {
|
||||
const retryDelayMs = Number.isInteger(retryOptions.retryDelayMs)
|
||||
&& retryOptions.retryDelayMs > 0
|
||||
? retryOptions.retryDelayMs
|
||||
: DEFAULT_STDIN_RETRY_DELAY_MS;
|
||||
const maxRetryWaitMs = Number.isInteger(retryOptions.maxRetryWaitMs)
|
||||
&& retryOptions.maxRetryWaitMs >= 0
|
||||
? retryOptions.maxRetryWaitMs
|
||||
: MAX_STDIN_RETRY_WAIT_MS;
|
||||
const wait = typeof retryOptions.wait === 'function'
|
||||
? retryOptions.wait
|
||||
: waitForStdinRetry;
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let remainingRetryWaitMs = maxRetryWaitMs;
|
||||
while (total <= maxBytes) {
|
||||
const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
|
||||
const bytesRead = fs.readSync(0, buffer, 0, buffer.length, null);
|
||||
let bytesRead;
|
||||
try {
|
||||
bytesRead = fs.readSync(0, buffer, 0, buffer.length, null);
|
||||
} catch (error) {
|
||||
const retryable = ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error?.code);
|
||||
if (!retryable) throw error;
|
||||
if (remainingRetryWaitMs < retryDelayMs) {
|
||||
throw new Error(
|
||||
`Standard input remained unavailable after ${maxRetryWaitMs}ms.`
|
||||
);
|
||||
}
|
||||
wait(retryDelayMs);
|
||||
remainingRetryWaitMs -= retryDelayMs;
|
||||
continue;
|
||||
}
|
||||
if (bytesRead === 0) break;
|
||||
chunks.push(buffer.subarray(0, bytesRead));
|
||||
total += bytesRead;
|
||||
@@ -153,7 +187,7 @@ function readBoundedStdin(maxBytes) {
|
||||
if (total > maxBytes) {
|
||||
throw new Error(`memory body is too large (maximum ${maxBytes} bytes).`);
|
||||
}
|
||||
return Buffer.concat(chunks, total).toString('utf8');
|
||||
return decodeUtf8(Buffer.concat(chunks, total), 'memory body from standard input');
|
||||
}
|
||||
|
||||
function readBody(options) {
|
||||
|
||||
Reference in New Issue
Block a user