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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ coverage/
.omc/
*.timestamp-*.mjs
.env
*.tsbuildinfo

# Internal development artifacts not suitable for the public repo
docs/superpowers/
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Guidance for coding agents working in this repository.
- Grimoire is a local-first bookmark manager.
- The frontend is a React 18 + Vite + TypeScript SPA under `src/`.
- The background daemon is a Bun service under `daemon/` that listens on `127.0.0.1:3210`, stores data in SQLite, and exposes the REST API used by the frontend.
- Current release: `1.0.0`.
- Current release: `1.1.0`.

## First Reads

Expand Down
4 changes: 2 additions & 2 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -2456,7 +2456,7 @@ Request:
curl -X POST http://127.0.0.1:3210/mcp \
-H "Authorization: Bearer limp_it_example" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.1.0"}}}'
```

Response:
Expand All @@ -2473,7 +2473,7 @@ Content-Type: application/json
"capabilities": {},
"serverInfo": {
"name": "grimoire",
"version": "1.0.0"
"version": "1.1.0"
}
}
}
Expand Down
59 changes: 57 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,70 @@ All notable changes to Grimoire will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [1.1.0] - 2026-08-12

### Added
- AI provider model picker in Settings: a searchable OpenRouter model combobox
with a Free models only filter and custom (typed) model entry, backed by a
new `GET /settings/ai-models` catalog endpoint (SSRF-guarded, size-capped).
- Route-level error boundary so malformed API data cannot blank the app.

### Fixed
- OpenRouter default model normalized to `openai/gpt-latest` (the `~` fallback
prefix is dropped and stored values are stripped on load).
- Concurrent same-URL bookmark creates return the existing bookmark instead of
failing with a raw error; trashed/archived duplicates get a clear 409.
- Mutations await daemon confirmation before success toasts, dialog close, and
undo actions; failures surface as destructive toasts.
- Bulk category moves use category IDs, and list/search filters prefer
`category_id` over the category name.
- The bookmarklet embeds the token and daemon URL via `JSON.stringify` so
quotes or backslashes cannot break the generated script.

### Security
- SSRF hardening: outbound page fetches and update checks follow redirects
manually and validate every hop; private hosts, embedded credentials, and
non-http(s) schemes are rejected at bookmarks, capture, MCP, and import.
- Remote upgrades require HTTPS release bases and detached signatures by
default, with optional signing-key fingerprint pinning and download size
caps; `--allow-unsigned` / `LITTLEIMP_ALLOW_UNSIGNED_UPGRADE=1` is an
explicit escape hatch.
- The daemon refuses non-loopback binds unless running in a container or
`LITTLEIMP_ALLOW_NON_LOOPBACK_BIND=1` is set explicitly.
- Mutating HTTP routes get default JSON body limits; restore no longer accepts
`allow_unsafe_no_checksum` over the HTTP API.

## [1.0.0] - 2026-07-20

### Changed
- General-availability release. The `littleimp`/`littleimpd` daemon and CLI
tooling names are stable on-disk identifiers and remain unchanged.

## [Unreleased]
## [1.0.1] - 2026-08-03

### Fixed
- The one-command installer now defaults to the current release version instead
of the stale `0.1.0-beta`.
- GitHub issue and repository extraction now accepts `www.github.com` URLs and
keeps stripping a trailing `.git` from repository names.
- Outbound HTTP requests identify as `Grimoire/<version>` (derived from the
packaged version) instead of the stale `LittleImp/0.0`.
- The sqlite-vec index is rebuilt in a single atomic transaction at daemon start.
- E2E mocks, test fixtures, and release docs aligned with the 1.0.x version
identity and the `goniszewski/grimoire` repository.

## [1.0.1] - 2026-08-03

No unreleased changes.
### Fixed
- The one-command installer now defaults to the current release version instead
of the stale `0.1.0-beta`.
- GitHub issue and repository extraction now accepts `www.github.com` URLs and
keeps stripping a trailing `.git` from repository names.
- Outbound HTTP requests identify as `Grimoire/<version>` (derived from the
packaged version) instead of the stale `LittleImp/0.0`.
- The sqlite-vec index is rebuilt in a single atomic transaction at daemon start.
- E2E mocks, test fixtures, and release docs aligned with the 1.0.x version
identity and the `goniszewski/grimoire` repository.

## [0.1.0-beta] - 2026-05-28

Expand Down
8 changes: 4 additions & 4 deletions Formula/grimoire.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ class Grimoire < Formula
depends_on "oven-sh/bun/bun"

if OS.mac?
url "https://github.com/goniszewski/grimoire/releases/download/v1.0.0/little-imp-1.0.0-macos.tar.gz"
sha256 "000000000000000000000000000000000000000000000000000000000000000a"
url "https://github.com/goniszewski/grimoire/releases/download/v1.1.0/little-imp-1.1.0-macos.tar.gz"
sha256 "f9435a3d956bf47aaa7b724319e49e41699b51d462d8474d91eaf5ca1f325cd2"
elsif OS.linux?
url "https://github.com/goniszewski/grimoire/releases/download/v1.0.0/little-imp-1.0.0-linux.tar.gz"
sha256 "000000000000000000000000000000000000000000000000000000000000000b"
url "https://github.com/goniszewski/grimoire/releases/download/v1.1.0/little-imp-1.1.0-linux.tar.gz"
sha256 "8b903ee96aa5ea9fa2edcec223c969c10e5e2231b1bfe4e7ad442be31d295b0c"
end

def install
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<br>

[![Quality Gates](https://github.com/goniszewski/grimoire/actions/workflows/quality.yml/badge.svg?branch=main)](https://github.com/goniszewski/grimoire/actions/workflows/quality.yml)
![Release target](https://img.shields.io/badge/release-1.0.0-7c3aed)
![Release target](https://img.shields.io/badge/release-1.1.0-7c3aed)
![Bun 1.x](https://img.shields.io/badge/Bun-1.x-black)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)

Expand Down Expand Up @@ -316,7 +316,19 @@ Then rerun the normal commands.

## Documentation

Guides in this repo (also on the web at **[goniszewski.com/grimoire](https://goniszewski.com/grimoire/)**):

- [Docs index](./docs/README.md)
- [Introduction](./docs/01-intro.md)
- [Quick start](./docs/02-quick-start.md)
- [Using Grimoire](./docs/03-using-grimoire.md)
- [Development](./docs/04-development.md)
- [Install without Docker](./docs/05-install-without-docker.md)
- [Remote access](./docs/06-remote-access.md)
- [FAQ](./docs/faq.md)

Reference and engineering:

- [API Reference](./API.md)
- [API Contract](./docs/api-contract.json)
- [Project Overview](./docs/overview.md)
Expand Down
9 changes: 5 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

| Version | Supported |
| --- | --- |
| `1.0.0` | Yes |
| `1.1.0` | Yes |
| `1.0.x` | No |
| `0.1.0-beta` | No |

## Reporting A Vulnerability
Expand All @@ -29,7 +30,7 @@ Please include:

## Security Boundary Summary

Grimoire is local-first, single-user, and loopback-first for `1.0.0`.
Grimoire is local-first, single-user, and loopback-first for `1.1.0`.

- Native daemon default: `127.0.0.1:3210`.
- Docker host port default: `127.0.0.1:3210:3210`.
Expand Down Expand Up @@ -122,7 +123,7 @@ claimed while unauthenticated release URLs return `404`.
- Any local process that can reach `127.0.0.1:3210` can call unprotected REST
routes. This is part of the local-first trust model.
- Grimoire is not designed for multi-user hosts or shared public servers in
`1.0.0`.
`1.1.0`.
- DNS rebinding is not fully mitigated and is accepted only because the daemon
is loopback-only.
- Content extraction fetches user-supplied public URLs. Protections reduce
Expand Down Expand Up @@ -159,7 +160,7 @@ When contributing:

## Future Security Work

These are not shipped in `1.0.0`:
These are not shipped in `1.1.0`:

- Optional authentication for future public or multi-user modes.
- Per-client rate limiting for authenticated non-local deployment modes.
Expand Down
2 changes: 1 addition & 1 deletion daemon/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "littleimpd",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"bin": {
Expand Down
12 changes: 7 additions & 5 deletions daemon/src/ai/models-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { log } from "../logger.js";
import { normalizeHttpsBaseUrl } from "../lib/base-url.js";
import { isPrivateHost } from "../lib/network.js";
import { fetchFollowingSafeRedirects } from "../lib/safe-fetch.js";

/**
* Model catalog fetching for AI providers.
Expand Down Expand Up @@ -88,11 +89,12 @@
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal, redirect: "follow" });
// SSRF guard: reject redirect chains that land on private hosts. Like
// pipeline/fetcher.ts this only inspects the final hop; intermediate hops
// are the same accepted risk documented in lib/network.ts.
if (res.redirected && isPrivateHost(new URL(res.url).hostname)) {
// Validate every redirect target before following it. Automatic redirect
// handling could contact a private intermediate host before this function
// gets a chance to inspect the final response.
const res = await fetchFollowingSafeRedirects(url, { signal: controller.signal });
const finalUrl = res.url || url;
if (isPrivateHost(new URL(finalUrl).hostname)) {
res.body?.cancel();
throw new Error("Model catalog redirected to a private host");
}
Expand Down Expand Up @@ -130,7 +132,7 @@
const json = await fetchJsonWithTimeout(url);
const rawModels = json.data;
if (!Array.isArray(rawModels)) {
throw new Error("Model catalog response is missing the data array");

Check warning on line 135 in daemon/src/ai/models-catalog.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`new Error()` is too unspecific for a type check. Use `new TypeError()` instead.

See more on https://sonarcloud.io/project/issues?id=goniszewski_grimoire&issues=AaAGcRvRL5ldg3JYHEqI&open=AaAGcRvRL5ldg3JYHEqI&pullRequest=207
}
const models: AiModelInfo[] = [];
for (const raw of rawModels) {
Expand Down
4 changes: 2 additions & 2 deletions daemon/src/api/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3105,7 +3105,7 @@ export const apiContract = {
{
title: "Call the MCP endpoint",
request:
'curl -X POST http://127.0.0.1:3210/mcp \\\n -H "Authorization: Bearer limp_it_example" \\\n -H "Content-Type: application/json" \\\n -d \'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}\'',
'curl -X POST http://127.0.0.1:3210/mcp \\\n -H "Authorization: Bearer limp_it_example" \\\n -H "Content-Type: application/json" \\\n -d \'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.1.0"}}}\'',
response: {
status: 200,
contentType: "application/json",
Expand All @@ -3117,7 +3117,7 @@ export const apiContract = {
capabilities: {},
serverInfo: {
name: "grimoire",
version: "1.0.0",
version: "1.1.0",
},
},
},
Expand Down
30 changes: 24 additions & 6 deletions daemon/src/db/embedding-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,30 @@ export class EmbeddingRepository {
.query<EmbeddingRow, []>("SELECT * FROM embeddings")
.all();

for (const table of this.listVectorTables()) {
this.db.run(`DELETE FROM ${table}`);
}
for (const row of rows) {
this.upsertVectorIndex(row.bookmark_id, row.model, row.dimensions, row.vector);
}
// One transaction for the whole rebuild: per-row transactions would be
// thousands of BEGIN/COMMIT cycles on every daemon start, and the single
// transaction also keeps the index atomic if a crash interrupts a boot.
this.db.transaction(() => {
for (const table of this.listVectorTables()) {
this.db.run(`DELETE FROM ${table}`);
}
for (const row of rows) {
const table = vectorTableName(row.dimensions);
this.db.exec(
`CREATE VIRTUAL TABLE IF NOT EXISTS ${table} USING vec0(
bookmark_id TEXT PRIMARY KEY,
model TEXT PARTITION KEY,
embedding FLOAT[${row.dimensions}] distance_metric=cosine
)`
);
this.db
.query<unknown, [string, string, Uint8Array]>(
`INSERT INTO ${table} (bookmark_id, model, embedding)
VALUES (?, ?, ?)`
)
.run(row.bookmark_id, row.model, row.vector);
}
})();
} catch (error) {
this.disableVectorIndex(error);
}
Expand Down
79 changes: 77 additions & 2 deletions daemon/src/lib/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,19 @@
* considered an accepted risk for a local-only daemon.
*/
export function isPrivateHost(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, "");
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();

if (isPrivateIpv4(host)) return true;

// IPv4-mapped and IPv4-compatible IPv6 literals can address the same
// private services as their dotted-quad forms (for example,
// [::ffff:7f00:1] is 127.0.0.1). Classify the embedded address before
// applying the IPv6-only checks below.
const embeddedIpv4 = embeddedIpv4Address(host);
if (embeddedIpv4 && isPrivateIpv4(embeddedIpv4)) return true;

// Loopback
if (host === "localhost" || host === "::1") return true;
if (/^127\./.test(host)) return true;

// Link-local (AWS IMDS, etc.)
if (/^169\.254\./.test(host)) return true;
Expand All @@ -41,3 +49,70 @@

return false;
}

function isPrivateIpv4(host: string): boolean {
// Loopback
if (/^127\./.test(host)) return true;

// Link-local (AWS IMDS, etc.)
if (/^169\.254\./.test(host)) return true;

Check warning on line 58 in daemon/src/lib/network.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'String#startsWith' method instead.

See more on https://sonarcloud.io/project/issues?id=goniszewski_grimoire&issues=AaAGcRwZL5ldg3JYHEqJ&open=AaAGcRwZL5ldg3JYHEqJ&pullRequest=207

// Private ranges
if (/^10\./.test(host)) return true;

Check warning on line 61 in daemon/src/lib/network.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'String#startsWith' method instead.

See more on https://sonarcloud.io/project/issues?id=goniszewski_grimoire&issues=AaAGcRwZL5ldg3JYHEqK&open=AaAGcRwZL5ldg3JYHEqK&pullRequest=207
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
if (/^192\.168\./.test(host)) return true;

Check warning on line 63 in daemon/src/lib/network.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'String#startsWith' method instead.

See more on https://sonarcloud.io/project/issues?id=goniszewski_grimoire&issues=AaAGcRwZL5ldg3JYHEqL&open=AaAGcRwZL5ldg3JYHEqL&pullRequest=207

// CGNAT / shared address space (RFC 6598)
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host)) return true;

// Unspecified / broadcast
return host === "0.0.0.0";
}

function embeddedIpv4Address(host: string): string | null {
if (!host.includes(":")) return null;
const words = parseIpv6Words(host);
if (!words || words.length !== 8) return null;

Check warning on line 75 in daemon/src/lib/network.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=goniszewski_grimoire&issues=AaAGcRwZL5ldg3JYHEqM&open=AaAGcRwZL5ldg3JYHEqM&pullRequest=207

// The first 80 bits are zero for IPv4-compatible and IPv4-mapped
// addresses. The sixth word is 0 for compatible or ffff for mapped.
if (!words.slice(0, 5).every((word) => word === 0) || (words[5] !== 0 && words[5] !== 0xffff)) {
return null;
}

return [words[6] >> 8, words[6] & 0xff, words[7] >> 8, words[7] & 0xff].join(".");
}

function parseIpv6Words(host: string): number[] | null {
const sections = host.split("::");
if (sections.length > 2) return null;

const parseSection = (section: string): number[] | null => {
if (!section) return [];
const groups = section.split(":");
const words: number[] = [];
for (const [index, group] of groups.entries()) {
if (group.includes(".")) {
if (index !== groups.length - 1) return null;
const octets = group.split(".");
if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet) || Number(octet) > 255)) {
return null;
}
words.push((Number(octets[0]) << 8) | Number(octets[1]), (Number(octets[2]) << 8) | Number(octets[3]));
continue;
}
if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
words.push(Number.parseInt(group, 16));
}
return words;
};

const left = parseSection(sections[0]);
const right = sections.length === 2 ? parseSection(sections[1]) : [];
if (!left || !right) return null;

if (sections.length === 1) return left.length === 8 ? left : null;
const missing = 8 - left.length - right.length;
if (missing < 1) return null;
return [...left, ...Array.from({ length: missing }, () => 0), ...right];
}
5 changes: 4 additions & 1 deletion daemon/src/lib/safe-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { parsePublicHttpUrl } from "./public-url.js";
const DEFAULT_MAX_REDIRECTS = 10;

export function isRedirectStatus(status: number): boolean {
return status >= 300 && status < 400;
// These are the redirect statuses that Fetch follows automatically. 304
// (Not Modified), 300 (Multiple Choices), and the obsolete 305/306 codes
// are ordinary responses for this helper and must be returned to callers.
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}

export function resolveSafeRedirectUrl(
Expand Down
Loading
Loading