security: fail-closed auth + drop wildcard CORS on HTTP server (v2.0.2)#10
Merged
Conversation
Fixes #7 — CORS wildcard + auth-off-by-default let any browser tab the developer visits exfiltrate the graph via GET /query and persistently poison mistake/decision nodes via POST /learn (text/plain, a CORS- safelisted content type). Poisoned nodes were then re-injected into the agent's context by Sentinel on SessionStart and Edit/Write, yielding persistent indirect prompt injection. Four stacked defenses: 1. Fail-closed auth. Every route except /health and /favicon.ico requires Authorization: Bearer <token> or an HttpOnly engram_token cookie. A random 64-char hex token is auto-generated on first server start and persisted to ~/.engram/http-server.token (0600). ENGRAM_API_TOKEN env still overrides, validated once at startup then snapshot — no mid- session re-read (would bypass the length gate). 2. No wildcard CORS. Access-Control-Allow-Origin: * removed everywhere. Default is no CORS headers. Opt-in allowlist via ENGRAM_ALLOWED_ORIGINS=a.com,b.com. 3. Host + Origin validation (DNS-rebinding defense). Host must match 127.0.0.1|localhost|::1 on the bound port exactly (case-insensitive hostname, strict port). Origin if present must be same-origin or in ENGRAM_ALLOWED_ORIGINS, else 403. 4. Content-Type: application/json enforced on mutations (POST/PUT/DELETE return 415 otherwise). Blocks the text/plain CSRF vector from the PoC. Plus: - /ui?token=<t> bootstrap exchange: the CLI-launched browser navigates to this URL; server validates via constant-time safeEqual, swaps for an HttpOnly SameSite=Strict cookie, 302s to clean /ui. Defense-in-depth via Sec-Fetch-Site gate (accepts none/same-origin only) prevents cross-origin <img>/<iframe> probe oracles. Response carries Referrer-Policy: no-referrer and Cache-Control: no-store. - safeEqual: length-first, constant-time, now also rejects empty strings (defense against corrupt-state == corrupt-state matches). - All 4 PoCs from issue #7 verified blocked end-to-end on the rebuilt binary: 403 / 415 / 400 / 401. - 29 new security tests (cross-origin exfil, text/plain CSRF, DNS rebinding, no-auth, empty Bearer, env-downgrade rejection, CORS wildcard absence, Host case-insensitive + no-port rejection, /ui?token cross-site oracle block, /ui?token address-bar allow). - 670/670 total tests pass (up from 641 baseline; +29 new). Breaking: external curl/CI probes must now send the token. See CHANGELOG.md v2.0.2 entry for one-liner. Credit: @gabiudrescu for responsible disclosure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NickCirv
added a commit
that referenced
this pull request
Apr 24, 2026
…rena reference plugin ITEM #2 — Plugin contract v2 Extends ContextProviderPlugin so plugin authors can declare an MCP server via 'mcpConfig' and skip writing resolve()/isAvailable() by hand. The loader auto-wraps via createMcpProvider() from item #1. Classic plugins (custom resolve()) continue to work unchanged — if both fields are present, the author's resolve() wins (they opted into custom logic). Type changes (src/providers/types.ts): - ContextProviderPlugin stays strict (extends ContextProvider fully) — this is the POST-VALIDATION shape the resolver consumes - NEW: RawPluginShape — the pre-validation shape a plugin-file author writes in .mjs. tier/tokenBudget/timeoutMs/resolve/isAvailable all optional (loader fills from factory when mcpConfig present) Loader changes (src/providers/plugin-loader.ts): - validatePlugin() branches on 'has mcpConfig vs. has resolve()' - name/label/version always required - Classic path: tier/tokenBudget/timeoutMs/isAvailable required - mcpConfig path: config validated via validateProviderConfig(), merged with plugin fields (author overrides win over factory defaults) - One clear error per rejection — 'invalid mcpConfig: <reason>' tells you exactly which sub-field on which plugin is broken Tests (+7 cases in tests/providers/plugin-loader.test.ts): - mcpConfig-only plugin auto-wraps resolve + isAvailable - Plugin with neither resolve nor mcpConfig rejected (clear message) - Invalid mcpConfig rejected (bad command, bad http url) - Custom resolve wins over mcpConfig when both present - Plugin tokenBudget override wins over factory default - Missing version rejected even for mcpConfig plugins ITEM #6 — Serena plugin reference docs/plugins/examples/serena-plugin.mjs (~60 lines incl. docs) — the full Serena (oraios/serena) wrapper as an mcpConfig-only plugin. Install is cp + enable. Thanks to item #2, NO custom transport code needed. docs/plugins/examples/static-context-plugin.mjs — the classic-path reference showing a tier 1 plugin with hand-rolled resolve() for users who just want to inject a fixed string on every Read. docs/plugins/README.md — author-facing guide. Shape 1 (MCP-backed), Shape 2 (classic), template tokens, safety guarantees, debugging checklist, publishing notes. FULL SUITE 808 -> 815 tests (+7), all passing. TypeScript clean, lint clean. V3.0 PROGRESS Done: #1 foundation, #2, #6, #7, #9, #10, #11 = 7 of 12 scope items. Next: #3 budget-weighted resolver + mistakes-boost (~2-3d).
NickCirv
added a commit
that referenced
this pull request
Apr 24, 2026
Two orthogonal improvements to the resolver's assembly pipeline. Both exported from resolver.ts so they're testable in isolation, and both run in the main resolveRichPacket() flow before the final priority sort. 1. PER-PROVIDER BUDGET ENFORCEMENT (enforcePerProviderBudget) Providers are SUPPOSED to self-truncate their content to 'tokenBudget', but a bad plugin or a non-conforming MCP server shouldn't be able to spend our entire total budget on one section. New helper truncates each result to the provider's declared budget BEFORE assembly. - Under-budget content passes through unchanged (zero-cost) - Over-budget content is line-truncated (never cut mid-word) - Edge: first line alone > budget -> hard-cap characters with marker Default budget for unknown/missing providers is 200 tokens (matches the MCP-config default from item #1). 2. MISTAKES-BOOST RERANKING (boostByMistakes) If the engram:mistakes provider fires for this file, scan OTHER providers' content for substring matches against mistake labels (extracted from the ' ! <label> (flagged <age>)' format). Matching results get confidence * 1.5 (capped at 1.0). Runs BEFORE the priority sort, but the secondary sort is now (priority asc, confidence desc) — so boost breaks ties WITHIN a priority tier without overriding priority across tiers. - Case-insensitive matching (labels normalized to lowercase) - Does NOT boost the mistakes provider itself - No-op if no mistakes are reported for this file (common case) Examples of the intended effect: - An engram:git commit message mentioning a known-broken function sorts UP within the git tier - A mempalace decision that references a mistaken architectural choice bubbles ahead of unrelated decisions TESTS (+10 cases in tests/providers/resolver.test.ts) enforcePerProviderBudget: - Under-budget untouched - Over-budget truncated by line with marker - Hard-cap when first line alone exceeds budget - Default 200 tokens when provider not found boostByMistakes: - No-op when no mistakes provider in set - Matching substring boosts confidence 0.6 -> 0.9 - Cap enforced (0.8 * 1.5 = 1.2 -> 1.0) - Non-matching results left alone - Mistakes provider itself is never self-boosted - Case-insensitive matching across upper/lower case variations Full suite: 815 -> 825 tests (+10), all passing. TypeScript clean. V3.0 PROGRESS: 8 of 12 scope items done. ✅ #1 foundation ✅ #2 ✅ #3 ✅ #6 ✅ #7 ✅ #9 ✅ #10 ✅ #11 Remaining: #4 Auto-Memory (blocked on MEMORY.md fixture), #5 SSE streaming, #8 pre-mortem warnings, #12 MCP Registry submit, and #1 completion (HTTP transport + real-server integration tests).
NickCirv
added a commit
that referenced
this pull request
Apr 24, 2026
Opt-in warnings that fire BEFORE Claude Code runs an Edit/Write/Bash
tool call against code previously flagged as a mistake. Fully gated
via ENGRAM_MISTAKE_GUARD env var — zero overhead when unset.
MODES
unset / '0' → off (default — no database read, no overhead)
'1' → permissive: tool proceeds, a warning is prepended
to any additionalContext the primary handler emits
'2' → strict: tool is denied with the warning as reason
Hooks Edit/Write/Bash only. Read already surfaces mistakes via the
engram:mistakes context provider — duplicating at tool-call time would
be noise.
MATCHING
Edit/Write:
- Normalize tool_input.file_path to relative POSIX vs projectRoot
- Indexed lookup via store.getNodesByFile() (uses idx_nodes_source_file)
- Dedupe by node id when both relative + raw shapes are stored
Bash:
- Substring match on mistake.metadata.commandPattern (length >2)
- Fallback: substring match on mistake.sourceFile (length >3 to avoid
accidentally matching single-char paths like 'a')
- Full-table scan of mistakes (unavoidable — no file axis to index on).
Bounded by project size; only runs when the guard is explicitly on.
BI-TEMPORAL FILTER (item #7 interop)
Mistakes with validUntil <= now are suppressed — they refer to code
that has since been refactored away. Prevents stale-warning fatigue.
INTEGRATION
New file: src/intercept/handlers/mistake-guard.ts
- currentGuardMode() — reads env var at call time, not module load,
so tests can flip between cases cleanly
- findMatchingMistakesAsync(target, projectRoot) — the matcher
- formatWarning(matches) — human-readable warning block
- applyMistakeGuard(rawResult, payload, kind) — wrapping fn that
augments additionalContext (permissive) or overrides to deny (strict)
src/intercept/dispatch.ts wiring: after runHandler() returns for Edit/
Write/Bash, pass result through applyMistakeGuard() before returning.
Two-line diff. Doesn't touch the existing handlers.
SAFETY
Every code path in mistake-guard is wrapped in try/catch with a null
return. A guard failure MUST NEVER break the primary handler. If the
store open fails, the env var is wrong, the payload is malformed —
guard silently returns the raw result unchanged.
TESTS (+21 cases in tests/intercept/handlers/mistake-guard.test.ts)
- currentGuardMode: off/permissive/strict recognition, bogus values
coerced to off
- formatWarning: empty-match string, single-match header, >5-match
collapse with '… and N more'
- findMatchingMistakesAsync (file): rel path, abs path normalization,
no-match, validUntil filter
- findMatchingMistakesAsync (bash): commandPattern substring match,
sourceFile-in-command match, case-insensitive, too-short pattern
guard, validUntil filter
- applyMistakeGuard: mode=off no-op, permissive augments additional
context, permissive no-match no-op, strict denies with reason,
permissive from passthrough emits fresh allow-with-warning
Full suite: 825 -> 846 tests (+21), all passing. TypeScript clean.
V3.0 PROGRESS — 9 of 12 scope items
✅ #1 foundation ✅ #2 ✅ #3 ✅ #6 ✅ #7 ✅ #8 ✅ #9 ✅ #10 ✅ #11
Remaining:
- #1 completion (HTTP transport + real-server integration tests)
- #4 Anthropic Auto-Memory bridge (blocked: needs MEMORY.md fixture)
- #5 SSE streaming for rich packet assembly
- #12 Official MCP Registry submission (post-ship)
NickCirv
added a commit
that referenced
this pull request
Apr 24, 2026
Adds progressive delivery for rich packet assembly. Instead of blocking
on Promise.allSettled (which waits for the slowest provider — Serena
cold-start, mempalace ChromaDB warmup), clients can stream results
as they arrive and render each section immediately.
NEW — resolveRichPacketStreaming generator (src/providers/resolver.ts)
AsyncGenerator<StreamEvent> that yields:
{ type: 'provider', result: ProviderResult } — as each resolves
{ type: 'done', providerCount, durationMs } — final totals
Order = ARRIVAL order (fast providers first). Consumers who want
priority order use the non-streaming resolveRichPacket() which applies
full priority + mistakes-boost + budget logic.
Implementation: fan-out all providers, funnel outcomes into a FIFO
queue + wake-on-arrival pattern. No extra deps. Per-provider timeouts
preserved (same resolveWithTimeout path as non-streaming).
NEW — /context/stream SSE endpoint (src/server/http.ts)
GET /context/stream?file=<relative-path> (auth required).
Emits one SSE frame per StreamEvent. Frame shape matches MCP SEP-1699
(SSE resumption):
id: 0
event: provider
data: {"provider":"engram:ast", …}
id: 1
event: provider
data: {"provider":"engram:mistakes", …}
id: N
event: done
data: {"providerCount":N,"durationMs":347}
Supports Last-Event-ID header — clients reconnecting via
'Last-Event-ID: 3' skip events 0-3 and pick up from 4. Useful for
long-running sessions that drop WiFi mid-stream without losing context.
Client-disconnect aborts the stream cleanly (req.close handler short-
circuits the generator loop).
TESTS (+6 new)
resolver.test.ts (+2):
- Smoke: streaming generator terminates with a 'done' event for any
project (no hang, no runaway)
- Arrival-order invariant: toy generator mirrors production shape,
verifies fast results yield before slow ones
server/http.test.ts (+4):
- Missing 'file' param returns 400
- Valid request returns 200 + text/event-stream + ends with 'done'
- Every frame carries an 'id:' header (SEP-1699 resumption)
- Auth required — unauthenticated returns 401
Full suite: 870 -> 876 tests (+6), all passing. TypeScript clean.
V3.0 PROGRESS — 11 of 12 scope items done
✅ #1 foundation ✅ #2 ✅ #3 ✅ #4 ✅ #5 ✅ #6 ✅ #7 ✅ #8
✅ #9 ✅ #10 ✅ #11
Only remaining in-scope work:
- #12 MCP Registry submission (~2h, post-ship only)
Plus item #1 completion (HTTP transport + minimal MCP server fixture
for integration tests) — technically part of #1 which shipped its
foundation as c719591; the HTTP transport path was explicitly deferred
until this SSE work landed. Now it can.
shahe-dev
pushed a commit
to shahe-dev/engram
that referenced
this pull request
Apr 25, 2026
…aming-collision disarm Items NickCirv#10 + NickCirv#11 from v3.0 Spine implementation plan (docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md). ITEM NickCirv#10 — engram gen emits CLAUDE.md AND AGENTS.md by default When no --target flag is passed, autogen() now writes BOTH CLAUDE.md AND AGENTS.md (and updates legacy .cursorrules if present). AGENTS.md is the Linux Foundation universal agent-instructions standard adopted by Codex CLI, Cursor, Windsurf, GitHub Copilot, JetBrains Junie, and Antigravity (donated to AAIF Dec 2025). Single-source-of-truth: same generated summary writes to both files. Explicit --target=claude / cursor / agents preserves single-file behavior. API change: autogen() return type goes from { file: string; ... } to { files: string[]; ... }. Only one caller (cli.ts) — updated. All tests pass (26/26, +5 new dual-emit cases). ITEM NickCirv#11 — README naming-collision disarm section Adds 'What engramx is not' section between hero and Dashboard. Disarms collision with Go-Engram (Gentleman-Programming/engram), DeepSeek's Engram paper (Jan 2026), and MemPalace in the first 30 seconds of any new visitor read. Per decision: 'engramx' stays canonical brand (decision logged in strategy folder). PLANNING SPEC Lands the full v3.0 implementation plan at docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md (605 lines, sibling to the elevation-trilogy spec). Grounded in actual file paths + line numbers from the existing codebase — provider system already production-grade, Pillar 1 is EXTENDING not rewriting. 12 scope items mapped with dependencies, branch strategy, schema migrations, test strategy, release checklist.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #7 — critical security issue where the local HTTP server shipped with
Access-Control-Allow-Origin: *on every response and defaulted to no authentication. Any browser tab the developer visited could exfiltrate the local knowledge graph viaGET /queryand persistently poisonmistakenodes viaPOST /learn(usingtext/plain, a CORS-safelisted content type). Poisoned nodes were then re-injected into the coding agent's context by Sentinel on SessionStart and every Edit/Write — yielding persistent indirect prompt injection against the user's AI agent.Credit: @gabiudrescu — responsible disclosure via #7 with file:line citations, working PoC, and a suggested fix.
Four stacked defenses
/healthand/favicon.icorequiresAuthorization: Bearer <token>OR anHttpOnlyengram_tokencookie. A random 64-char hex token is auto-generated on first server start and persisted to~/.engram/http-server.token(mode 0600).ENGRAM_API_TOKENenv still overrides, validated at startup then snapshot (no mid-session re-read — would bypass the 32-char minimum gate).ACAO: *removed everywhere. Default is no CORS headers (dashboard is same-origin). Opt-in viaENGRAM_ALLOWED_ORIGINS=a.com,b.com.127.0.0.1|localhost|::1on the bound port exactly. Origin if present must be same-origin or in allowlist, else 403.Content-Type: application/jsonenforced on mutations — POST/PUT/DELETE return 415 otherwise. Blocks thetext/plainCSRF vector from the PoC.Plus:
/ui?token=<t>bootstrap withSec-Fetch-Sitegate (blocks cross-site oracle),safeEqualconstant-time comparison with empty-string guard,authCookie()helper (HttpOnly; SameSite=Strict; Path=/),Referrer-Policy: no-referrer+Cache-Control: no-storeon the 302.Verification
engram serverenables CSRF, graph exfil, and persistent prompt injection #7 blocked end-to-end on the rebuilt binary: 403 / 415 / 400 / 401tests/server/security.test.tscovering fail-closed auth, empty-Bearer guard, env-downgrade rejection, cookie auth, no wildcard CORS anywhere, Host case-insensitive + no-port rejection, text/plain CSRF block,/ui?tokencross-site oracle defense, and the end-to-end exploit chainsecurity-reviewer(1 HIGH, 4 MED, 7 LOW — all HIGH/MED ship-blockers addressed) +code-reviewer(2 CRITICAL, 3 HIGH, 6 MED/LOW — all CRITICAL/HIGH addressed). Low-priority follow-ups (WWW-Authenticate header, CSP tightening, error-detail stripping) tracked for v2.0.3 / v2.1.0.Breaking
External callers (curl, scripts, CI probes) must now send the token:
curl -H "Authorization: Bearer $(cat ~/.engram/http-server.token)" \ http://127.0.0.1:7337/statsSee
CHANGELOG.mdv2.0.2 entry for full list.Test plan
npm run buildcleannpx tsc --noEmitcleannpx vitest run— 670/670 passengram serverenables CSRF, graph exfil, and persistent prompt injection #7 return 403/415/400/401 on rebuilt binary/ui?tokenwithSec-Fetch-Site: cross-site→ 401 (oracle blocked)/ui?tokenwithSec-Fetch-Site: none→ 302 (address-bar nav works)/stats→ 200; public/health→ 200~/.engram/http-server.token🤖 Generated with Claude Code