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
+17
View File
@@ -9,6 +9,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The
vault stores portable `ecc.memory.v1` Markdown documents rather than
harness-specific transcripts or inboxes.
## Runtime Prerequisite
This skill is guidance, not the Memory Vault executable. Skill-only, minimal,
manual, and Claude plugin installs do not create the required commands on
`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI
or MCP examples:
```bash
npm install -g ecc-universal
ecc memory --help
command -v ecc-memory-mcp
```
A repository checkout may instead run the CLI as
`node scripts/ecc.js memory ...`, but MCP configurations that name
`ecc-memory-mcp` still require that binary on `PATH`.
## When To Use
- Save durable context that another agent or later session will need.
+17
View File
@@ -10,6 +10,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The
vault stores portable `ecc.memory.v1` Markdown documents rather than
harness-specific transcripts or inboxes.
## Runtime Prerequisite
This skill is guidance, not the Memory Vault executable. Skill-only, minimal,
manual, and Claude plugin installs do not create the required commands on
`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI
or MCP examples:
```bash
npm install -g ecc-universal
ecc memory --help
command -v ecc-memory-mcp
```
A repository checkout may instead run the CLI as
`node scripts/ecc.js memory ...`, but MCP configurations that name
`ecc-memory-mcp` still require that binary on `PATH`.
## When To Use
- Save durable context that another agent or later session will need.
+10
View File
@@ -293,6 +293,16 @@ team scopes live under `.ecc/memory/`; user scope lives under
use the team scope only for human-inspected, version-controlled sharing. Team
memories remain unreviewed context even after they are committed.
Skill-only, minimal, manual, and Claude plugin installs do not put the Memory
Vault runtime on `PATH`. Install the npm runtime separately before using the CLI
or optional MCP server:
```bash
npm install -g ecc-universal
ecc memory --help
command -v ecc-memory-mcp
```
```bash
# Initialize the project vault.
ecc memory init --scope project
+14
View File
@@ -107,6 +107,20 @@ Codex、Hermes 等 harness 之间传递上下文。常规搜索只召回 `projec
`ECC_MEMORY_ALLOW_USER_SCOPE=1` 后,MCP 调用才能显式请求 `user` 范围。
该服务默认不会启用。
仅安装 skill、最小配置、手动复制或 Claude 插件不会把记忆库运行时加入
`PATH`。请先单独安装 ECC npm 运行时:
```bash
npm install -g ecc-universal
ecc memory --help
command -v ecc-memory-mcp
```
如需启用 MCP,请从 `mcp-configs/mcp-servers.json` 复制
`ecc-memory-vault` 配置到对应 harness,并为每个 harness 分别启动一个服务
进程,例如 `ECC_MEMORY_HARNESS=codex ecc-memory-mcp`。不同 harness 可以共享
同一个二进制文件和记忆库目录,但不能共用同一个服务进程。
## 快速开始
在 2 分钟内快速上手:
+16 -5
View File
@@ -54,7 +54,16 @@ Use this as the minimal surface to reproduce the setup without leaking private s
ECC Memory Vault provides one file-first handoff layer instead of a separate
inbox or transcript store for every agent. Initialize it from the repository
that the agents share:
that the agents share. Skill-only, minimal, manual, and Claude plugin installs
do not add the Memory Vault runtime to `PATH`; install it separately first:
```bash
npm install -g ecc-universal
ecc memory --help
command -v ecc-memory-mcp
```
Then initialize the vault:
```bash
ecc memory init --scope project --scope team
@@ -68,10 +77,12 @@ unreviewed context; human acceptance means promoting verified knowledge into
governed project documentation.
Hermes can call the CLI directly or use the opt-in `ecc-memory-mcp` stdio
server. Claude, Codex, Cursor, and OpenCode can connect to the same server or
use the same CLI. Every harness must launch from the same repository working
directory or receive identical `ECC_MEMORY_PROJECT_ROOT` and
`ECC_MEMORY_USER_ROOT` overrides.
server. Harnesses may share the same installed binary and vault storage, but
each harness must launch its own server process with its own distinct lowercase
`ECC_MEMORY_HARNESS` identity; they must not connect to one shared server
process. Every process must launch from the same repository working directory
or receive identical `ECC_MEMORY_PROJECT_ROOT` and `ECC_MEMORY_USER_ROOT`
overrides.
A Hermes-to-Codex handoff can be written without putting the body in the
process list:
+14
View File
@@ -177,6 +177,20 @@ Codex、Hermes 等 harness 之间传递上下文。常规搜索只召回 `projec
`ECC_MEMORY_ALLOW_USER_SCOPE=1` 后,MCP 调用才能显式请求 `user` 范围。
该服务默认不会启用。
仅安装 skill、最小配置、手动复制或 Claude 插件不会把记忆库运行时加入
`PATH`。请先单独安装 ECC npm 运行时:
```bash
npm install -g ecc-universal
ecc memory --help
command -v ecc-memory-mcp
```
如需启用 MCP,请从 `mcp-configs/mcp-servers.json` 复制
`ecc-memory-vault` 配置到对应 harness,并为每个 harness 分别启动一个服务
进程,例如 `ECC_MEMORY_HARNESS=codex ecc-memory-mcp`。不同 harness 可以共享
同一个二进制文件和记忆库目录,但不能共用同一个服务进程。
## 快速开始
在 2 分钟内启动并运行:
+8
View File
@@ -470,6 +470,14 @@
"workflow-quality"
]
},
{
"id": "skill:unified-memory",
"family": "skill",
"description": "Cross-harness memory guidance that requires the separately installed ecc-universal CLI runtime.",
"modules": [
"skill-unified-memory"
]
},
{
"id": "skill:tdd-workflow",
"family": "skill",
+32 -3
View File
@@ -272,10 +272,40 @@
"cost": "medium",
"stability": "stable"
},
{
"id": "skill-unified-memory",
"kind": "skills",
"description": "Single-skill unified-memory guidance; requires the separately installed ecc-universal CLI runtime.",
"paths": [
"skills/unified-memory"
],
"targets": [
"claude",
"claude-project",
"cursor",
"antigravity",
"codex",
"gemini",
"opencode",
"codebuddy",
"joycode",
"qwen",
"zed",
"hermes",
"openclaw",
"kimi"
],
"dependencies": [
"platform-configs"
],
"defaultInstall": false,
"cost": "light",
"stability": "stable"
},
{
"id": "workflow-quality",
"kind": "skills",
"description": "Evaluation, TDD, verification, compaction, learning, and cross-harness memory skills, including the legacy continuous-learning v1 path.",
"description": "Evaluation, TDD, verification, compaction, learning, and cross-harness memory skills, including the legacy continuous-learning v1 path. The unified-memory workflow requires the separately installed ecc-universal CLI runtime.",
"paths": [
"skills/agent-sort",
"skills/agent-introspection-debugging",
@@ -297,7 +327,6 @@
"skills/skill-stocktake",
"skills/strategic-compact",
"skills/tdd-workflow",
"skills/unified-memory",
"skills/verification-loop",
"skills/windows-desktop-e2e",
"skills/agent-self-evaluation",
@@ -338,7 +367,7 @@
"kimi"
],
"dependencies": [
"platform-configs"
"skill-unified-memory"
],
"defaultInstall": true,
"cost": "medium",
+1
View File
@@ -81,6 +81,7 @@
"framework-language",
"database",
"workflow-quality",
"skill-unified-memory",
"security",
"research-apis",
"business-content",
+1 -1
View File
@@ -44,7 +44,7 @@
"ecc-memory-vault": {
"command": "ecc-memory-mcp",
"env": {
"ECC_MEMORY_HARNESS": "REPLACE_WITH_LOWERCASE_HARNESS_SLUG"
"ECC_MEMORY_HARNESS": "YOUR_LOWERCASE_HARNESS_SLUG_HERE"
},
"description": "Opt-in local ECC Memory Vault shared by Claude, Codex, Hermes, Cursor, OpenCode, and other MCP clients. Replace ECC_MEMORY_HARNESS with this server's lowercase identity; callers cannot override it. Normal search recall is active project+team memory. To permit explicitly requested user scope, the operator may also set ECC_MEMORY_ALLOW_USER_SCOPE=1. Writes are create-only and always unreviewed. Install ECC globally or make its bin available on PATH. Not enabled by default."
},
+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) {
+17
View File
@@ -11,6 +11,23 @@ Use the ECC Memory Vault as the common context layer between harnesses. The
vault stores portable `ecc.memory.v1` Markdown documents rather than
harness-specific transcripts or inboxes.
## Runtime Prerequisite
This skill is guidance, not the Memory Vault executable. Skill-only, minimal,
manual, and Claude plugin installs do not create the required commands on
`PATH`. Install the `ecc-universal` npm runtime separately before using the CLI
or MCP examples:
```bash
npm install -g ecc-universal
ecc memory --help
command -v ecc-memory-mcp
```
A repository checkout may instead run the CLI as
`node scripts/ecc.js memory ...`, but MCP configurations that name
`ecc-memory-mcp` still require that binary on `PATH`.
## When To Use
- Save durable context that another agent or later session will need.
+69
View File
@@ -0,0 +1,69 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..', '..');
const SKILL_PATHS = [
'skills/unified-memory/SKILL.md',
'.agents/skills/unified-memory/SKILL.md',
'.cursor/skills/unified-memory/SKILL.md',
];
const RUNTIME_DOC_PATHS = [
...SKILL_PATHS,
'README.md',
'docs/HERMES-SETUP.md',
'README.zh-CN.md',
'docs/zh-CN/README.md',
];
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
passed += 1;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` ${error.stack || error.message}`);
failed += 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8');
}
function stripFrontmatter(source) {
return source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
}
console.log('\n=== Testing unified-memory install and adapter surfaces ===\n');
test('documents the separately installed ECC runtime on every exposed surface', () => {
for (const relativePath of RUNTIME_DOC_PATHS) {
const source = read(relativePath);
assert.match(
source,
/npm install -g ecc-universal/i,
`${relativePath} must state how to install the required CLI runtime`
);
assert.match(
source,
/ecc-memory-mcp/,
`${relativePath} must identify the optional MCP binary`
);
}
});
test('keeps harness-specific unified-memory skill bodies in sync', () => {
const bodies = SKILL_PATHS.map(relativePath => stripFrontmatter(read(relativePath)));
assert.strictEqual(bodies[1], bodies[0], `${SKILL_PATHS[1]} body drifted`);
assert.strictEqual(bodies[2], bodies[0], `${SKILL_PATHS[2]} body drifted`);
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+53 -4
View File
@@ -168,6 +168,27 @@ function runTests() {
);
})) passed++; else failed++;
if (test('marks unified-memory install surfaces as requiring the separate ECC runtime', () => {
const component = getInstallComponent('skill:unified-memory');
assert.deepStrictEqual(component.moduleIds, ['skill-unified-memory']);
assert.match(component.description, /ecc-universal/i);
assert.match(component.description, /separate|external|runtime/i);
const modules = listInstallModules();
const singleSkillModule = modules.find(module => module.id === 'skill-unified-memory');
const workflowModule = modules.find(module => module.id === 'workflow-quality');
assert.ok(singleSkillModule, 'Should define an explicit unified-memory module');
assert.match(singleSkillModule.description, /ecc-universal/i);
assert.match(workflowModule.description, /ecc-universal/i);
const plan = resolveInstallPlan({
includeComponentIds: ['skill:unified-memory'],
target: 'claude',
});
assert.ok(plan.selectedModuleIds.includes('skill-unified-memory'));
assert.ok(plan.selectedModuleIds.includes('platform-configs'));
})) passed++; else failed++;
if (test('lists supported legacy compatibility languages', () => {
const languages = listLegacyCompatibilityLanguages();
assert.ok(languages.includes('typescript'));
@@ -230,7 +251,14 @@ function runTests() {
assert.deepStrictEqual(
plan.selectedModuleIds,
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
[
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'skill-unified-memory',
'workflow-quality'
]
);
assert.ok(plan.skippedModuleIds.includes('hooks-runtime'));
assert.ok(!plan.skippedModuleIds.includes('platform-configs'));
@@ -248,7 +276,14 @@ function runTests() {
assert.deepStrictEqual(
plan.selectedModuleIds,
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
[
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'skill-unified-memory',
'workflow-quality'
]
);
assert.ok(!plan.selectedModuleIds.includes('hooks-runtime'),
'minimal profile should not install hooks-runtime');
@@ -265,7 +300,14 @@ function runTests() {
assert.deepStrictEqual(
plan.selectedModuleIds,
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
[
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'skill-unified-memory',
'workflow-quality'
]
);
assert.deepStrictEqual(plan.skippedModuleIds, []);
assert.strictEqual(plan.targetAdapterId, 'qwen-home');
@@ -290,7 +332,14 @@ function runTests() {
assert.deepStrictEqual(
plan.selectedModuleIds,
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
[
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'skill-unified-memory',
'workflow-quality'
]
);
assert.deepStrictEqual(plan.skippedModuleIds, []);
assert.strictEqual(plan.targetAdapterId, 'zed-project');
+7
View File
@@ -103,5 +103,12 @@ test('rejects terminal and bidirectional control characters', () => {
assertRejected(representativeMemory({ body: ' \n\t' }), 'pattern');
});
test('accepts newlines, tabs, and carriage returns inside a non-empty Markdown body', () => {
const memory = representativeMemory({
body: 'Line one\n\n- item\twith tab\r\nLine two',
});
assert.strictEqual(validateMemory(memory), true, JSON.stringify(validateMemory.errors));
});
console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
+113 -11
View File
@@ -7,6 +7,9 @@ const path = require('path');
const { spawnSync } = require('child_process');
const {
MAX_DIAGNOSTICS,
MAX_FILES,
MAX_SCAN_BYTES,
MEMORY_SCHEMA_VERSION,
MEMORY_KINDS,
doctorMemoryVault,
@@ -169,6 +172,21 @@ test('round-trips the strict ecc.memory.v1 Markdown frontmatter contract', () =>
assert.deepStrictEqual(parseMemoryDocument(serialized, 'handoff.md'), original);
});
test('accepts CRLF frontmatter delimiters and line endings', () => {
const original = baseMemory();
const serialized = serializeMemoryDocument(original).replace(/\n/g, '\r\n');
assert.deepStrictEqual(parseMemoryDocument(serialized, 'windows.md'), original);
});
test('requires the closing frontmatter marker to occupy an exact delimiter line', () => {
const malformed = serializeMemoryDocument(baseMemory())
.replace('\n---\n\n', '\n---NOT-A-DELIMITER\n\n');
assert.throws(
() => parseMemoryDocument(malformed, 'malformed-closing.md'),
/closing frontmatter|frontmatter line/i
);
});
test('rejects malformed, unknown-schema, and invalid metadata documents', () => {
assert.throws(() => parseMemoryDocument('not frontmatter', 'bad.md'), /frontmatter/i);
assert.throws(
@@ -249,6 +267,30 @@ test('never overwrites a duplicate ID', () => {
}
});
test('never follows a pre-existing destination symlink during create-only publication', () => {
const fixture = createFixture();
const outside = path.join(fixture.root, 'outside.md');
try {
const notes = path.join(fixture.roots.project, 'notes');
fs.mkdirSync(notes, { recursive: true });
fs.writeFileSync(outside, 'outside sentinel');
const destination = path.join(notes, 'mem_20260726_01kexample.md');
fs.symlinkSync(outside, destination);
assert.throws(
() => saveMemory(
{ title: 'Must not overwrite', body: 'create-only content' },
fixedOptions(fixture.roots)
),
/already exists|create-only|outside|refusing/i
);
assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'outside sentinel');
assert.strictEqual(fs.lstatSync(destination).isSymbolicLink(), true);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
});
test('fails closed when the project memory gitignore is preseeded with unsafe rules', () => {
const fixture = createFixture();
try {
@@ -327,6 +369,48 @@ test('rejects a vault path that traverses a symlink before creating directories'
}
});
test('rejects a symlinked ancestor when roots come back from initializeVault', () => {
const fixture = createFixture();
const outside = path.join(fixture.root, 'outside');
fs.mkdirSync(outside);
try {
const initialized = initializeVault({ roots: fixture.roots, scopes: ['project'] });
fs.rmSync(path.join(fixture.projectRoot, '.ecc'), { recursive: true, force: true });
fs.symlinkSync(outside, path.join(fixture.projectRoot, '.ecc'));
assert.throws(
() => saveMemory(
{ title: 'Escaped note', body: 'must stay in the project' },
{ ...fixedOptions(fixture.roots), roots: initialized.roots }
),
/symlink|outside|trusted/i
);
assert.strictEqual(fs.existsSync(path.join(outside, 'memory')), false);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
});
test('fails closed when callers provide roots without a boundary policy', () => {
const fixture = createFixture();
try {
const rootsWithoutPolicy = {
project: fixture.roots.project,
team: fixture.roots.team,
user: fixture.roots.user,
};
assert.throws(
() => saveMemory(
{ title: 'Untrusted roots', body: 'must not be written' },
fixedOptions(rootsWithoutPolicy)
),
/boundary policy|trusted boundary/i
);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
});
test('rejects traversal IDs, oversized bodies, NUL bytes, and suspected secrets', () => {
const fixture = createFixture();
try {
@@ -449,6 +533,20 @@ test('opens regular text files without following a stable symlink', () => {
}
});
test('rejects malformed UTF-8 instead of altering durable text', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-utf8-'));
const target = path.join(root, 'invalid.md');
try {
fs.writeFileSync(target, Buffer.from([0x61, 0xc3, 0x28, 0x62]));
assert.throws(
() => readRegularTextFile(target, { maxBytes: 16 }),
/valid UTF-8/i
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('opens a file descriptor before inspecting path metadata', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-open-first-'));
const target = path.join(root, 'target.md');
@@ -683,7 +781,7 @@ test('doctor caps traversal before an oversized directory can dominate recall',
const notes = path.join(fixture.roots.project, 'notes');
try {
fs.mkdirSync(notes, { recursive: true });
for (let index = 0; index < 5001; index += 1) {
for (let index = 0; index < MAX_FILES + 1; index += 1) {
fs.writeFileSync(path.join(notes, `noise-${index}.txt`), '');
}
const report = doctorMemoryVault({
@@ -702,14 +800,16 @@ test('doctor caps hostile diagnostics and reports total counts', () => {
const notes = path.join(fixture.roots.project, 'notes');
try {
fs.mkdirSync(notes, { recursive: true });
for (let index = 0; index < 120; index += 1) {
const invalidFileTotal = MAX_DIAGNOSTICS + 20;
for (let index = 0; index < invalidFileTotal; index += 1) {
fs.writeFileSync(path.join(notes, `malformed-${index}.md`), 'not memory');
}
const missingLinks = Array.from(
{ length: 64 },
(_, index) => `mem_missing_${String(index).padStart(3, '0')}`
);
for (let index = 0; index < 2; index += 1) {
const linkDocumentCount = Math.ceil((MAX_DIAGNOSTICS + 1) / missingLinks.length);
for (let index = 0; index < linkDocumentCount; index += 1) {
fs.writeFileSync(
path.join(notes, `links-${index}.md`),
serializeMemoryDocument(baseMemory({
@@ -724,10 +824,10 @@ test('doctor caps hostile diagnostics and reports total counts', () => {
roots: fixture.roots,
scopes: ['project'],
});
assert.strictEqual(report.invalidFileCount, 120);
assert.strictEqual(report.invalidFiles.length, 100);
assert.strictEqual(report.brokenLinkCount, 128);
assert.strictEqual(report.brokenLinks.length, 100);
assert.strictEqual(report.invalidFileCount, invalidFileTotal);
assert.strictEqual(report.invalidFiles.length, MAX_DIAGNOSTICS);
assert.strictEqual(report.brokenLinkCount, linkDocumentCount * missingLinks.length);
assert.strictEqual(report.brokenLinks.length, MAX_DIAGNOSTICS);
assert.strictEqual(report.diagnosticsTruncated, true);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
@@ -739,7 +839,9 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => {
const notes = path.join(fixture.roots.project, 'notes');
try {
fs.mkdirSync(notes, { recursive: true });
for (let index = 0; index < 270; index += 1) {
const bodyBytes = 63 * 1024;
const fileTotal = Math.ceil(MAX_SCAN_BYTES / bodyBytes) + 2;
for (let index = 0; index < fileTotal; index += 1) {
const id = `mem_scan_${String(index).padStart(4, '0')}`;
fs.writeFileSync(
path.join(notes, `${id}.md`),
@@ -747,7 +849,7 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => {
id,
kind: 'note',
links: [],
body: 'x'.repeat(63 * 1024),
body: 'x'.repeat(bodyBytes),
}))
);
}
@@ -756,8 +858,8 @@ test('doctor enforces one aggregate scan-byte budget across a request', () => {
scopes: ['project'],
});
assert.strictEqual(report.truncated, true);
assert.ok(report.scannedBytes <= 16 * 1024 * 1024);
assert.ok(report.memoryCount < 270);
assert.ok(report.scannedBytes <= MAX_SCAN_BYTES);
assert.ok(report.memoryCount < fileTotal);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
+16 -3
View File
@@ -365,7 +365,10 @@ function runTests() {
assert.ok(result.stdout.includes('Mode: manifest'));
assert.ok(result.stdout.includes('Profile: core'));
assert.ok(result.stdout.includes('Included components: (none)'));
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, hooks-runtime, platform-configs, workflow-quality'));
assert.ok(result.stdout.includes(
'Selected modules: rules-core, agents-core, commands-core, hooks-runtime, '
+ 'platform-configs, skill-unified-memory, workflow-quality'
));
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
} finally {
cleanup(homeDir);
@@ -404,7 +407,10 @@ function runTests() {
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(result.stdout.includes('Mode: manifest'));
assert.ok(result.stdout.includes('Profile: minimal'));
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, platform-configs, workflow-quality'));
assert.ok(result.stdout.includes(
'Selected modules: rules-core, agents-core, commands-core, platform-configs, '
+ 'skill-unified-memory, workflow-quality'
));
assert.ok(!result.stdout.includes('hooks-runtime'));
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
} finally {
@@ -491,7 +497,14 @@ function runTests() {
assert.strictEqual(state.request.legacyMode, false);
assert.deepStrictEqual(
state.resolution.selectedModules,
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
[
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'skill-unified-memory',
'workflow-quality'
]
);
assert.ok(state.resolution.skippedModules.includes('hooks-runtime'));
assert.ok(!state.resolution.skippedModules.includes('workflow-quality'));
+52 -1
View File
@@ -9,6 +9,11 @@ const { PassThrough } = require('stream');
const { pathToFileURL } = require('url');
const SERVER = path.join(__dirname, '..', '..', 'scripts', 'memory-mcp.mjs');
const {
MAX_RESULTS,
resolveVaultRoots,
saveMemory,
} = require('../../scripts/lib/memory-vault');
let passed = 0;
let failed = 0;
@@ -345,6 +350,51 @@ async function main() {
});
});
await test('filters harness-visible backlinks before applying the response cap', async () => {
await withClient(async (client, fixture) => {
const roots = resolveVaultRoots({
cwd: fixture.projectRoot,
env: fixture.env,
});
const saveWithId = (input, id) => saveMemory(input, {
roots,
now: () => '2026-07-26T20:00:00.000Z',
idFactory: () => id,
});
const targetId = 'mem_backlink_target';
saveWithId({
title: 'Backlink target',
body: 'Visible target body.',
targetHarnesses: ['claude'],
}, targetId);
for (let index = 0; index < MAX_RESULTS; index += 1) {
saveWithId({
title: `Hidden backlink ${index}`,
body: 'Only Hermes may see this backlink.',
targetHarnesses: ['hermes'],
links: [targetId],
}, `mem_backlink_hidden_${String(index).padStart(3, '0')}`);
}
saveWithId({
title: 'Visible backlink',
body: 'Claude must still receive this backlink.',
targetHarnesses: ['claude'],
links: [targetId],
}, 'mem_backlink_visible_zzz');
const read = parseTextResult(await client.callTool({
name: 'memory_read',
arguments: { id: targetId },
}));
assert.deepStrictEqual(
read.backlinks.map(memory => memory.id),
['mem_backlink_visible_zzz']
);
assert.strictEqual(read.backlinksTruncated, false);
});
});
await test('denies user scope unless the server explicitly grants it', async () => {
await withClient(async client => {
await assert.rejects(
@@ -429,7 +479,8 @@ async function main() {
env: fixture.env,
encoding: 'utf8',
});
assert.notStrictEqual(started.status, 0);
assert.strictEqual(started.error, undefined);
assert.strictEqual(started.status, 1);
assert.match(started.stderr, /ECC_MEMORY_HARNESS/);
assert.ok(!started.stderr.includes('\n at '));
} finally {
+77 -1
View File
@@ -8,7 +8,7 @@ const { spawnSync } = require('child_process');
const MEMORY_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'memory.js');
const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
const { sanitizeTerminalText } = require(MEMORY_SCRIPT);
const { readBoundedStdin, sanitizeTerminalText } = require(MEMORY_SCRIPT);
let passed = 0;
let failed = 0;
@@ -103,6 +103,68 @@ test('routes stdin through ecc memory without dropping the body', () => {
}
});
test('retries transient stdin EAGAIN without busy-spinning and preserves byte bounds', () => {
const originalReadSync = fs.readSync;
let readCalls = 0;
let waitCalls = 0;
try {
fs.readSync = (_descriptor, buffer) => {
readCalls += 1;
if (readCalls <= 2) {
const error = new Error('temporarily unavailable');
error.code = 'EAGAIN';
throw error;
}
if (readCalls === 3) {
buffer.write('ready');
return 5;
}
return 0;
};
assert.strictEqual(readBoundedStdin(8, {
retryDelayMs: 1,
maxRetryWaitMs: 4,
wait: () => {
waitCalls += 1;
},
}), 'ready');
assert.strictEqual(readCalls, 4);
assert.strictEqual(waitCalls, 2);
} finally {
fs.readSync = originalReadSync;
}
});
test('bounds persistent stdin EAGAIN retries instead of waiting forever', () => {
const originalReadSync = fs.readSync;
let readCalls = 0;
let waitCalls = 0;
try {
fs.readSync = () => {
readCalls += 1;
const error = new Error('temporarily unavailable');
error.code = 'EAGAIN';
throw error;
};
assert.throws(
() => readBoundedStdin(8, {
retryDelayMs: 1,
maxRetryWaitMs: 3,
wait: () => {
waitCalls += 1;
},
}),
/standard input remained unavailable/i
);
assert.strictEqual(readCalls, 4);
assert.strictEqual(waitCalls, 3);
} finally {
fs.readSync = originalReadSync;
}
});
test('initializes selected scopes and reports their roots as JSON', () => {
const fixture = createFixture();
try {
@@ -310,6 +372,20 @@ test('rejects ambiguous body sources and does not expose a trust promotion flag'
], fixture, { input: ' \n\t' });
assert.notStrictEqual(empty.status, 0);
assert.ok(empty.stderr.includes('non-whitespace context'));
const invalidUtf8Body = path.join(fixture.root, 'invalid-utf8.md');
fs.writeFileSync(invalidUtf8Body, Buffer.from([0x61, 0xc3, 0x28, 0x62]));
const invalidUtf8 = run(MEMORY_SCRIPT, [
'save',
'--title', 'Invalid UTF-8',
'--body-file', invalidUtf8Body,
], fixture);
assert.notStrictEqual(invalidUtf8.status, 0);
assert.match(invalidUtf8.stderr, /valid UTF-8/i);
assert.strictEqual(
fs.existsSync(path.join(fixture.projectRoot, '.ecc', 'memory')),
false
);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}