From 1511b99588e8c2a32d55a9831849ed7774cac1d9 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Mon, 6 Jul 2026 11:50:57 +0200 Subject: [PATCH] chore(deps): engine 0.1.3/0.0.3 - assert secret refusal, read clamp --- e2e/mcp-contract.test.ts | 96 ++++++++++++++++++++++++++++++++++---- e2e/memory-offline.test.ts | 71 ++++++++++++++++++++++++++++ package-lock.json | 18 +++---- package.json | 4 +- 4 files changed, 169 insertions(+), 20 deletions(-) diff --git a/e2e/mcp-contract.test.ts b/e2e/mcp-contract.test.ts index cca3711..6455bd3 100644 --- a/e2e/mcp-contract.test.ts +++ b/e2e/mcp-contract.test.ts @@ -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, @/ 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 }>; @@ -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); + } + }); }); diff --git a/e2e/memory-offline.test.ts b/e2e/memory-offline.test.ts index 46f1570..a8f99cc 100644 --- a/e2e/memory-offline.test.ts +++ b/e2e/memory-offline.test.ts @@ -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'; @@ -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(); + } + }); }); diff --git a/package-lock.json b/package-lock.json index 54095e2..228a716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.0.3", "license": "MIT", "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", @@ -39,9 +39,9 @@ } }, "node_modules/@agentage/memory-core": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@agentage/memory-core/-/memory-core-0.1.2.tgz", - "integrity": "sha512-bnUjYcDpvzzPfJMY1ZZ3yOHAdAIku0Ez9f308QjL6Uy36EjllRUPYyfVZRjnXbn4mPp6GMlCCo4wFDEIcxqkFA==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@agentage/memory-core/-/memory-core-0.1.3.tgz", + "integrity": "sha512-BFzh/VoP5zm9j7CwazCRJRyqHT6PzImbaDqmKi8Iay3QX6TKYmWCIg1pXxy02yuJdSou4NR6FX5r8ezVSmBhhA==", "license": "MIT", "dependencies": { "yaml": "^2.7.0", @@ -53,12 +53,12 @@ } }, "node_modules/@agentage/server-memory": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@agentage/server-memory/-/server-memory-0.0.2.tgz", - "integrity": "sha512-YwwWUzTWxWpQlAboSxsEB2QkUPkZq7H4Os+i9EJvGhoU7l1mRlWwc3Pj2KFe0AjvzaJNNSi4pgTE2y1AoDxuyA==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@agentage/server-memory/-/server-memory-0.0.3.tgz", + "integrity": "sha512-t+xfLcxoeNly/Vo4baelKP27R2o/qaKFYWuPMPDusWPn4uVhtfE9ph7sCxxR154FpTovoTSa3HarfNgtJ6MrDg==", "license": "MIT", "dependencies": { - "@agentage/memory-core": "^0.1.0", + "@agentage/memory-core": "^0.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, diff --git a/package.json b/package.json index 8233127..22cfade 100644 --- a/package.json +++ b/package.json @@ -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",