Root cause
Authentication & access control introduced
feat: v1.0.0 "Protocol" — full ecosystem release
How AI contributed: Direct introductionPublished Apr 22, 2026 in
nickcirv/engram
CSRF / cross-origin graph exfiltration and persistent indirect prompt injection (CWE-942 + CWE-306) in the engram HTTP server (src/server/http.ts): Access-Control-Allow-Origin: * plus auth-off-by-default let any browser tab exfiltrate the graph via GET /query and poison mistake/decision nodes via POST /learn (text/plain is CORS-safelisted); poisoned nodes are later re-injected into agent context by Sentinel.
Root cause
feat: v1.0.0 "Protocol" — full ecosystem release
Fix
security: fail-closed auth + drop wildcard CORS on HTTP server (v2.0.2)
AI-assisted fix: Claude Code · Nicholas Ashkar
Case facts
@@ -0,0 +1,184 @@+/**+ * Auto-tuner — analyses `.engram/hook-log.jsonl` and proposes adjustments+ * to the per-project config. Never modifies config without an explicit+ * `applyTuning` call.+ *+ * Analysis rules+ * ──────────────+ * 1. Confidence threshold — if the median confidence of denied Read+ * events is more than 0.1 above the current threshold, suggest raising+ * the threshold (reduces noise without missing real context).+ * 2. Provider disable — if a provider name never appears in any entry's+ * `provider` field across the entire log, suggest disabling it (it is+ * contributing nothing and burning latency budget).+ * 3. Provider budget increase — if a provider always contributes content+ * (appears in every intercepted read), suggest raising its token budget+ * by 20% so it has room to deliver more context.+ *+ * All arithmetic uses immutable spreads; no in-place mutation.+ */+import { readHookLog } from "../intelligence/hook-log.js";+import { readConfig, writeConfig, type EngramConfig } from "./config.js";++/** A single proposed configuration change. */+export interface TuneChange {+ readonly field: string;+ readonly current: number | boolean;+ readonly proposed: number | boolean;+ readonly reason: string;+}++/** Output of an analysis run. */+export interface TuneProposal {+ readonly changes: TuneChange[];+ readonly entriesAnalyzed: number;+ readonly daysSpanned: number;+}++/** Minimum denied-read events required before suggesting threshold changes. */+const MIN_CONFIDENCE_SAMPLES = 10;++/** How far median must exceed current threshold before we suggest raising it. */+const CONFIDENCE_HYSTERESIS = 0.1;++/** Minimum intercepted-read events to conclude a provider is always-on. */+const ALWAYS_ON_MIN_EVENTS = 20;++/**+ * Analyse the hook log for the given project and return a list of+ * proposed configuration changes. Returns an empty proposal if the log+ * has too few entries to draw conclusions.+ */+export function analyzeTuning(projectRoot: string): TuneProposal {+ const entries = readHookLog(projectRoot);+ if (entries.length === 0) {+ return { changes: [], entriesAnalyzed: 0, daysSpanned: 0 };+ }++ const config = readConfig(projectRoot);+ const changes: TuneChange[] = [];++ // ── Derived sets ──────────────────────────────────────────────────+ const deniedReads = entries.filter(+ (e) => e.event === "PreToolUse" && e.decision === "deny"+ );++ // Track which providers appeared in any hook entry+ const providerHits = new Map<string, number>();+ const confidences: number[] = [];++ for (const entry of deniedReads) {+ const prov = (entry as unknown as Record<string, unknown>).provider;+ if (typeof prov === "string") {+ providerHits.set(prov, (providerHits.get(prov) ?? 0) + 1);+ }+ if (typeof entry.confidence === "number") {+ confidences.push(entry.confidence);+ }+ }++ // ── Rule 1: Confidence threshold ──────────────────────────────────+ if (confidences.length >= MIN_CONFIDENCE_SAMPLES) {+ const sorted = [...confidences].sort((a, b) => a - b);+ const median = sorted[Math.floor(sorted.length / 2)];+ if (median > config.confidenceThreshold + CONFIDENCE_HYSTERESIS) {+ const proposed = Math.round(median * 100) / 100;+ changes.push({+ field: "confidenceThreshold",+ current: config.confidenceThreshold,+ proposed,@@ -0,0 +1,60 @@+/**+ * Per-project engram configuration — overrides hardcoded defaults for+ * token budgets, provider settings, and confidence thresholds.+ *+ * Stored at `.engram/config.json` inside each project root. Falls back+ * to DEFAULTS if the file is absent or malformed. All mutations produce+ * new objects (immutable patterns).+ */+import { existsSync, readFileSync, writeFileSync } from "node:fs";+import { join } from "node:path";++/** Per-provider overrides applied on top of provider-level defaults. */+export interface ProviderOverride {+ readonly enabled?: boolean;+ readonly tokenBudget?: number;+ readonly timeoutMs?: number;+}++/** Project-level engram configuration schema. */+export interface EngramConfig {+ readonly confidenceThreshold: number;+ readonly totalTokenBudget: number;+ readonly providers: Readonly<Record<string, ProviderOverride>>;+}++/** Baseline defaults. Must match the hard-coded values in resolver.ts. */+const DEFAULTS: EngramConfig = {+ confidenceThreshold: 0.7,+ totalTokenBudget: 600,+ providers: {},+};++/**+ * Read project config from `.engram/config.json`.+ * Returns DEFAULTS if the file does not exist or cannot be parsed.+ */+export function readConfig(projectRoot: string): EngramConfig {+ const configPath = join(projectRoot, ".engram", "config.json");+ if (!existsSync(configPath)) return DEFAULTS;+ try {+ const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Partial<EngramConfig>;+ return {+ ...DEFAULTS,+ ...raw,+ providers: { ...DEFAULTS.providers, ...(raw.providers ?? {}) },+ };+ } catch {+ return DEFAULTS;+ }+}++/**+ * Write project config to `.engram/config.json`.+ * Overwrites any existing file — callers are responsible for merging+ * with the current config before writing.+ */+export function writeConfig(projectRoot: string, config: EngramConfig): void {+ const configPath = join(projectRoot, ".engram", "config.json");+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");+}@@ -0,0 +1,246 @@+/**+ * engram HTTP REST server — Node built-in http only, zero new deps.+ * Binds to 127.0.0.1 only (local privacy invariant).+ * Default port: 7337.+ *+ * Auth: if ENGRAM_API_TOKEN env var is set, all requests require+ * Authorization: Bearer <token>. If not set, no auth required.+ *+ * PID file: <projectRoot>/.engram/http-server.pid — written on start,+ * removed on shutdown. Checked by component-status.ts for HUD display.+ */+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";+import { writeFileSync, unlinkSync, mkdirSync, existsSync } from "node:fs";+import { join } from "node:path";+import { query, stats, learn } from "../core.js";++// Read version — try both paths (works from src/ in dev and dist/ when built).+import { createRequire } from "node:module";+const require = createRequire(import.meta.url);+const PKG_VERSION = (() => {+ for (const p of ["../package.json", "../../package.json"]) {+ try { return (require(p) as { version: string }).version; } catch { /* next */ }+ }+ return "0.0.0";+})();++const PROVIDERS = [+ "structure",+ "mistakes",+ "git",+ "mempalace",+ "context7",+ "obsidian",+] as const;++// ---------------------------------------------------------------------------+// Helpers+// ---------------------------------------------------------------------------++function parseUrl(req: IncomingMessage): URL {+ return new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);+}++async function readBody(req: IncomingMessage): Promise<string> {+ const chunks: Buffer[] = [];+ for await (const chunk of req) chunks.push(chunk as Buffer);+ return Buffer.concat(chunks).toString("utf-8");+}++function json(res: ServerResponse, status: number, data: unknown): void {+ res.writeHead(status, {+ "Content-Type": "application/json",+ "Access-Control-Allow-Origin": "*",+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",+ "Access-Control-Allow-Headers": "Authorization, Content-Type",+ });+ res.end(JSON.stringify(data));+}++function checkAuth(req: IncomingMessage, res: ServerResponse): boolean {+ const token = process.env.ENGRAM_API_TOKEN;+ if (!token) return true;+ const header = req.headers.authorization ?? "";+ if (header === `Bearer ${token}`) return true;+ json(res, 401, { error: "Unauthorized" });+ return false;+}++// ---------------------------------------------------------------------------+// Route handlers — each <50 lines+// ---------------------------------------------------------------------------++function handleHealth(+ _req: IncomingMessage,+ res: ServerResponse,+ startedAt: number+): void {+ json(res, 200, {+ ok: true,+ version: PKG_VERSION,+ uptime: Math.floor((Date.now() - startedAt) / 1000),+ });+}++async function handleQuery(+ req: IncomingMessage,+ res: ServerResponse,+ projectRoot: string+): Promise<void> {@@ -20,9 +20,25 @@ export async function startHttpServer( projectRoot: string, port: number = DEFAULT_PORT ): Promise<void> {- await createHttpServer(projectRoot, port);- // Log after bind so port conflicts surface before the message.- process.stdout.write(- `engram HTTP server listening on http://127.0.0.1:${port}\n`- );+ const tokenInfo = await createHttpServer(projectRoot, port);+ const url = `http://127.0.0.1:${port}`;+ process.stdout.write(`engram HTTP server listening on ${url}\n`);++ // Auth banner — tell the user where their token lives so external callers+ // (curl, scripts) can find it. Browser dashboard auths automatically via+ // HttpOnly cookie set on GET /ui.+ if (tokenInfo.source === "env") {+ process.stderr.write(+ "engram: auth token from ENGRAM_API_TOKEN env var\n"+ );+ } else if (tokenInfo.source === "file") {+ process.stderr.write(+ `engram: auth token at ${tokenInfo.path}\n`+ );+ } else {+ process.stderr.write(+ `engram: auth token generated at ${tokenInfo.path} (0600)\n` ++ ` curl -H "Authorization: Bearer $(cat ${tokenInfo.path})" ${url}/stats\n`+ );+ } }@@ -3,8 +3,20 @@ * Binds to 127.0.0.1 only (local privacy invariant). * Default port: 7337. *- * Auth: if ENGRAM_API_TOKEN env var is set, all requests require- * Authorization: Bearer <token>. If not set, no auth required.+ * Auth: fail-closed. Every request except /health and /favicon.ico requires+ * either `Authorization: Bearer <token>` or `Cookie: engram_token=<token>`.+ * Token is resolved from ENGRAM_API_TOKEN env var, then the persisted+ * ~/.engram/http-server.token file (auto-generated on first start, 0600).+ *+ * CORS: no wildcard. Default is no CORS headers (same-origin dashboard only).+ * Additional origins opt in via ENGRAM_ALLOWED_ORIGINS=a.com,b.com.+ *+ * Host/Origin: DNS-rebinding defense — rejects Host headers that aren't+ * 127.0.0.1|localhost|::1 on the bound port, and Origin values not in the+ * same-origin or env allowlist.+ *+ * Content-Type: mutations (POST/PUT/DELETE) must be application/json. This+ * forces CORS preflight for cross-origin writes as a belt-and-braces check. * * PID file: <projectRoot>/.engram/http-server.pid — written on start, * removed on shutdown. Checked by component-status.ts for HUD display.@@ -19,6 +31,14 @@ import { getCumulativeStats } from "../intelligence/token-tracker.js"; import { getContextCache, ContextCache } from "../intelligence/cache.js"; import { getComponentStatus } from "../intercept/component-status.js"; import { buildDashboardHtml } from "./ui.js";+import {+ getOrCreateToken,+ isHostValid,+ isOriginAllowed,+ parseCookies,+ safeEqual,+ type TokenInfo,+} from "./auth.js"; // Read version — try both paths (works from src/ in dev and dist/ when built). import { createRequire } from "node:module";@@ -39,6 +59,36 @@ const PROVIDERS = [ "obsidian", ] as const; +// ---------------------------------------------------------------------------+// Server-scoped state — resolved once per createHttpServer() call.+// ---------------------------------------------------------------------------++let serverToken = "";+let serverPort = 0;++/**+ * Snapshotted auth token resolved once at server start. Never returns+ * empty — `createHttpServer` populates `serverToken` from+ * `getOrCreateToken()` before accepting connections. We deliberately do+ * NOT re-read `process.env.ENGRAM_API_TOKEN` at request time: a downstream+ * plugin or test helper that mutates the env var mid-session could silently+ * downgrade auth, and `getOrCreateToken`'s length gate wouldn't apply.+ * Tests that need a specific token set the env BEFORE calling+ * `createHttpServer`.+ */+function currentToken(): string {+ return serverToken;+}++/**+ * Build the auth cookie string. Tokens are URL-safe (hex or env-supplied+ * >=32-char), so no percent-encoding is needed — keeping the cookie value+ * raw means `parseCookies` round-trips exactly without asymmetric decode.+ */+function authCookie(token: string): string {+ return `engram_token=${token}; HttpOnly; SameSite=Strict; Path=/`;+}+ // --------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------@@ -53,25 +103,69 @@ async function readBody(req: IncomingMessage): Promise<string> { return Buffer.concat(chunks).toString("utf-8"); } -function json(res: ServerResponse, status: number, data: unknown): void {+/**+ * Build CORS headers for a response. By default emits nothing — same-origin+ * dashboard doesn't need them. Echoes Origin only when the request's Origin+ * header is in the allowlist (same-origin or ENGRAM_ALLOWED_ORIGINS).+ */+function corsHeaders(req: IncomingMessage): Record<string, string> {+ const origin = req.headers.origin;+ if (!origin || !isOriginAllowed(origin, serverPort)) return {};+ return {+ "Access-Control-Allow-Origin": origin,@@ -0,0 +1,176 @@+/**+ * engram HTTP server auth — token management, constant-time comparison,+ * cookie parsing.+ *+ * Token resolution priority:+ * 1. process.env.ENGRAM_API_TOKEN (power-user override)+ * 2. ~/.engram/http-server.token (auto-generated on first start, 0600)+ *+ * Rationale: the local HTTP server must be fail-closed. Any browser tab the+ * developer visits is a local client — wildcard CORS + auth-off-by-default+ * previously enabled graph exfiltration and persistent prompt injection via+ * Sentinel-surfaced mistake nodes. See issue #7 / GHSA.+ */+import { randomBytes } from "node:crypto";+import {+ chmodSync,+ existsSync,+ mkdirSync,+ readFileSync,+ writeFileSync,+} from "node:fs";+import { homedir } from "node:os";+import { join } from "node:path";++const TOKEN_MIN_LEN = 32;+const TOKEN_BYTES = 32; // 64-char hex output++export interface TokenInfo {+ /** The resolved token value. */+ readonly token: string;+ /** Where the token came from — useful for CLI banner messaging. */+ readonly source: "env" | "file" | "generated";+ /** Absolute path to the token file, if one was read or written. */+ readonly path: string | null;+}++function tokenDir(): string {+ return join(homedir(), ".engram");+}++function tokenPath(): string {+ return join(tokenDir(), "http-server.token");+}++/**+ * Resolve the server auth token. Reads ENGRAM_API_TOKEN env, then the+ * on-disk cache, otherwise generates a fresh 32-byte random token and+ * persists it to ~/.engram/http-server.token with mode 0600.+ *+ * Idempotent: subsequent calls return the same token unless the file is+ * deleted or the env var changes.+ */+export function getOrCreateToken(): TokenInfo {+ const envToken = process.env.ENGRAM_API_TOKEN;+ if (envToken && envToken.length >= TOKEN_MIN_LEN) {+ return { token: envToken, source: "env", path: null };+ }++ const path = tokenPath();+ if (existsSync(path)) {+ try {+ const cached = readFileSync(path, "utf8").trim();+ if (cached.length >= TOKEN_MIN_LEN) {+ return { token: cached, source: "file", path };+ }+ } catch {+ // fall through to regenerate+ }+ }++ const fresh = randomBytes(TOKEN_BYTES).toString("hex");+ const dir = tokenDir();+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });+ writeFileSync(path, fresh + "\n", { mode: 0o600 });+ try {+ chmodSync(path, 0o600);+ } catch {+ // Windows chmod is a no-op — acceptable.+ }+ return { token: fresh, source: "generated", path };+}++/**+ * Constant-time string comparison. Prevents timing attacks on token+ * validation. Returns false on length mismatch (length itself is not secret).+ *+ * Defence-in-depth: empty inputs never match. Guards against the footgun+ * where both the presented and expected token somehow end up empty strings+ * (regressions, misconfig, corrupt token file) — `safeEqual("", "")` wouldAI-assisted change 64ef0bac36de9531a49b93f67645ffc37dc62a7b8ead97b61f9eba4f193bf146 · Fix 0556ee5a91361c0f21d26d0ff63f01c3af0be375ae8106414ef5d77db6597bf4