Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
11d6632
feat(store): add a test seam to the store factory
beardthelion Sep 3, 2026
b53887e
fix(memory): make a crash mid-commit leave a consistent namespace
beardthelion Sep 3, 2026
1dadcd6
feat(memory): refuse a write whose base is out of date
beardthelion Sep 3, 2026
3d46329
feat(store): let the store declare whether delete erases
beardthelion Sep 3, 2026
2bf3b8f
fix(health): report liveness only, and probe the store at startup
beardthelion Sep 3, 2026
fe97dd5
feat(observability): log every refusal with a closed field set
beardthelion Sep 3, 2026
9fed0ad
refactor: consolidate Phase 0 duplication and correct stale docs
beardthelion Sep 3, 2026
86207f3
fix(memory): make reclaim non-fatal, complete, and actually tested
beardthelion Sep 3, 2026
4f9f450
fix(memory): stop one bad manifest hash from failing a whole read
beardthelion Sep 3, 2026
30228a7
fix(api): close the remaining review findings on the write contract
beardthelion Sep 4, 2026
341dfcb
docs: describe the contract this branch actually serves
beardthelion Sep 4, 2026
2358ce6
test: cover the whole stack end to end, and close the last gaps
beardthelion Sep 4, 2026
795cf9c
feat(client): send a write precondition and raise typed refusals
beardthelion Sep 4, 2026
dda4038
feat(mcp): say which memory system a fact belongs in
beardthelion Sep 4, 2026
eae08d3
feat(client): generate the setup card without touching the passphrase
beardthelion Sep 4, 2026
f9d36d7
feat(mcp): tell the model what was refused and what to do next
beardthelion Sep 4, 2026
a1ce501
feat(mcp): refuse to start on a configuration that would corrupt memory
beardthelion Sep 4, 2026
3f0ddb1
fix(client): stop a no-op push asking the server twice
beardthelion Sep 4, 2026
7ace5f1
fix(client): stop the write precondition failing open
beardthelion Sep 4, 2026
22ea704
fix(mcp): stop the startup check passing on a namespace it never read
beardthelion Sep 4, 2026
be81bc2
fix(mcp): stop telling the model a refused write was saved
beardthelion Sep 4, 2026
46c0e86
fix(mcp): make the guide and the pasted card agree on where memory goes
beardthelion Sep 4, 2026
0e4b489
feat(api): serve one entry without serving the whole namespace
beardthelion Sep 4, 2026
9a7a1cc
fix(client): bound every wait, every cache, and read one entry at a time
beardthelion Sep 4, 2026
fe69fa6
perf(mcp): prove the passphrase with one entry instead of the namespace
beardthelion Sep 4, 2026
433b1be
fix(client): refuse a setup card that cannot work
beardthelion Sep 4, 2026
1a7aa5e
fix(mcp): stop reporting lost memory as an empty namespace
beardthelion Sep 4, 2026
65e7f5e
docs: describe the routes, command and knob this phase added
beardthelion Sep 4, 2026
4118d24
fix(mcp): sanitize every string the server chooses, not just one of them
beardthelion Sep 4, 2026
fb1f55b
feat(build): publish something Node can actually run
beardthelion Sep 4, 2026
f4ce6b8
test(ci): read the artifact a consumer installs, not the working tree
beardthelion Sep 4, 2026
575f578
feat(release): ship binaries that serve the real guide, on an image t…
beardthelion Sep 4, 2026
98dd2f2
docs: describe integrations that exist
beardthelion Sep 4, 2026
e079366
docs: correct what the standalone binaries actually need
beardthelion Sep 5, 2026
4b0560e
fix(build): emit client entries Node can actually load
beardthelion Sep 5, 2026
19a2335
feat(mcp): read the passphrase from a file, not only the environment
beardthelion Sep 5, 2026
ef18824
fix(mcp): name an unexpanded passphrase-file reference as one
beardthelion Sep 5, 2026
b41746f
feat(cli): report the version, and check the bin runs under Node
beardthelion Sep 5, 2026
07c425b
feat(store): keyed naming, at-rest wrapping and path mapping for a no…
beardthelion Sep 5, 2026
7ff7d70
feat(store): add the gitlawb node storage driver
beardthelion Sep 5, 2026
3d0396a
feat(store): tell the truth about deletion on a retaining store
beardthelion Sep 5, 2026
f9ab719
docs: name the node store, which is no longer planned
beardthelion Sep 5, 2026
f124159
feat(store): gate node storage on consent, and make its secret rotatable
beardthelion Sep 5, 2026
fdb5e02
feat(mcp): refuse a non-blocking scan where deletion cannot erase
beardthelion Sep 5, 2026
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
55 changes: 55 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ PORT=8080
# ─── Storage driver ─────────────────────────────────────────────────────
# fs — filesystem (zero-config; best for self-host / local dev)
# s3 — S3-compatible (Tigris, AWS, R2, minio; best for hosted)
# node — gitlawb node, one private repo per namespace (see the block below)
STORE=fs

# fs driver
Expand Down Expand Up @@ -59,4 +60,58 @@ RATE_LIMIT_BURST=240
# Default namespace the MCP server reads/writes when a tool call omits one.
# MEMLAWB_NAMESPACE=user:me
# Secret-scan policy applied to plaintext before encryption: block|warn|off.
# The passphrase may come from a file instead, and the file wins when both are
# set. A host that launches the MCP server spreads its own environment into
# every stdio child, so a passphrase exported for memlawb is readable by every
# other MCP server; a path is not.
# MEMLAWB_PASSPHRASE_FILE=/run/secrets/memlawb-passphrase
# MEMLAWB_SCAN=block
# How long any single request may take before the client gives up.
# MEMLAWB_TIMEOUT_MS=120000

# ── Auth ────────────────────────────────────────────────────────────────
# Leave ALLOW_UNAUTHENTICATED=true for single-user self-host. For multi-tenant,
# set it false and supply ONE of the two below.
# "owner:key" pairs, comma separated. Self-host without Supabase.
# STATIC_API_KEYS=alice:sk_alice,bob:sk_bob
# MEMLAWB_SUPABASE_URL=
# MEMLAWB_SUPABASE_SECRET_KEY=

# ── S3-compatible store (STORE=s3) ──────────────────────────────────────
# Requires the Bun runtime: the adapter uses Bun's S3 client and has no Node
# equivalent, so STORE=s3 does not work from the Node build.
# S3_BUCKET=
# S3_ENDPOINT=
# S3_REGION=auto
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=

# ── Node storage driver (STORE=node) ────────────────────────────────────
# Stores ciphertext in per-namespace private repos on a gitlawb node. Needs
# git, gl and git-remote-gitlawb on PATH; the shipped image carries them.
# GITLAWB_NODE_URL=http://localhost:7545
#
# Node storage will not start without this acknowledgement, because all three of
# the following are irreversible and none are visible from STORE=node alone:
# 1. Deleting an entry does not erase it. Prior ciphertext stays in git history.
# 2. Any pin or anchor already taken is permanent and cannot be retracted.
# 3. The only real erasure is destroying the passphrase, and that destroys
# every namespace that owner holds, not the one entry you meant to remove.
# GITLAWB_NODE_ACKNOWLEDGE=true
# Path to the signing identity. The driver never reads the key itself; it
# passes the path to the git remote helper, which signs the push.
# GITLAWB_NODE_IDENTITY_PATH=/run/secrets/gitlawb-identity
#
# !! The store secret is NOT rotatable in place. !!
# It derives every repo name and the at-rest wrapping key, so changing it
# re-paths and re-wraps every namespace and needs the migration to run.
# Losing it orphans every node-stored namespace permanently: neither the repo
# name nor the wrapping key can be recovered from anything else. Disclosing it
# exposes manifest metadata (entry keys, sizes, timestamps) and confirms which
# namespaces exist; entry bodies stay client-encrypted and are not affected.
# Inject it at runtime, never bake it into an image layer, and custody it
# separately from the signing identity above. Back it up, and restore from that
# backup once to prove it works: an untested backup of this value is not a
# backup. To rotate, run scripts/node-store-migrate.ts, which re-paths and
# re-wraps each namespace; there is no in-place rotation.
# GITLAWB_NODE_STORE_SECRET=
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,45 @@ jobs:
run: bun run type-check
- name: Test
run: bun test
- name: Build
run: bun run build

# The working tree always runs from source under Bun, so every packaging
# failure is invisible to the job above: it was green while Node could not
# import this package at all. This job is the only thing that reads the
# artifact a consumer actually installs.
package:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# 20 is the declared engines floor, and is otherwise never exercised;
# 22 is what a consumer most likely has today.
node-version: ['20', '22']
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.2'
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: bun install --frozen-lockfile
- name: Packed tarball, installed and driven under Node
run: bun run test:package

# The node storage driver's runtime dependencies are external binaries this
# repo does not build, so nothing in the suite above can tell whether they are
# present or at the version the Dockerfile pins. This job reads the image that
# would actually ship.
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.2'
- name: Build the image
run: docker build -t memlawb:test .
- name: Both node binaries present and at the pinned version
run: bun run test:image
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ jobs:
bun-version: '1.2'
- run: bun install --frozen-lockfile
- run: bunx biome ci && bun run type-check && bun test
# `npm publish` fires `prepack`, which builds, so `dist/` reaches the
# tarball without an explicit step here (verified with `npm publish
# --dry-run` after deleting `dist/`). This runs the packed-tarball check
# anyway: publishing a package no consumer can install is the one failure
# every in-repo gate is structurally blind to, and it is unrecoverable
# once a version is on the registry.
- name: Verify the artifact a consumer will install
run: bun run test:package

# npm
- uses: actions/setup-node@v4
Expand All @@ -52,6 +60,16 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

# Standalone binaries, for a machine with neither Bun nor Node. Bun
# cross-compiles every target from one runner, so this is a single job
# rather than a matrix of them.
- name: Build standalone binaries
run: bun run build:binaries
- name: Attach binaries to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ needs.release-please.outputs.tag_name }}" binaries/* --clobber

# GHCR image
- uses: docker/login-action@v3
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ node_modules/
dist/
data/
.memlawb-key
binaries/
37 changes: 36 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
FROM oven/bun:1.1-alpine
# The node storage driver (STORE=node) shells out rather than speaking git
# itself: writes go through `git push` over the gitlawb remote helper, which is
# what signs them. So git, gl and git-remote-gitlawb are runtime dependencies of
# that driver, not build tools, and they are absent from a bun-alpine base. A
# driver that discovers that at the first save has already accepted the write.
#
# Pinned by version rather than fetched latest, because memlawb builds none of
# these and the signing helper is the last component that should move without
# someone choosing to move it. npm verifies the integrity of both hops: the
# wrapper's postinstall copies the binaries out of a platform package that
# @gitlawb/gl@<version> pins to that exact version, so the pin reaches the
# binaries and not just the wrapper around them.
#
# node and npm exist only to run that install, so it happens in a stage that is
# thrown away and only the two binaries are copied forward.
FROM oven/bun:1.2-alpine AS glbin
ARG GL_VERSION=0.7.1
RUN apk add --no-cache nodejs npm \
&& npm install -g "@gitlawb/gl@${GL_VERSION}"
# npm links a package's bins before postinstall runs, and this package ships an
# empty bin/ that postinstall fills, so npm silently creates no symlinks. Copy
# from the package directory, not from a bin/ that npm never linked.

FROM oven/bun:1.2-alpine
WORKDIR /app

ARG GL_VERSION=0.7.1
RUN apk add --no-cache git
COPY --from=glbin /usr/local/lib/node_modules/@gitlawb/gl/bin/gl /usr/local/bin/gl
COPY --from=glbin /usr/local/lib/node_modules/@gitlawb/gl/bin/git-remote-gitlawb /usr/local/bin/git-remote-gitlawb
# Assert the pin, not mere presence: a binary that runs but is the wrong version
# is exactly what pinning exists to prevent, and `command -v` cannot see it.
# Runs in the final stage so it checks what ships, not what the builder had.
RUN test "$(gl --version)" = "gl ${GL_VERSION}" \
&& test "$(git-remote-gitlawb --version)" = "git-remote-gitlawb ${GL_VERSION}" \
&& git --version >/dev/null

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

COPY src ./src
COPY client ./client
COPY skills ./skills
COPY tsconfig.json ./

ENV NODE_ENV=production
Expand Down
10 changes: 6 additions & 4 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ A later optional "searchable tier" can do server-side encrypted search (see §7)
```
agent (openclaude / Claude Code / Cursor / opencode / SDK)
│
├─ Path A native sync → speaks /team_memory contract (openclaude, ~0 changes)
├─ Path A native sync → speaks the /api/memory contract directly (not built)
├─ Path B MCP tools → memory_save / recall / search / list (universal)
└─ Path C local daemon → mirrors ~/.claude memdir to cloud (tool-agnostic)
│
Expand All @@ -72,8 +72,10 @@ Namespace = unit of sharing/scoping. Examples: `user:<id>` (private),
`repo:<owner>/<name>` (team), `agent:<id>`. Each namespace holds entries keyed by path
(`MEMORY.md`, `feedback/x.md`, ...), mirroring the memdir layout.

**Sync API (Path A — openclaude drop-in).** Mirror the existing contract so openclaude
works by changing a base URL:
**Sync API (Path A).** The HTTP contract a caller can speak directly. Note this is
not an openclaude drop-in: openclaude has no memlawb integration today, and the
one that is planned goes through the MCP tools (Path B) rather than this route.
The routes are:

- `GET /api/memory/:namespace` → full data + entryChecksums
- `GET /api/memory/:namespace?view=hashes`→ metadata + per-key checksums only
Expand Down Expand Up @@ -105,7 +107,7 @@ so the remote memlawb server still only sees ciphertext.
- Create `Gitlawb/memlawb` OSS repo (MIT, release-please + GHCR — match node).
- Lock crypto choices, API schema (Zod), namespace/ACL model.

**Phase 1 — Hosted MVP, openclaude drop-in (highest leverage).**
**Phase 1 — Hosted MVP (highest leverage).**
- memlawb server (Bun/Fly): auth, `/api/memory` sync contract, Postgres index,
Tigris/S3 private BlobStore. Server is crypto-blind (stores ciphertext).
- openclaude client change: configurable team-memory base URL + a thin E2E-encrypt
Expand Down
119 changes: 114 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ agent / CLI / MCP
▼
memlawb server (crypto-blind — only ever sees ciphertext)
▼
BlobStore: fs | s3 (Tigris/R2/AWS) | ipfs/git (planned)
BlobStore: fs | s3 (Tigris/R2/AWS) | node (a private repo per namespace on a gitlawb node)
```

The server is **crypto-blind**: it does delta sync, dedup, and storage entirely
Expand All @@ -91,13 +91,55 @@ bun run bin/memlawb.ts push ./my-memories user:me # encrypt + upload
bun run bin/memlawb.ts pull ./restored user:me # download + decrypt
```

To configure an agent rather than sync a directory, generate the block to paste:

```bash
# prints the MCP config block, plus a fresh passphrase shown once
MEMLAWB_API_KEY=<your key> bun run bin/memlawb.ts setup <owner> https://memory.gitlawb.com
```

The passphrase is generated locally and never leaves the machine: it is printed
for you to store, and the block carries a placeholder rather than the value. The
URL must be `https` unless it points at a loopback host.

That block is a public interface, not example output. The consumer integrations
point back at it rather than restating it, and the packaging test asserts that
every environment variable it emits is one the server still reads, so a rename
here fails in this repository instead of at spawn time in another one.

What lands on the server is ciphertext — `grep` your data dir for any plaintext
and you'll find nothing.

> ⚠️ **Zero-knowledge means no recovery.** If you lose your passphrase, the host
> cannot help you — the data is unreadable to everyone but the key holder. Back
> up your passphrase.

## Install

Three ways in, depending on what the machine already has.

```bash
npm install @gitlawb/memlawb # Node 20+, or any npm-compatible installer
bun add @gitlawb/memlawb # Bun resolves the TypeScript source directly
```

For a machine with neither runtime, each release attaches standalone binaries
with a `SHA256SUMS` file. The runtime is baked in, so they are large (tens of
MB). The glibc builds need nothing installed, verified on a stock `debian:12-slim`
with neither Bun nor Node present. The `-musl` builds are the exception and do
have a prerequisite: they link against `libstdc++` and `libgcc`, so on a bare
Alpine they fail to load until you `apk add libstdc++`.

```bash
curl -LO https://github.com/Gitlawb/memlawb/releases/latest/download/memlawb-linux-x64
chmod +x memlawb-linux-x64 && ./memlawb-linux-x64 setup <owner>
```

Under Node the client commands all work: `push`, `pull`, `setup` and `mcp`.
`memlawb serve` needs Bun, because the server uses Bun's HTTP and S3 APIs, and
says so rather than failing obscurely. Use `bunx @gitlawb/memlawb serve` or the
container image for self-hosting.

## Use as a library

```ts
Expand Down Expand Up @@ -167,21 +209,76 @@ All bodies are ciphertext; the server validates sizes/hashes without decrypting.

| Method | Route | Purpose |
|---|---|---|
| `GET` | `/health` | liveness + active store |
| `GET` | `/health` | liveness |
| `GET` | `/api/memory/:ns` | full data (ciphertext entries + checksums) |
| `GET` | `/api/memory/:ns?view=hashes` | per-key checksums only (for delta) |
| `PUT` | `/api/memory/:ns` | delta upsert `{ entries, deletions? }` |
| `DELETE` | `/api/memory/:ns?key=<entryKey>` | remove one entry |
| `GET` | `/api/memory/:ns?view=entry&key=<entryKey>` | one entry's ciphertext, without fetching the rest |
| `PUT` | `/api/memory/:ns` | delta upsert `{ entries, deletions?, base? }` |
| `DELETE` | `/api/memory/:ns?key=<entryKey>[&base=sha256:<hex>]` | remove one entry |

A *namespace* (`user:me`, `repo:owner/name`, `agent:intern`) is the unit of
scoping. Entry keys mirror the memdir layout (`MEMORY.md`, `feedback/x.md`).

The hashes view also reports `supports` (server capabilities a client can rely
on) and `erasure` (whether this deployment's store actually removes bytes on
delete); write responses carry `erasure` too.

The entry view answers with that key's base64 ciphertext and checksum, byte for
byte what the full read returns under the same key. It exists so a caller can
check one entry without downloading a namespace, and it distinguishes three
answers a single status would blur: `404 empty` (no such namespace),
`404 entry_not_found` (the namespace exists, the key does not), and
`503 entry_unreadable` (the manifest names the key and the store cannot produce
its body).

A write may be sent with a `base` mapping each touched key to the ciphertext
hash the client last saw, or `null` to assert the key must not exist. Only reads
a caller asked for fill that map, so the guarantee belongs to a long-lived
client, which in practice means the MCP server across a session. `memlawb push`
builds a fresh client per invocation and has read nothing, so it sends no base
and its writes are unconditional by design. The server
answers `409 stale_base_version` when that disagrees with its manifest, naming
the keys that moved. A write with no `base` is unconditional, so a client that
predates this keeps working.

`base` is an optional precondition: a map of entry key to the ciphertext hash
the caller believes that key holds, or `null` for "should not exist". A request
that disagrees with the stored manifest is refused with `409 stale_base_version`
and a `details.conflicts` map naming what each key actually holds. Omitting
`base` writes unconditionally, which is what a client that has not adopted it
still does. This guards the caller's own turn, not the moment between its last
read and its write, which is why the check is per entry rather than a namespace
version. A namespace whose manifest cannot be parsed answers
`503 manifest_unreadable` rather than appearing empty.

## Configuration

See [`.env.example`](./.env.example). Key knobs: `STORE` (`fs`|`s3`),
See [`.env.example`](./.env.example). Key knobs: `STORE` (`fs`|`s3`|`node`),
`ALLOW_UNAUTHENTICATED`, `STATIC_API_KEYS` / Supabase auth, and per-namespace
size/count limits.

### Node storage

`STORE=node` keeps each namespace in its own private repo on a gitlawb node. It
needs `git`, `gl` and `git-remote-gitlawb` on `PATH`; the published image carries
them. Two things to understand before enabling it, and the server will not start
until you acknowledge them with `GITLAWB_NODE_ACKNOWLEDGE=true`:

**Deletion is not erasure.** Removing an entry takes it out of the namespace, but
prior ciphertext stays in repository history, and any IPFS pin or Arweave anchor
already taken is permanent. The only real erasure is destroying the passphrase,
which destroys every namespace that owner holds rather than the one entry. The
`memory_delete` tool says so on this store, and the client refuses a non-blocking
secret scan against it, since a warned-through credential could not be removed.

**The store secret cannot be rotated in place.** It derives every repo name and
the at-rest wrapping key, so a new secret is a new location. Rotate with
`scripts/node-store-migrate.ts`, which re-paths and re-wraps each namespace and
copies entry blobs byte for byte (they are client-encrypted; the server cannot
re-encrypt them). Losing the secret orphans every node-stored namespace
permanently, so back it up, custody it separately from the signing identity, and
restore from that backup once to prove the backup works.

## Security model

- **Encryption:** AES-256-GCM; key = `scrypt(passphrase, salt=sha256("memlawb:"+namespace))`.
Expand All @@ -192,6 +289,18 @@ size/count limits.
- **Defense in depth:** a client-side secret scanner runs before encryption and
(by default) blocks uploads containing live-looking credentials. Override with
`MEMLAWB_SCAN=warn|off`.
- **Passphrase custody:** the MCP server accepts `MEMLAWB_PASSPHRASE_FILE`, a
path, as well as the value in `MEMLAWB_PASSPHRASE`, and the file wins when
both are set. Agent hosts commonly spread their own environment into every
stdio server they launch, so a passphrase exported for memlawb is readable by
all of them; a path is not, and a file can carry permissions an environment
cannot.
- **Startup refusal:** the MCP server checks its configuration against the
pinned namespace before serving a tool, and exits rather than start on one
that would corrupt stored memory: unexpanded template text in a secret, a
passphrase that cannot decrypt what is stored, a rejected key, an unauthorized
namespace, or a scan mode it does not recognize. Every wait is bounded;
`MEMLAWB_TIMEOUT_MS` raises the limit on a slow link.
- **Tenancy:** each API key maps to an owner who controls exactly their own
`user:<owner>` namespace subtree (strict segment match, no substring escapes);
per-account quotas and per-owner rate limits are enforced server-side.
Expand Down
Loading
Loading