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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "claude-code",
"source": "./plugins/claude-code",
"description": "Persistent semantic memory for Claude Code - user preferences, project context, prior decisions, and codebase facts that survive across sessions.",
"version": "0.1.17",
"version": "0.1.18",
"category": "productivity",
"homepage": "https://docs.atomicstrata.ai/integrations/coding-agents/claude-code",
"license": "Apache-2.0"
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ repository. Human-facing project context lives in `README.md`, `CONTRIBUTING.md`
- No fallback modes. If something fails, fail closed with a clear error instead
of running in a degraded or partially-supported mode.
- Add comments only when they explain non-obvious intent or constraints.
- Cross-cutting controls live at one chokepoint, enumerated and bypass-tested.
When a security/correctness rule must hold for *all* of a category — every
input reaching Postgres, every scoped MCP tool, every memory→model surface —
apply it where those surfaces converge (the query layer, one scope gate, one
shared sanitizer/validator), not replicated per surface. If it must be
replicated, add an enumeration test that fails when a new surface lacks it.
Tests must exercise the adversarial bypass (the encoding, the object key, the
header, the interleaving, the second language) — not just the canonical
example — and validate against the downstream consumer's interpretation (the
resolver, Postgres, the model's tag parser), not your own parser. Per-surface
defense is leaky by construction: one sibling always gets missed.

### Size Limits

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/cli-spec.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"spec_version": "5.0.0",
"package_name": "@atomicmemory/cli",
"package_version": "0.1.3",
"package_version": "0.1.4",
"global_options": [
{
"name": "--json",
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@atomicmemory/cli",
"version": "0.1.3",
"version": "0.1.4",
"description": "AtomicMemory CLI for memory operations, config, status, and agent-friendly JSON output.",
"type": "module",
"publishConfig": {
Expand Down Expand Up @@ -59,7 +59,7 @@
},
"dependencies": {
"@atomicmemory/llmwiki": "^1.0.0",
"@atomicmemory/sdk": "^1.0.2",
"@atomicmemory/sdk": "^1.1.1",
"commander": "^12.1.0",
"ink": "npm:@jrichman/ink@6.6.9",
"react": "^19.2.6",
Expand Down
9 changes: 8 additions & 1 deletion packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [1.1.1] - 2026-06-15

### Added
- Phase 1 migration hardening now packages a deterministic
`dist/db/schema-sha256.json` manifest for the shipped DB schema bytes.
Expand All @@ -20,7 +22,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`pgmigrations`. The existing `status` enum (`up_to_date` / `older_db` /
`newer_db` / `unstamped` / `no_schema`) is unchanged.

### Changed
### Fixed
- Entities API routes (`/v1/entities` list / get / profile / merge / delete) no
longer return 500 errors.

### Security
- Hardened input validation and request handling. Upgrade recommended.
- The published `atomicmemory-core migrate` command now calls the
programmatic migration API directly, so npm installs can run the documented
migration command without the command word being reparsed as a migration
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@atomicmemory/core",
"version": "1.1.0",
"version": "1.1.1",
"description": "Open-source memory engine for AI applications — semantic retrieval, AUDN mutation, and contradiction-safe claim versioning.",
"type": "module",
"license": "Apache-2.0",
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/__tests__/nul-scan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @file Unit tests for the shared NUL-byte scanner (`src/nul-scan.ts`). NUL is
* built via fromCharCode so this source file carries no raw NUL byte.
*/

import { describe, expect, it } from 'vitest';
import { scanForNul, containsNoNul } from '../nul-scan.js';

const NUL = String.fromCharCode(0);

describe('containsNoNul', () => {
it('is false for a string with a NUL and true otherwise', () => {
expect(containsNoNul(`a${NUL}b`)).toBe(false);
expect(containsNoNul('clean')).toBe(true);
});
});

describe('scanForNul', () => {
it('finds a NUL in a top-level string', () => {
expect(scanForNul(`a${NUL}`)).toBe('nul');
});

it('finds a NUL nested in an array', () => {
expect(scanForNul(['ok', ['deeper', `bad${NUL}`]])).toBe('nul');
});

it('finds a NUL in an object VALUE', () => {
expect(scanForNul({ a: { b: `v${NUL}` } })).toBe('nul');
});

it('finds a NUL in an object KEY (reaches a JSONB column)', () => {
expect(scanForNul({ [`k${NUL}`]: 'v' })).toBe('nul');
});

it('skips Buffer values so binary uploads are not treated as text', () => {
expect(scanForNul({ body: Buffer.from([0x01, 0x00, 0x02]) })).toBe('clean');
});

it('returns clean for NUL-free nested data and non-string scalars', () => {
expect(scanForNul({ a: ['x', { b: 1, c: 'y', z: null }] })).toBe('clean');
});

it('returns too-deep past the depth bound instead of recursing unboundedly', () => {
let nested: unknown = 'leaf';
for (let i = 0; i < 50; i += 1) nested = { next: nested };
expect(scanForNul(nested, 10)).toBe('too-deep');
});
});
8 changes: 8 additions & 0 deletions packages/core/src/app/__tests__/runtime-container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ describe('createCoreRuntime', () => {
expect(runtime.services.memory).toBeDefined();
});

it('installs the NUL parameter guard on explicit pool deps', async () => {
const pool = stubPool();
await createCoreRuntime({ pool });
await expect(pool.query('SELECT 1 WHERE user_id=$1', [`u${String.fromCharCode(0)}`])).rejects.toThrow(
/NUL bytes/,
);
});

it('constructs domain-facing stores alongside repos', async () => {
const pool = stubPool();
const runtime = await createCoreRuntime({ pool });
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/app/__tests__/versioned-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,50 @@ describe('createApp /v1 mount coverage', () => {
expect(agentRes.status).toBe(404);
});

// Boundary NUL guards (rejectNulInRequestTarget / rejectNulInBody): a NUL byte
// in user-controlled input must 400 at the edge, never 500 at Postgres.
it('rejects a NUL byte in a query param with 400', async () => {
const res = await fetch(`${booted.baseUrl}/v1/memories/list?user_id=qa%00x`, {
headers: authHeader(),
});
expect(res.status).toBe(400);
});

it('rejects a NUL byte in a JSON body with 400', async () => {
const res = await fetch(`${booted.baseUrl}/v1/agents/trust`, {
method: 'PUT',
headers: { ...authHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_id: TEST_AGENT,
user_id: `qa${String.fromCharCode(0)}x`,
trust_level: 0.5,
}),
});
expect(res.status).toBe(400);
});

it('rejects a percent-encoded NUL in a path segment with 400', async () => {
const res = await fetch(`${booted.baseUrl}/v1/entities/user/qa%00x/profile`, {
headers: authHeader(),
});
expect(res.status).toBe(400);
});

it('rejects a NUL byte in a /v1/documents JSON body with 400 (router owns its parsing)', async () => {
const res = await fetch(`${booted.baseUrl}/v1/documents`, {
method: 'POST',
headers: { ...authHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: TEST_USER,
source_site: 'sdk',
provider: 'p',
external_id: 'e',
display_name: `doc${String.fromCharCode(0)}name`,
}),
});
expect(res.status).toBe(400);
});

it('GET /v1/documents/limits is reachable and reports the runtime config snapshot', async () => {
const res = await fetch(`${booted.baseUrl}/v1/documents/limits`, { headers: authHeader() });
expect(res.status).toBe(200);
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/app/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createEntityRouter } from '../routes/entities.js';
import { MAX_INDEX_TEXT_BYTES } from '../schemas/documents.js';
import { requireBearer } from '../middleware/require-bearer.js';
import { assertedUserGuard } from '../middleware/asserted-user.js';
import { rejectNulInRequestTarget, rejectNulInBody } from '../middleware/reject-nul-bytes.js';
import { CORS_ALLOWED_HEADERS_VALUE } from './cors-headers.js';
import { openApiSpec } from './openapi-spec.js';
import { CORE_CAPABILITIES } from './capabilities-descriptor.js';
Expand Down Expand Up @@ -66,6 +67,14 @@ export function createApp(runtime: CoreRuntime): ReturnType<typeof express> {
next();
});

// Reject NUL bytes (U+0000) in the query string / request target on every
// route. Postgres cannot store `\x00` in text, so an un-rejected `%00` in a
// user_id / identifier becomes a 500 instead of a validated 400. Safe to
// mount globally — the query string and request target are never binary. The
// per-router `rejectNulInBody` below covers JSON bodies; raw/binary bodies
// (storage uploads, document raw index) are intentionally never body-scanned.
app.use(rejectNulInRequestTarget);

// `requireBearer` validates `Authorization: Bearer <CORE_API_KEY>`
// on every SDK-facing `/v1/*` router. Built once and reused so the
// expected-key buffer is captured a single time (timingSafeEqual
Expand Down Expand Up @@ -93,13 +102,15 @@ export function createApp(runtime: CoreRuntime): ReturnType<typeof express> {
'/v1/memories',
auth,
express.json({ limit: DEFAULT_JSON_BODY_LIMIT }),
rejectNulInBody,
assertUser,
memoryRouter,
);
app.use(
'/v1/agents',
auth,
express.json({ limit: DEFAULT_JSON_BODY_LIMIT }),
rejectNulInBody,
assertUser,
createAgentRouter(runtime.repos.trust),
);
Expand Down Expand Up @@ -172,6 +183,7 @@ export function createApp(runtime: CoreRuntime): ReturnType<typeof express> {
'/v1/entities',
auth,
express.json({ limit: DEFAULT_JSON_BODY_LIMIT }),
rejectNulInBody,
createEntityRouter({
pool: runtime.pool,
memory: runtime.repos.memory,
Expand All @@ -188,6 +200,7 @@ export function createApp(runtime: CoreRuntime): ReturnType<typeof express> {
'/v1/admin',
requireBearer(runtime.config.coreAdminApiKey),
express.json({ limit: DEFAULT_JSON_BODY_LIMIT }),
rejectNulInBody,
createAdminRouter({
memory: runtime.repos.memory,
testScopeAllowPattern: runtime.config.coreTestScopeAllowPattern,
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/app/runtime-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { EntityCardsRepository } from '../db/entity-cards-repository.js';
import { EntitySettingsRepository } from '../db/entity-settings-repository.js';
import { ContradictionsRepository } from '../db/contradictions-repository.js';
import { EntityValuesRepository } from '../db/entity-values-repository.js';
import { installNulGuard } from '../db/nul-guard.js';
import { TllRepository } from '../db/repository-tll.js';
import { FirstMentionRepository } from '../db/repository-first-mentions.js';
import { DocumentService } from '../services/document-service.js';
Expand Down Expand Up @@ -271,7 +272,9 @@ export interface CoreRuntimeConfigRouteAdapter {
* Explicit dependency bundle accepted by `createCoreRuntime`.
*
* `pool` is required — the composition root never reaches around to
* import the singleton `pg.Pool` itself.
* import the singleton `pg.Pool` itself. The runtime installs the same
* query-parameter NUL guard on explicit pools that startup installs on the
* singleton pool.
*
* Optional `config` is a composition-time override for isolated harnesses
* such as AtomicBench. It is not a per-request override and should not be
Expand Down Expand Up @@ -325,11 +328,11 @@ export interface CoreRuntime {
* service from an explicit pool. Uses either the module-level config singleton
* or an explicit composition-time config and passes that same object into leaf
* module initializers and MemoryService so the composition root owns the seam.
* No mutation.
* Mutates the supplied pool only to install idempotent query guards.
*/
// fallow-ignore-next-line complexity
export async function createCoreRuntime(deps: CoreRuntimeDeps): Promise<CoreRuntime> {
const { pool } = deps;
const pool = installNulGuard(deps.pool);
const runtimeConfig = deps.config ?? defaultConfig;

// Leaf-module config init. Embedding and LLM modules
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/db/__tests__/migration-backcompat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ import {

const pool = useMigrationTestPool({ beforeEach, afterAll });

const ALLOWED_NEW_TABLES = new Set<string>(['pgmigrations', 'schema_version', 'entity_settings']);
// `entity_edges` (migration 0004) is additive: a brand-new table that touches
// no legacy table. Allowlisting it covers its PK, indexes, CHECK and the FK to
// memories, since isAllowedNewIndex/isAllowedNewConstraint pass any object whose
// table is a new allowed table.
const ALLOWED_NEW_TABLES = new Set<string>(['pgmigrations', 'schema_version', 'entity_settings', 'entity_edges']);
const ALLOWED_NEW_INDEXES = new Set([
// schema_version primary key is auto-generated by Postgres on `applied_at`.
'schema_version_pkey',
// Explicit applied_at DESC index per the plan §1 (schema_version table).
'idx_schema_version_applied_at',
// Migration 0003 added this partial-unique index to the (legacy) memories
// table to enforce verbatim-ingest idempotency. It is additive (a new index,
// no column/type change) but lands on a pre-existing table, so it must be
// named here. It was not allowlisted when 0003 shipped, which left this
// backcompat test red on main — see PR notes. Additive and safe.
'uniq_memories_user_external_id_live',
]);
const ALLOWED_NEW_CHECK_CONSTRAINTS = new Set<string>();
const ALLOWED_NEW_FOREIGN_KEYS = new Set<string>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
applyLegacySchema,
useMigrationTestPool,
} from './migration-test-helpers.js';
import { listMigrationFilenames } from '../migration-schema.js';

const pool = useMigrationTestPool({ beforeEach, afterAll });

Expand All @@ -42,7 +43,12 @@ describe('Phase 2 — baseline schema validator', () => {

await expect(migrate({ pool })).resolves.toBeDefined();

expect(await pgmigrationsCount()).toBe(2); // baseline + 0002_entity_settings
// Every shipped migration is recorded in pgmigrations: the baseline is
// stamped on the pre-phase-2 cutover, then each post-baseline file runs.
// Asserting against the live count keeps this robust as migrations are
// added (it was hardcoded to 2 and went stale once 0002_…index / 0003 / 0004
// landed).
expect(await pgmigrationsCount()).toBe(listMigrationFilenames().length);
expect(await schemaVersionCount()).toBe(1);
});

Expand Down
Loading
Loading