Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 87 additions & 9 deletions e2e/mcp-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { assertCliBuilt, createCliMachine, freePort, type CliMachine } from './h
// dual-channel output); this proves the BEHAVIORS a bad @agentage/server-memory or
// @agentage/memory-core bump could regress: str_replace edit edge cases, @<vault>/ routing over
// two vaults, bounded search pagination, soft-delete recoverability from git, list-tree shapes,
// and the per-connection instructions block. Ephemeral port + isolated AGENTAGE_CONFIG_DIR keep it
// off the real daemon; fully offline (local git working-copy vaults, loopback transport). @p0
// the per-connection instructions block, secret refusal, and the read-budget clamp. Ephemeral
// port + isolated AGENTAGE_CONFIG_DIR keep it off the real daemon; fully offline (local git
// working-copy vaults, loopback transport). @p0

interface ToolResult {
content?: Array<{ type: string; text: string }>;
Expand Down Expand Up @@ -340,12 +341,89 @@ test.describe('frozen MCP contract behaviors @p0', () => {
}
});

// FINDING: secret refusal is DESCRIPTION-ONLY upstream - memory__write/edit descriptions say
// secrets "are refused" but neither register-tools.js nor memory-core enforces it, so a
// credential body writes through. Nothing to assert without a fake-passing test.
test.skip('memory__write refuses obvious secrets (not enforced upstream)', () => {});
test('memory__write/edit refuse obvious secrets and never persist them', async () => {
const d = await startDaemon(['main']);
const vaultDir = join(d.m.configDir, 'main');
try {
// Ordinary prose that merely mentions "password" is NOT a false positive - it writes,
// establishing a HEAD to prove a refused write never moves.
const clean = await callTool(d.port, 'memory__write', {
path: 'ops/runbook.md',
body: 'Rotate the router password every quarter; see the ops calendar.',
});
expect(clean.isError, text(clean)).toBeFalsy();
const headBefore = git(vaultDir, ['rev-parse', 'HEAD']).trim();

// FINDING: memory__read returns the full body verbatim (memory-core local-backend read ->
// doc.body); there is no byte/line budget or truncation on read. Nothing to assert.
test.skip('memory__read returns a bounded body (no read budget upstream)', () => {});
// (a) A fake-but-shape-valid AWS access key in the body is refused with the canonical text.
const akia = await callTool(d.port, 'memory__write', {
path: 'secrets/aws.md',
body: 'export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
});
expect(akia.isError).toBe(true);
expect(text(akia)).toContain('Refused: this appears to contain');

// The refused doc does not exist: a read is not-found, git never recorded it, HEAD is unmoved.
const readBack = await callTool(d.port, 'memory__read', { path: 'secrets/aws.md' });
expect(readBack.isError).toBe(true);
expect(text(readBack)).toContain('No memory at path "secrets/aws.md"');
expect(git(vaultDir, ['log', '--oneline', '--', 'secrets/aws.md']).trim()).toBe('');
expect(git(vaultDir, ['rev-parse', 'HEAD']).trim()).toBe(headBefore);

// (b) A PEM private-key block is refused too (a different restricted class).
const pem = await callTool(d.port, 'memory__write', {
path: 'secrets/key.md',
body: '-----BEGIN RSA PRIVATE KEY-----\nMIIfakefakefake\n-----END RSA PRIVATE KEY-----',
});
expect(pem.isError).toBe(true);
expect(text(pem)).toContain('Refused: this appears to contain');

// (c) An edit whose new_str injects a secret is refused; the note stays exactly as written.
await callTool(d.port, 'memory__write', { path: 'ops/creds.md', body: 'placeholder value' });
const inject = await callTool(d.port, 'memory__edit', {
path: 'ops/creds.md',
mode: 'str_replace',
old_str: 'placeholder value',
new_str: 'token AKIAIOSFODNN7EXAMPLE',
});
expect(inject.isError).toBe(true);
expect(text(inject)).toContain('Refused: this appears to contain');
const unchanged = await callTool(d.port, 'memory__read', { path: 'ops/creds.md' });
expect(unchanged.structuredContent?.body).toBe('placeholder value');
} finally {
await stopDaemon(d);
}
});

test('memory__read clamps an oversized body on both channels; the stored file stays whole', async () => {
const d = await startDaemon(['main']);
const vaultDir = join(d.m.configDir, 'main');
try {
// A body well over the 64 KB read budget; no digits/secret shapes, so the write is accepted.
const big = 'the quokka keeps durable notes and knowledge here forever '.repeat(4000);
const originalBytes = Buffer.byteLength(big, 'utf8');
expect(originalBytes).toBeGreaterThan(200_000);
const w = await callTool(d.port, 'memory__write', { path: 'big/tome.md', body: big });
expect(w.isError, text(w)).toBeFalsy();

const read = await callTool(d.port, 'memory__read', { path: 'big/tome.md' });
expect(read.isError, text(read)).toBeFalsy();
const structuredBody = read.structuredContent?.body as string;
const marker = '[Truncated for display:';
// Both MCP channels carry the clamped body plus the marker line.
expect(structuredBody).toContain(marker);
expect(text(read)).toContain(marker);
// Clamped to the 65536-byte budget plus the short marker, far below the original.
expect(Buffer.byteLength(structuredBody, 'utf8')).toBeLessThanOrEqual(65536 + 256);
expect(Buffer.byteLength(structuredBody, 'utf8')).toBeLessThan(originalBytes);
expect(structuredBody).toContain(`of ${originalBytes} bytes`);

// The stored file on disk is the full, unclamped original - the marker never touches it.
const onDisk = readFileSync(join(vaultDir, 'big', 'tome.md'), 'utf-8');
expect(Buffer.byteLength(onDisk, 'utf-8')).toBe(originalBytes);
expect(onDisk).not.toContain(marker);
expect(onDisk.endsWith(big.slice(-80))).toBe(true);
} finally {
await stopDaemon(d);
}
});
});
71 changes: 71 additions & 0 deletions e2e/memory-offline.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { assertCliBuilt, createCliMachine } from './helpers.js';
Expand Down Expand Up @@ -91,4 +92,74 @@ test.describe('offline memory CRUD @p0', () => {
m.cleanup();
}
});

test('a write carrying an obvious secret is refused; ordinary prose still writes', async () => {
const m = createCliMachine(OFFLINE);
try {
const vaultDir = join(m.configDir, 'main');
expect((await m.exec(['vault', 'add', 'main', '--local', vaultDir])).code).toBe(0);

// Prose that merely mentions "password" is not a false positive - it writes, giving a HEAD.
const clean = await m.exec([
'memory',
'write',
'ops/note.md',
'--body',
'Change the wifi password before the demo.',
]);
expect(clean.code, clean.stderr).toBe(0);
const head = execFileSync('git', ['-C', vaultDir, 'rev-parse', 'HEAD'], {
encoding: 'utf-8',
}).trim();

// A fake-but-shape-valid AWS key is refused with the canonical message and a non-zero exit.
const secret = await m.exec([
'memory',
'write',
'sec/aws.md',
'--body',
'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
]);
expect(secret.code).not.toBe(0);
expect(secret.stderr + secret.stdout).toContain('Refused: this appears to contain');

// Nothing was persisted: no file, the read fails, and HEAD did not move.
expect(existsSync(join(vaultDir, 'sec', 'aws.md'))).toBe(false);
expect((await m.exec(['memory', 'read', 'sec/aws.md'])).code).not.toBe(0);
expect(
execFileSync('git', ['-C', vaultDir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim()
).toBe(head);
} finally {
m.cleanup();
}
});

test('read clamps an oversized doc for display but the stored file stays whole', async () => {
const m = createCliMachine(OFFLINE);
try {
const vaultDir = join(m.configDir, 'main');
expect((await m.exec(['vault', 'add', 'main', '--local', vaultDir])).code).toBe(0);

// A body over the 64 KB read budget (no digits/secret shapes so the write is accepted);
// passed on argv, comfortably under the OS per-arg limit.
const body = 'durable notes and knowledge kept here forever '.repeat(1600);
const originalBytes = Buffer.byteLength(body, 'utf-8');
expect(originalBytes).toBeGreaterThan(65536);
expect((await m.exec(['memory', 'write', 'big/tome.md', '--body', body])).code).toBe(0);

const read = await m.exec(['memory', 'read', 'big/tome.md']);
expect(read.code, read.stderr).toBe(0);
// The printed body is clamped to the budget plus the marker, well under the original.
expect(read.stdout).toContain('[Truncated for display:');
expect(read.stdout).toContain(`of ${originalBytes} bytes`);
expect(Buffer.byteLength(read.stdout, 'utf-8')).toBeLessThan(originalBytes);

// The stored file on disk is the full, unclamped original - the marker never touches it.
const onDisk = readFileSync(join(vaultDir, 'big', 'tome.md'), 'utf-8');
expect(Buffer.byteLength(onDisk, 'utf-8')).toBe(originalBytes);
expect(onDisk).not.toContain('[Truncated for display:');
} finally {
m.cleanup();
}
});
});
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
"prepublishOnly": "npm run verify"
},
"dependencies": {
"@agentage/memory-core": "^0.1.2",
"@agentage/server-memory": "^0.0.2",
"@agentage/memory-core": "^0.1.3",
"@agentage/server-memory": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.29.0",
"chalk": "latest",
"commander": "latest",
Expand Down