fix: harden unified memory boundaries

This commit is contained in:
Affaan Mustafa
2026-07-26 04:35:46 -04:00
parent 60919b9472
commit c64875d9c6
22 changed files with 632 additions and 56 deletions
+22 -5
View File
@@ -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
View File
@@ -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,
+1 -6
View File
@@ -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
View File
@@ -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) {