6 Commits
Author SHA1 Message Date
ef1133df3e docs(security): Docker network isolation for co-located self-hosted Postgres (#3270) (#3331)
OAuth/source scoping only guards the serve --http path; a container
sharing Docker's default bridge with the brain's Postgres can open a
direct DB session without a token. Adds a 'Co-located Docker workloads'
subsection to docs/mcp/DEPLOY.md with the operator checklist, a
trust-boundary paragraph in SECURITY.md, and an ops note + cross-link
in the company-brain tutorial.

Fixes #3270

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:29 -07:00
dde1132a29 v0.42.55.0 fix(security): dotfile/skills/slug confinement + DCR consent default + schema-lint migration (#418 #419 #245 #1353 #1647 #171 #1385) (#2399)
* fix(security): confine routing dotfiles, skills dir, slugs, and transcription exec

Shared src/core/path-confine.ts consolidates the realpath-containment idiom
(moved from sources-ops.ts) and adds isTrustedDotfile + isWriteTargetContained.

- .gbrain-source (source-resolver) and .gbrain-mount (brain-resolver) walk-up
  dotfiles are now lstat trust-gated: a symlink, foreign-owned, or world-writable
  file is refused on multi-user hosts (#418), fail-closed on stat error.
- resolveWorkspaceSkillsDir + every skills-dir tier (env, cwd_walk_up, repo_root,
  cwd_skills, install_path) route through realpath containment so a symlinked
  workspace/skills can't escape the declared workspace (#419).
- resolveSourceId/resolveBrainId realpath both sides of the registered local_path
  / mount prefix match so a symlinked cwd can't misattribute source/brain.
- validateSlug rejects NUL/control, bidi/RTL overrides, backslashes, and
  URL-encoded path separators at the shared putPage/updateSlug chokepoint;
  write-through confirms the file path stays within the source tree.
- transcribeLargeFile uses execFileSync arg-arrays + fs.rmSync (no shell), so a
  path with shell metacharacters is never parsed by a shell (#245).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): default dynamic-registration clients to authorization_code

Self-registered DCR clients (the unauthenticated network registration path)
previously defaulted to the client_credentials grant, which bypasses the
/authorize consent screen. They now default to authorization_code; an explicit
client_credentials request is rejected with invalid_client_metadata unless the
operator opts in with the new --enable-dcr-insecure flag. A loud stderr WARNING
prints at startup whenever DCR is enabled (#1353). Manual CLI/admin client
registration is unchanged (operator-trusted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): schema-lint hardening migration (search_path + view security_invoker)

Migration v120 brings existing brains to the same posture as fresh installs:

- ALTER VIEW page_links SET (security_invoker = on) on Postgres so the view
  honors the caller's RLS instead of the owner's (the view-through-RLS bypass).
- ALTER FUNCTION ... SET search_path on the gbrain-owned trigger/event functions
  (both engines, IF EXISTS so engine-only functions are skipped; body untouched,
  so the load-bearing auto_enable_rls event trigger is unchanged). Closes #171.
- Broaden the BYPASSRLS preflight in the historical RLS migration gates to honor
  superuser and inherited-role BYPASSRLS, so a superuser-connected fresh install
  no longer aborts (#1385).

Fresh-install function definitions in schema.sql / pglite-schema.ts are
born-correct (regenerated schema-embedded.ts). scripts/check-search-path.sh is a
new CI guard (wired into verify) that fails if a trigger function in the schema
base files is added without SET search_path. Postgres-only assertions live in the
bootstrap E2E; the PGLite path is covered by test/migration-v120.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v0.42.55.0 fix(security): dotfile/skills/slug confinement, DCR consent default, schema-lint migration

Bump VERSION + package.json to 0.42.55.0 and add the CHANGELOG entry for the
security-hardening wave (#418 #419 #245 #1353 #1647 #171 #1385).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(security): note the DCR consent default in SECURITY.md (#1353)

The "disable client_credentials, only allow authorization_code" guidance is now
the built-in DCR default; document the new --enable-dcr-insecure escape hatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(todos): add takes_search + code_def to the federated by-slug P1 (#2200)

The v0.42.55.0 eng-review codex pass flagged takes_search (holder-allowlist only)
and code_def (brain-wide raw SQL over content_chunks) as remaining same-class
surfaces. Noted on the existing #2200 P1 follow-up, with the caveat that the
#2399 close-list deliberately keeps #1371/#2200 open until this lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): correct plpgsql alias collision in #1385 BYPASSRLS gate (real-PG)

The broadened BYPASSRLS preflight aliased `pg_roles r`, but several RLS DO-blocks
already declare `r record` for their backfill FOR loop, so plpgsql resolved
`r.oid`/`r.rolbypassrls` to the unassigned record variable → "record \"r\" is
not assigned yet" on real Postgres (PGLite tolerated it; the DATABASE_URL-gated
e2e jobs are the backstop). Renamed the subquery alias to `pr` at all 10
migrate.ts sites; also broadened the schema.sql base RLS gate the same way (with
the `pr` alias) for #1385 consistency on superuser fresh installs, and
regenerated schema-embedded.ts.

Also fixes a PRE-EXISTING engine-parity bug (confirmed failing on clean
origin/master): the relationalFanout shape compared `canonical_chunk_id`, a
serial id that diverges between a fresh PGLite engine and a shared Postgres DB
(setupDB TRUNCATEs without RESTART IDENTITY). Compare its presence, not the
exact value.

Validated on real Postgres (pgvector/pg16): migration v120 applies, the v35 RLS
backfill runs, and engine-parity + postgres-bootstrap + jsonb-parity are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:00:58 -07:00
6af0c91e53 v0.41.3.0 fix(security/mcp): OAuth CORS lockdown + pre-register without DCR + validator surface (#1403)
* v0.41.3.0 fix(security/mcp): OAuth CORS lockdown, pre-register without DCR, validator surface

Three expanded cherry-picks plus codex-surfaced live-CORS fix, parser
rewrite, atomicity fix, DCR validator gate, SECURITY.md reconciliation.

What ships
- gbrain auth register-client gets --redirect-uri (repeatable) and
  --token-endpoint-auth-method flags so the SECURITY.md-recommended
  "pre-register without --enable-dcr" path actually works for claude.ai
  and ChatGPT custom connectors.
- ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS = {client_secret_post,
  client_secret_basic, none} validator gates all three registration
  entry points (CLI, admin endpoint, DCR /register) so --enable-dcr is
  no longer the looser path.
- Live Express OAuth server (/mcp, /token, /authorize, /register,
  /revoke) was using default-wide-open cors() middleware — every
  origin could complete a token exchange from a logged-in operator's
  browser. Now default-deny; allowlist via GBRAIN_HTTP_CORS_ORIGIN.
- GBRAIN_HTTP_TRUST_PROXY env var on Express server with the same
  semantics as the legacy bearer transport already had. Default
  'loopback' preserved. SECURITY.md doc rewritten to match reality
  (was lying that trust proxy was "disabled by default" while code
  hardcoded 'loopback').
- Admin endpoint registration now atomic — INSERT-then-UPDATE for
  public clients replaced with single INSERT via the new
  registerClientManual(..., tokenEndpointAuthMethod) parameter (codex
  outside-voice F4 catch).
- Legacy transport corsHeaders + corsPreflightHeaders consolidated
  into one function gated on the allowlist for BOTH Allow-Origin and
  Allow-Methods/Headers (codex F1; #983 thematically).

Surfaced by D7 codex outside-voice review on the v0.41.3 plan:
F1 (live Express CORS wide-open), F2 (indexOf parser couldn't do
repeatable flags), F3 (client_secret_basic missing from validator),
F4 (admin endpoint INSERT-then-UPDATE atomicity), F5 (DCR path
bypassed validator), F6 (env var already existed on legacy transport),
F7 (SECURITY.md vs impl doc disagreement).

Tests: 183 directly-touched cases green. Three new test files
(test/serve-http-trust-proxy.test.ts, test/serve-http-cors.test.ts,
test/auth-register-client-args.test.ts) + 18 new oauth.test.ts cases
+ 4 IRON RULE CORS preflight regressions.

Plan: ~/.claude/plans/system-instruction-you-are-working-wise-piglet.md
(D1-D11 captured, codex outside-voice integrated, GSTACK REVIEW REPORT
verdict CLEARED).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(test): audit-writer readRecent calendar-boundary flake

writer.log() uses real `new Date()` for filename computation, but the
test mocked `now` to 2026-05-22. When CI runs on a date in a different
ISO week (e.g. 2026-05-25 W22 vs the mocked W21), log() writes to one
file but readRecent(now) reads a different one — zero events overlap,
expect(2).toBe(0) fails.

Fix: write events directly to the file matching the test's mocked
`now` via writer.computeFilename(now), same pattern the cross-week
straddle test (line 234+) already used for the previous-week event.

Pre-existing test bug, surfaced when CI rolled past the week boundary
the original author wrote against. Not introduced by v0.41.3.0; fix
included here because /ship found it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 09:57:30 -07:00
488e4824e8 v0.34.1.0 fix(mcp): MCP fix wave — source-isolation P0 + PKCE DCR + federated_read + 3 more (#996)
* fix(mcp): skip stdin EOF handlers when MCP_STDIO=1

OpenClaw's bundle-mcp gateway and similar wrappers pipe the JSON-RPC
handshake on stdin then close their stdin half. Pre-fix, both stdin
'end' and 'close' listeners (server.ts:65-66 and serve.ts:204-206)
treated this as a permanent disconnect and shut the server down before
the first tool call arrived.

Guard both sites with `process.env.MCP_STDIO !== '1'`. Signal handlers
(SIGTERM/SIGINT/SIGHUP), transport.onclose, and the parent-process
watchdog still cover legitimate shutdown paths. The serve.ts site
threads the env read through an injectable `mcpStdio?: boolean` on
ServeOptions so tests stay isolated (no process.env mutation per
scripts/check-test-isolation.sh R1).

Tests: 3 new cases in test/serve-stdio-lifecycle.test.ts pin the
guard's invariants — mcpStdio=true must NOT trigger shutdown on stdin
EOF, signals must still drive shutdown with mcpStdio=true, and
mcpStdio=false (default) preserves existing CLI behavior. 25/25 pass.

Origin: PR #870.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): honor token_endpoint_auth_method=none for PKCE public clients

RFC 7591 §3.2.1: when a DCR client declares
token_endpoint_auth_method="none" (PKCE-only public clients like Claude
Code, Cursor), the authorization server MUST NOT issue a client_secret.
Pre-fix, registerClient unconditionally minted a secret, and the MCP
SDK's clientAuth middleware then rejected valid public-client flows on
/token because it expected client.client_secret to match.

Three changes to src/core/oauth-provider.ts:registerClient:

  - Gate clientSecret generation on isPublicClient = (auth_method === 'none').
    Public clients store client_secret_hash = NULL.
  - Omit client_secret from the response payload for public clients.
    Confidential clients (default client_secret_post and explicit
    client_secret_basic) keep their existing one-time-reveal shape.
  - Normalize NULL secret_hash to JS undefined in getClient so SDK
    middleware (which checks client.client_secret === undefined, not
    === null) correctly identifies public clients and skips the
    secret-comparison branch on /token.

Schema is already permissive (client_secret_hash TEXT, no NOT NULL on
both src/schema.sql and src/core/pglite-schema.ts) — no migration
needed.

Tests: 5 new cases in test/oauth.test.ts pin:
  - public client → no client_secret in response (#11 from plan)
  - default auth_method → secret unchanged (regression guard)
  - explicit client_secret_post → secret unchanged
  - getClient NULL→undefined normalization
  - PKCE full /authorize → /token end-to-end with no secret (#15 from plan)

69/69 oauth.test.ts cases pass. typecheck clean.

Origin: PR #909.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(serve-http): --bind HOST, default to loopback (127.0.0.1)

Adds `gbrain serve --http --bind <interface>` to control which network
interface the HTTP MCP server listens on. Default flipped from
`0.0.0.0` (pre-v0.34) to `127.0.0.1` (v0.34.0+).

Why the flip: gbrain's primary use case is a personal-knowledge brain on
a laptop. The previous default exposed brains on every interface — one
accidental `--http` invocation away from publishing the brain to a LAN.
Server operators who need remote access pass `--bind 0.0.0.0` (or a
specific interface). Codex's outside-voice on the original PR #864
correctly flagged that the additive flag wasn't actually the fix; the
default needed to change for the safety claim to hold.

If `--public-url` is set but `--bind` is unset, runServeHttp prints a
loud stderr WARN at startup recommending `--bind 0.0.0.0`. Declaring a
public URL while quietly binding loopback is almost always a
misconfiguration; we want the operator to see it on first start, not
silently fail remote requests.

Startup banner now includes a `Bind:` row so the listening interface is
visible alongside Port / Engine / Issuer.

Origin: PR #864, extended with D11 (default flip) per /plan-eng-review
codex outside-voice review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): seal source-isolation leak on read path (P0)

Pre-fix, an authenticated OAuth MCP client scoped to source-A could
enumerate source-B pages via six read-side ops: search, query (text
AND image paths), list_pages, traverse_graph, and find_experts. The
v0.31.8 source-scoping pattern shipped through dispatch.ts but the op
handlers never threaded ctx.sourceId into their engine calls, and
hybridSearch.ts:223's explicit SearchOpts rebuild dropped sourceId
even when callers passed it.

Sealing the leak:

  - src/core/operations.ts adds sourceScopeOpts(ctx), the canonical
    precedence ladder: ctx.auth.allowedSources (federated) wins over
    ctx.sourceId (scalar) wins over nothing. Threaded into all 5
    read-side op handlers + the query-image-path searchVector call
    (the 6th leak surface codex caught in plan review).

  - src/core/search/hybrid.ts:223 now threads sourceId + sourceIds
    fields through the inner SearchOpts rebuild. The explicit pick
    shape is preserved (HNSW inner-CTE ordering depends on it) but
    extended.

  - src/core/types.ts adds sourceIds?: string[] to SearchOpts +
    PageFilters (D9: federated read needs array-shaped engine filter
    or fan-out; array wins for hot retrieval).

  - src/core/operations.ts AuthInfo gains sourceId + allowedSources
    (D2: identity surface symmetric with the federated_read column
    #876 will add).

  - Both engines now apply WHERE source_id = $N (scalar) or = ANY($N::text[])
    (array) at the SQL layer for searchKeyword, searchKeywordChunks,
    searchVector, listPages, traverseGraph, traversePaths. Array form
    wins when both are set. The searchVector filter pushes into the
    inner HNSW CTE (codex flagged this placement during plan review).

  - traverseGraph + traversePaths signatures gain opts.sourceId +
    opts.sourceIds; engine.ts interface updated.

  - findExperts (the whoknows op, D3 5th leak surface) accepts
    sourceId + sourceIds and threads them into its internal
    hybridSearch call. PR #861 was authored before v0.33 shipped so
    this op wasn't covered in the original PR.

Auth wiring:

  - GBrainOAuthProvider.verifyAccessToken populates AuthInfo.sourceId
    from oauth_clients.source_id. JOIN guarded by isUndefinedColumnError
    so pre-v55 brains degrade to legacy projection rather than refusing
    every token verification.

  - GBrainOAuthProvider.registerClientManual gains a sourceId
    parameter (defaults to 'default'). DCR registerClient also sets
    source_id='default' on the inserted row.

  - serve-http.ts:929 cleanup: AuthInfo.sourceId is now a real typed
    field. The cast + GBRAIN_SOURCE env fallback chain is gone (D13).
    Legacy bearer tokens default to 'default' source in
    verifyAccessToken.

  - http-transport.ts (legacy access_tokens path) threads
    sourceId='default' through DispatchOpts so v0.22.7 callers stay
    source-scoped.

  - auth.ts CLI adds --source flag to gbrain auth register-client.

Migration v55 (D10 + D13):

  - ALTER TABLE oauth_clients ADD COLUMN source_id TEXT (nullable).
  - Backfill UPDATE source_id = 'default' WHERE source_id IS NULL —
    preserves v0.33 effective behavior verbatim for legacy clients.
  - ADD CONSTRAINT FK ... REFERENCES sources(id) ON DELETE SET NULL,
    wrapped in DO block so re-runs against fresh-install brains (where
    the FK already lives inline in SCHEMA_SQL) no-op cleanly.
  - CREATE INDEX idx_oauth_clients_source_id WHERE source_id IS NOT NULL
    for the verifyAccessToken JOIN.
  - GBRAIN_ACCEPT_SILENT_WIDEN env-flag wired through the runner via
    SET LOCAL gbrain.accept_silent_widen — reserved for future migrations
    that hit the silent-widen footgun codex flagged. This migration
    doesn't need it (column is brand new; no pre-existing stale values
    possible by definition).
  - src/core/pglite-schema.ts + src/schema.sql include the column +
    FK + index inline for fresh installs.

Tests: new test/e2e/source-isolation-pglite.test.ts with 13 regression
cases — one per leak surface (search/list_pages/traverse/etc.) plus
explicit AuthInfo.sourceId and AuthInfo.allowedSources op-handler
threading checks. Full unit suite: 6034 pass / 0 fail. PGLite
initSchema time dropped from 2.4s to 850ms after consolidating v55's
DO blocks (multiple DO blocks were slow on PGLite; one DO block for
the FK install only is fine).

Origin: PR #861 + plan-eng-review decisions D2/D3/D4/D9/D10/D13 + F2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gateway): multimodal embedding for openai-compatible providers

Pre-fix, embedMultimodal hardcoded a recipe.id === 'voyage' branch and
threw AIConfigError for every other recipe. Multimodal-capable providers
fronted by LiteLLM (or any openai-compatible proxy) were unreachable
even when the operator had wired up the model.

The fix:

  - src/core/ai/gateway.ts adds embedMultimodalOpenAICompat() that
    POSTs to the standard /embeddings endpoint with content arrays
    carrying image_url entries. Routing comes from the existing
    recipe.implementation switch — Voyage stays on its own
    /multimodalembeddings path; every other openai-compatible recipe
    flows through the new helper.

  - src/core/ai/recipes/litellm-proxy.ts declares
    supports_multimodal: true so embedMultimodal accepts the recipe.
    No multimodal_models allow-list: LiteLLM is a passthrough proxy
    and the user owns model-id selection; provider rejection (400 from
    upstream) is the right enforcement layer there. Voyage's static
    allow-list shape stays unchanged (its 12 models share
    supports_multimodal but only one is multimodal-capable).

  - D12 runtime dimension validation: the new helper checks the
    returned vector length against the recipe's declared default_dims
    (preferred) or the brain's embedding_dimensions config. Mismatch
    throws AIConfigError with model id + observed + expected so the
    operator can swap models or rebuild the column. Pre-fix, a
    wrong-dim response would surface as a cryptic pgvector
    "vector dimension mismatch" at INSERT time.

  - Auth resolution routes through the existing defaultResolveAuth
    helper so optional-auth recipes (LiteLLM proxy with no
    LITELLM_API_KEY) and required-auth recipes both share one code
    path. Optional-auth sends "Authorization: Bearer unauthenticated"
    which servers like Ollama / llama-server ignore but the SDK
    contract requires.

Tests: 11 new cases in test/openai-compat-multimodal.test.ts cover
happy-path, multi-input batching, unauthenticated proxy, D12 dim
mismatch + default-dim fallback, 401 / 400 / malformed-JSON / non-array
error paths, and an explicit Voyage-regression test pinning that the
new openai-compat route doesn't accidentally hijack the Voyage path.
All 41 multimodal-related tests pass (existing voyage suite + new).
typecheck clean.

Origin: PR #875 + plan-eng-review D12 (runtime dim validation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(oauth): federated_read read scope (#876)

Pre-fix, OAuth clients had a single source-scope axis (source_id, added
in v55). A client could either write+read one source OR be a super-reader
across all sources (via NULL source_id). There was no middle ground —
WeCare-style L3 dept clients that need to write to dept-x but read
dept-x + parent canon + shared canon had no expression.

#876 adds federated_read TEXT[] as an orthogonal read-scope axis. source_id
is the WRITE authority; federated_read is the READ authority. They default
to matching values (read scope == write scope, the pre-v0.34 default)
when a client is registered without an explicit federated read list.

Migrations v56-v60 (six new migrations on top of v55):

  - v56: ALTER TABLE ... ADD COLUMN federated_read TEXT[] NOT NULL DEFAULT '{}'.
  - v57 (F5): explicit CASE backfill so source_id IS NULL → '{}' (not an
    array containing NULL — codex caught this ambiguity during plan review).
  - v58: post-backfill validation. Fails loud if any row's source_id isn't
    in its federated_read array, pointing at a logic bug in v57 if fired.
  - v59: flip the source_id FK from ON DELETE SET NULL to ON DELETE
    RESTRICT now that federated_read provides the alternative scope-loss
    path. Pre-flip, deleting a source could silently widen any oauth_client
    to super-reader; post-flip, source delete is refused if any client
    references it (operator must revoke/re-scope first).
  - v60: GIN index on federated_read for array-containment queries.

Auth wiring:

  - GBrainOAuthProvider.verifyAccessToken JOINs c.federated_read and
    populates AuthInfo.allowedSources. Pre-v56 / pre-v55 brains degrade
    via the existing isUndefinedColumnError fallback chain.
  - registerClientManual gains a federatedRead?: string[] parameter
    (defaults to [sourceId]).
  - DCR registerClient sets source_id='default' + federated_read=['default']
    on the inserted row.
  - auth.ts CLI adds --federated-read SRC1,SRC2,... flag. The
    register-client output now prints "Federated reads:" so operators
    confirm the scope they set.

Engines consume the federated array through the SearchOpts.sourceIds /
PageFilters.sourceIds field that #861 added (no engine changes here — the
plumbing was D9). sourceScopeOpts in operations.ts already prefers the
auth.allowedSources array over scalar ctx.sourceId when set.

Test seam:
  - test/book-mirror.test.ts now spawns the CLI with GBRAIN_HOME pointed
    at a tempdir so the test isn't sensitive to the developer's local
    ~/.gbrain/config.json. Pre-fix the test could silently inherit a real
    Postgres connection and hang past the default 5s test timeout. Fresh
    GBRAIN_HOME → "No brain configured" → exit 1 in <1s.
  - test/e2e/source-isolation-pglite.test.ts gains one more regression
    case: AuthInfo.allowedSources = [] (explicit empty) MUST NOT widen
    scope to "all sources" — the silent-widen footgun precedence ladder.
  - test/openai-compat-multimodal.test.ts is part of the wave's commits
    via the migrate.ts changes that bump the schema chain. typecheck-only
    fix on a captured-auth type was already in #875's tree.

6045 unit tests pass / 0 fail. typecheck clean. PGLite initSchema runs
v55-v60 in ~786ms total (within the test-harness budget for tests using
the canonical beforeAll engine pattern).

Origin: PR #876 + plan-eng-review F5 (CASE backfill).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.34.0.0: MCP fix wave (#870 #909 #864 #861 #875 #876)

VERSION + package.json + CHANGELOG bump for the six-PR MCP fix wave.
Schema chain extends from v54 → v60; oauth_clients gains source_id +
federated_read columns; auth'd MCP clients now stay inside their scope
across all read-side ops; PKCE-only DCR works; --bind defaults to
loopback; LiteLLM multimodal embedding ships.

Contributed by @Hansen1018 (#870), @ding-modding (#909), @DukeDawg
(#864), @toilalesondev (#861 + #876), @yoelgal (#875).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.34.0.0

Sync README, CLAUDE.md, SECURITY.md, docs/architecture/topologies.md,
and docs/mcp/DEPLOY.md to reflect the v0.34.0.0 MCP fix wave:

- README: document --bind HOST default (loopback), --source +
  --federated-read register-client flags, PKCE public-client gate
- SECURITY.md: note loopback-by-default for serve --http, update the
  trust-proxy contract to point at the new default
- CLAUDE.md: annotate operations.ts (sourceScopeOpts helper),
  oauth-provider.ts (verifyAccessToken JOIN + PKCE public clients),
  serve-http.ts (--bind flag), gateway.ts (openai-compat multimodal +
  dim validation), mcp/server.ts (MCP_STDIO guard), auth.ts (--source
  + --federated-read), migrate.ts (v58-v63 chain), engine.ts
  (sourceIds field). Add 4 new test-file entries for
  source-isolation-pglite, openai-compat-multimodal,
  serve-stdio-lifecycle, oauth.test.ts PKCE cases
- docs/architecture/topologies.md: source-scoped register-client
  example, --bind 0.0.0.0 for thin-client host setup
- docs/mcp/DEPLOY.md: --bind explanation in the ngrok section,
  source-scoped client recipe
- llms-full.txt: regenerated per the CLAUDE.md-edit chaser rule

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump v0.34.0.0 → v0.34.1.0

Renumbering the MCP fix wave from v0.34.0.0 to v0.34.1.0 so the
release slot lands between master's v0.33.2.1 and the next minor.

Touches every release-artifact mention:
- VERSION: 0.34.0.0 → 0.34.1.0
- package.json: same
- CHANGELOG.md header + "To take advantage" block
- CLAUDE.md key-files annotations (8 entries that document this wave)
- llms-full.txt (regen from CLAUDE.md)
- README.md / SECURITY.md / docs/architecture/topologies.md / docs/mcp/DEPLOY.md
- Wave code-comment markers ("// v0.34.0 (#NNN):" → "// v0.34.1 (#NNN):")

Test files renamed alongside since they were committed with the wave.

Commit subjects on the original 6 PR commits + the v0.34.0.0 bump
commit (4f533c726b47db7e) intentionally NOT rewritten — those are
history. `git log` finds the implementation by message subject, not by
version tag.

6275 unit tests pass, typecheck clean, migration chain v58-v63 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:15:29 -07:00
cb02932388 v0.26.9 fix(oauth): RFC 6749 hardening + close HTTP MCP shell-job RCE (#628)
* fix(mcp): close HTTP MCP shell-job RCE + tighten remote contract

The HTTP MCP transport in serve-http.ts inlined its own OperationContext
literal and forgot to set `remote: true`. With the field undefined at the
operations.ts protected-job-name guard (line 1391), an HTTP MCP caller
holding a write-scoped OAuth token could submit `submit_job {name: "shell"}`
and execute arbitrary commands on the gbrain host (RCE-class).

Two-layer fix:

1. F7 — explicit `remote: true` on the inlined /mcp OperationContext.
   Stdio MCP at src/mcp/dispatch.ts:61 already set this; the HTTP path
   was the regression.

2. F7b — fail-closed contract on the four ctx.remote consumer sites in
   operations.ts (auto-link skip, telemetry x2, protected-job guard).
   The protected-job guard flips from `if (ctx.remote && ...)` to
   `if (ctx.remote !== false && ...)` and the trusted-marker site flips
   from `!ctx.remote && ...` to `ctx.remote === false && ...`. Anything
   that isn't strictly `false` now treats the caller as remote/untrusted.

3. D12 — `OperationContext.remote` becomes REQUIRED in the TypeScript
   type. The compiler now catches future transports that forget the field.
   The runtime fail-closed defaults are belt+suspenders for any caller
   that bypasses the type via `as` cast or `Partial<>` spread.

Tests:

- New `test/trust-boundary-contract.test.ts` (4 cases) pins the
  fail-closed semantics: undefined-via-cast rejects, remote=true rejects,
  remote=false allowed (only path that escalates protected-name jobs).

- `test/e2e/serve-http-oauth.test.ts` adds 2 cases asserting HTTP MCP
  cannot submit `shell` or `subagent` jobs even with read+write scope.

- `test/e2e/graph-quality.test.ts` adds the now-required `remote: false`
  to its fixture (e2e graph quality simulates local-CLI writes).

Verification: bun test -> 3742 pass / 0 fail. typecheck clean.

Thanks to @ElectricSheepIO on X for the security review that surfaced
this trust-boundary regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): RFC 6749 hardening + serve-http defense in depth

OAuth provider hardening pass that brings the provider into RFC compliance
on auth code, refresh token, and revocation flows, and tightens the
serve-http surface around request logging and admin cookies.

Provider (src/core/oauth-provider.ts):

- F1: bind client_id atomically into the auth code DELETE WHERE clause for
  exchangeAuthorizationCode + challengeForAuthorizationCode. Previous
  pattern (DELETE...RETURNING then post-hoc client compare) burned codes
  on the wrong-client path so the legitimate client could not retry.
  RFC 6749 §10.5.

- F2: same atomic predicate on exchangeRefreshToken. The pre-fix shape
  defeated RFC 6749 §10.4's stolen-token detection by letting attacker +
  victim both succeed.

- F3: refresh token rejects requested scopes that are not a subset of the
  ORIGINAL grant on the row. Codex C9: subset is checked against the
  recorded grant, not the client's currently-allowed scopes (which can
  expand later); omitted scope inherits the original verbatim and stays
  distinct from explicit-empty. RFC 6749 §6.

- F4: revokeToken adds AND client_id to the DELETE so a client cannot
  revoke another client's tokens by guessing the hash. RFC 7009 §2.1.

- F5: deleted_at and token_ttl column probes use a new
  isUndefinedColumnError helper (extracted to src/core/utils.ts per D14)
  that matches SQLSTATE 42703 or column-name-in-message. Bare catch{}
  used to swallow lock timeouts, network blips, and auth failures as
  "column missing" — fail-open posture in a security path.

- F6: sweepExpiredTokens uses RETURNING 1 + array length. Pre-fix
  (result as any).count returned 0 on at least one engine even when
  rows were deleted, and codes were never counted.

- F7c: NEW finding eva-brain missed. exchangeAuthorizationCode now folds
  redirect_uri into the atomic DELETE predicate when the parameter is
  provided. Stored on /authorize, never compared on /token before this
  commit. RFC 6749 §4.1.3 violation. Back-compat: when caller omits the
  parameter the predicate is skipped, preserving SDK consumers that
  haven't adopted the parameter yet.

- F12 (cleanup, not security): dcrDisabled constructor option replaces
  the prior monkey-patch of _clientsStore in serve-http.ts. The SDK's
  mcpAuthRouter only wires up /register when the store exposes
  registerClient, so omitting the method via the constructor is
  sufficient. Reframed as cleanup per codex C10 — the monkey-patch
  happened before mcpAuthRouter ran, so the prior shape did not have
  a real security regression to claim.

Dispatch (src/mcp/dispatch.ts):

- F8: new summarizeMcpParams(opName, params) intersects submitted keys
  against the operation's declared params allow-list. Returns
  {redacted, kind, declared_keys, unknown_key_count, approx_bytes}.
  Closes the codex C8 leak: a naive "dump all submitted keys" summary
  still echoed attacker-controlled key names like
  put_page {"wiki/people/sensitive_name": "..."} into mcp_request_log
  + the SSE feed. Allow-list pattern keeps debug visibility on declared
  keys while counting unknowns without naming them.

Serve-http (src/commands/serve-http.ts) + serve (src/commands/serve.ts):

- F8 wiring: mcp_request_log + SSE broadcast routed through
  summarizeMcpParams by default. New --log-full-params flag bypasses
  redaction with a loud stderr warning at startup. Default privacy-
  positive; flag is the documented escape hatch for self-hosted
  operators debugging on their own laptop.

- F9: admin cookies set Secure when req.secure OR issuerUrl.protocol
  is https. Cloudflare-tunnel + reverse-proxy deployments where the
  inside-tunnel hop looks like http but the public URL is https now
  tag cookies correctly.

- F10: bound magicLinkNonces with NONCE_LRU_CAP. Previously only the
  consumed-nonces map was capped; an attacker (or misbehaving agent)
  with the bootstrap token could mint nonces faster than they expired
  and grow the live store unbounded.

- F12: dcrDisabled flows through to the provider constructor instead of
  monkey-patching _clientsStore after construction.

- F14: try/catch wraps StreamableHTTPServerTransport setup +
  handleRequest. SDK-level throws no longer fall through to express's
  default HTML error page; clients expecting JSON-RPC envelopes get a
  JSON 500 instead.

- F15: error envelope unified via buildError + serializeError from
  src/core/errors.ts. OperationError and unexpected exceptions both
  emit the same {class, code, message, hint} shape so clients can
  pattern-match a single envelope.

Tests:

- test/oauth.test.ts adds 11 cases:
  * F1+F2 wrong-client cannot consume / read PKCE / burn refresh,
    paired with owner-still-redeems atomically afterward (codex D6 —
    proves the predicate doesn't burn the row on attacker attempts).
  * F3 refresh scope subset enforced.
  * F4 wrong-client cannot revoke.
  * F5 non-schema SQL not swallowed by client_credentials soft-delete probe.
  * F6 sweepExpiredTokens returns count > 0 after deleting rows.
  * F7c redirect_uri match succeeds, mismatch rejects, omitted preserves
    back-compat for callers that don't pass the parameter.
  * F12 dcrDisabled constructor option exposes only getClient,
    registerClientManual still works.

- test/mcp-dispatch-summarize.test.ts (NEW, 6 cases): pins the F8
  privacy invariants. The codex-C8 attacker-key-name probe asserts that
  a sensitive name submitted as a key never appears anywhere in the
  redactor's output.

Verification: bun run typecheck clean. test/oauth.test.ts 55/55,
test/mcp-dispatch-summarize.test.ts 6/6,
test/trust-boundary-contract.test.ts 4/4 from commit A. The one
unrelated unit failure surfaces on master too — environment-sensitive
test that expects ~/.gbrain/config.json to be absent in the test env.

Out of scope: F11 (auth register-client --redirect-uri flag) and F13
(serve --http argv positive-int validator) per codex C11 — operator
UX gaps, not trust-boundary fixes. Filed as follow-up TODOs.

Thanks to @ElectricSheepIO on X for the security review that surfaced
this hardening pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: file F11 + F13 as OAuth hardening follow-up TODOs

Codex C11 flagged these as scope creep on the v0.26.7 OAuth hardening
PR (operator UX, not trust-boundary). Capturing them here so the
context survives — eva-brain has both implementations and the lift is
mechanical when we want to do them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): close adversarial-review findings on F7c + F8

Two bugs surfaced by an adversarial subagent during /ship's pre-landing
review pass that the codex + plan-eng-review didn't catch.

D15 / F7c: `exchangeAuthorizationCode` used `redirectUri ? ...` ternary
to choose the with-redirect vs no-redirect SQL. Empty string fell
through to the no-redirect branch, so a caller submitting
`redirect_uri=""` at /token bypassed the binding entirely. RFC 6749
§4.1.3 spec violation. Switch to `redirectUri !== undefined`. Test:
empty-string redirect_uri must reject when /authorize stored a real URI.

D16 / F8: `summarizeMcpParams` published exact byte length via
`approx_bytes = JSON.stringify(params).length`. Submitting put_page with
a known prefix and observing the resulting log entry across repeated
probes lets an attacker binary-search the size of secret suffix content.
Bucket to 1KB resolution. The redacted summary keeps a coarse
"roughly how big" signal for operators while making size-based
side-channel attacks useless.

Test count: 65 → 67 across the three new test files.
Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.26.9)

OAuth 2.1 hardening + HTTP MCP shell-job RCE fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.26.9

Annotate CLAUDE.md key-files entries with v0.26.9 OAuth/MCP hardening pass:
- src/core/operations.ts: D12 (OperationContext.remote required) + F7b
  (4-site fail-closed flip), HTTP MCP shell-job RCE close
- src/core/utils.ts: D14 isUndefinedColumnError extracted helper
- src/mcp/dispatch.ts: F8 summarizeMcpParams privacy redactor with
  declared-keys allow-list + 1KB byte bucketing
- src/commands/serve-http.ts: F7+F8+F9+F10+F12+F14+F15 hardening
- src/core/oauth-provider.ts: F1+F2+F3+F4+F5+F6+F7c+F12 RFC 6749/7009
  hardening pass

Add new test-file entries for test/mcp-dispatch-summarize.test.ts
(7 cases) and test/trust-boundary-contract.test.ts (4 cases). Extend
test/oauth.test.ts (+14 cases) and test/e2e/serve-http-oauth.test.ts
(+2 RCE-close regressions) entries with v0.26.9 case counts.

README.md: added --log-full-params to gbrain serve --http surface.

SECURITY.md: documented mcp_request_log.params redaction default
({redacted, kind, declared_keys, unknown_key_count, approx_bytes}) +
--log-full-params opt-in.

docs/mcp/DEPLOY.md: operator-facing note on SSE feed + audit log
redaction default and when to flip --log-full-params on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:11:15 -07:00
d3b52edeba v0.22.7 fix: built-in HTTP transport with bearer auth for remote MCP (#483)
* fix: add built-in HTTP transport with bearer auth for remote MCP

Adds `gbrain serve --http` with token-based authentication using the
existing access_tokens table. Eliminates the need for standalone OAuth
wrappers that may have insecure open registration endpoints.

- New: src/mcp/http-transport.ts — HTTP+SSE transport with bearer auth
- New: SECURITY.md — security advisory for remote MCP deployments
- Updated: serve command accepts --http and --port flags
- Updated: DEPLOY.md recommends --http for remote access
- Bump: 0.22.4 → 0.22.5

* chore: extract shared MCP dispatch + rate-limit modules

dispatch.ts is the single source of truth for stdio + HTTP transport: validateParams,
OperationContext build, handler invocation, error formatting. Server.ts refactored to
use it. Prevents the F1-F3 transport-drift bugs where stdio and HTTP independently
implemented dispatch logic differently (reversed args, missing context fields, no
param validation).

rate-limit.ts: bounded-LRU token-bucket. Tracks lastTouchedMs separately from
lastRefillMs so an exhausted key can't be reset by hammering past the TTL.

* feat: HTTP transport hardening + F1-F3 dispatch bug fixes

Rewrite of src/mcp/http-transport.ts on top of the new dispatch.ts and rate-limit.ts:

- F1 fix: dispatch via shared dispatchToolCall(ctx, params) — was reversed args
  (params, ctx) before, would have crashed every real tools/call.
- F2 fix: full OperationContext (engine, config, logger, dryRun, remote) — was
  only {engine, remote: true} before.
- F3 fix: validateParams runs on HTTP path — was skipped before.
- Engine.kind fail-fast: clear error message on PGLite (access_tokens table is
  Postgres-only by design).
- CORS: default-deny via GBRAIN_HTTP_CORS_ORIGIN allowlist.
- Body cap: stream-counted via req.body reader, catches chunked transfers
  without Content-Length. Default 1 MiB via GBRAIN_HTTP_MAX_BODY_BYTES.
- Rate limit: pre-auth IP bucket fires BEFORE DB lookup (limits brute-force
  load), post-auth token-id bucket fires after auth (limits runaway clients).
  Both bounded LRU with TTL prune.
- mcp_request_log: per-request audit row reusing the existing schema (v4).
- last_used_at SQL-level debounce: WHERE last_used_at < now() - interval
  '60 seconds'. Race-tolerant under PgBouncer.
- Response shape: application/json (gbrain MCP tools don't stream).
  Streamable-HTTP transport spec compliant for non-streaming responses.
- X-Forwarded-For honored only when GBRAIN_HTTP_TRUST_PROXY=1.

* feat: wire gbrain auth into the main CLI

The original PR's docs referenced 'gbrain auth create/list/revoke' but auth.ts
was a standalone script never wired to the CLI dispatcher. Running 'gbrain auth'
from the compiled binary returned 'Unknown command'.

- auth.ts: extract the dispatch into runAuth(args) + import.meta.main guard
  so direct-script invocation still works (bun run src/commands/auth.ts ...).
- cli.ts: add 'auth' to CLI_ONLY set + handler in handleCliOnly that imports
  runAuth and dispatches without requiring an engine connection (auth.ts
  manages its own postgres() connection).

* test: HTTP transport unit + E2E coverage (23 + 8 cases)

test/http-transport.test.ts — 23 unit cases against mocked engine.sql:
  - Auth: valid/missing/no-Bearer/unknown/revoked/health-bypass (1-6)
  - F1+F2 round-trip via dispatch.ts (7) — regression guard for reversed args
  - F3 invalid_params via validateParams (8) — regression guard
  - Response Content-Type application/json, not SSE (9)
  - CORS default-deny + allowlist + non-match (10-12)
  - Body cap: Content-Length + chunked-transfer (13-14)
  - Rate limit: refill, exhaust+Retry-After, LRU eviction, TTL prune,
    pre-auth IP fires before DB, /health bypasses (15-20)
  - mcp_request_log audit: success row + auth_failed row (21-22)

test/e2e/http-transport.test.ts — 8 cases against real Postgres:
  - /health, tools/list, tools/call list_pages (real op round-trip),
    revoked → 401, last_used_at debounce within 60s (asserts ONE update),
    debounce 65s gap (asserts TWO updates), mcp_request_log row check,
    invalid_params via real handler.

* docs: v0.22.7 CHANGELOG + SECURITY.md + DEPLOY.md

CHANGELOG: v0.22.7 release notes covering the F1-F3 dispatch fixes, the full
hardening surface (CORS default-deny, two-bucket rate limit, body cap, audit
log), and the upgrade path. Master's v0.22.6 schema-verify entry stitched in
above (preserving merge ordering).

SECURITY.md: full hardening reference for gbrain serve --http — Postgres-only
caveat, CORS allowlist, rate limit + tunnel caveat, body cap, audit log query,
GBRAIN_HTTP_TRUST_PROXY warning.

docs/mcp/DEPLOY.md: Postgres-only call-out, env var summary, fail-fast behavior
on PGLite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: codex review follow-ups (DB-probing /health + XFF trust safety contract)

- /health now does SELECT 1 against Postgres and returns 503 + status:unhealthy
  when the DB is unreachable. Prevents the failure mode where orchestration
  sees green pods while clients get misleading 401s during a DB outage.
- SECURITY.md: tighten the GBRAIN_HTTP_TRUST_PROXY=1 guidance with the explicit
  two-condition safety contract — gbrain bound to a private interface AND the
  proxy strips client-supplied XFF. Without both, the flag enables IP spoofing
  past the pre-auth rate limit.
- Tests: add 6b (/health DB-down → 503) + assert db:'ok' on the happy path.

Caught by codex adversarial review during /ship Step 11.

* docs: TODOS.md — v0.22.7 follow-ups (audit volume, validateParams enums, SSE, scopes)

* docs: update project documentation for v0.22.7

CLAUDE.md: document src/mcp/dispatch.ts, src/mcp/rate-limit.ts, and the
rewritten src/mcp/http-transport.ts in the Key files section. Add
test/http-transport.test.ts (23 unit cases) and test/e2e/http-transport.test.ts
(8 E2E cases) to the test inventories.

CHANGELOG.md: fix copy-paste version mismatches inside the v0.22.7 entry that
referenced v0.22.5 (header line + "To take advantage of" block).

README.md: replace the standalone bun-run auth invocation with the wired-in
gbrain auth CLI; add gbrain serve --http startup step to the Remote MCP
example; surface gbrain auth in the admin command list; link SECURITY.md
from the Remote MCP section so it's discoverable.

SECURITY.md: align "as of v0.22.5" callouts with the actual release version
(v0.22.7).

docs/mcp/DEPLOY.md: align v0.22.5+ callout with v0.22.7+; switch token-management
examples from `bun run src/commands/auth.ts` to `gbrain auth` now that auth is
in the main CLI.

docs/mcp/ALTERNATIVES.md: drop the "planned but not yet implemented" note for
gbrain serve --http; document that the built-in HTTP transport is the
recommended path.

docs/mcp/{CLAUDE_DESKTOP,CLAUDE_COWORK,CLAUDE_CODE,PERPLEXITY}.md: switch
token-creation examples from `bun run src/commands/auth.ts create` to
`gbrain auth create` to match the wired-in CLI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: typecheck — cast CallToolRequestSchema handler return to any

MCP SDK 1.29 widened the response type for setRequestHandler(CallToolRequestSchema, ...)
to require a 'task' field for managed-task responses. gbrain ops are synchronous and
return the legacy { content, isError? } shape, which is still valid via the SDK's
ServerResult union. Casting the handler return type to any silences the narrowing
that broke after dispatch.ts was extracted (the original inline handler dodged this
because TypeScript inferred its return as any from the function body).

CI failure: src/mcp/server.ts(25,51): error TS2345 — Property 'task' is missing in
type 'ToolResult' but required in type '{ ...; task: { taskId: string; ... }; ... }'.
Caught by the 'test' job's bun run typecheck step at PR #483 commit 65ea9e7.

* docs: regenerate llms-full.txt after master merge

The build-llms regen-drift guard fails when committed llms.txt + llms-full.txt
don't match what scripts/build-llms.ts produces from current source. Master's
v0.22.6.1 merge brought in new content (CLAUDE.md entries, CHANGELOG, etc.)
that hadn't been folded into the bundle. Running 'bun run build:llms' to sync.

llms.txt unchanged; llms-full.txt picks up the new entries.

* docs: CHANGELOG — scrub attack-surface enumeration from v0.22.7 entry

Per CLAUDE.md responsible-disclosure rule: 'when a release fixes a security
gap or a user-impacting bug, describe the fix functionally. Do not enumerate
the attack surface, quantify the exposure window, or highlight the most
sensitive records by name in public-facing artifacts.'

Removed:
- Lead-paragraph attack-chain ('attacker who discovers URL → POST /register
  → client_credentials → read entire brain'). Public-doc readers don't need
  the directed probe path.
- 'Bug fixes folded in' section that itemized prior-version failure modes.
  Reframed as a 'transport refactor' note in the For Contributors section,
  describing the dispatch consolidation functionally without claiming the
  prior version was broken in specific ways.
- 'Without the OAuth footgun' lead headline. The fix's mechanism (built-in
  bearer auth via access_tokens) is already self-evident from the headline.
- F1/F2/F3 internal labels and 'caught by codex outside-voice during
  planning' parenthetical.

Kept:
- The full hardening reference table (configuration / behavior, not exposure).
- 'gbrain serve --http' user-facing operator ergonomics.
- 'Postgres-only by design' known-limit framing.
- Dispatch consolidation as a contributor-facing single-source-of-truth note.

SECURITY.md left intact: its OAuth-deployment guidance is generic 'if you
deploy MCP behind a custom HTTP wrapper, here are the rules' framing, not
gbrain-version-specific exposure. That's defensible under the same rule.

* docs: SECURITY.md — drop unverified security@garrytan.com address

The address was in the original PR's SECURITY.md commit (6e740590, author
'root <root@localhost>' — machine-generated) and never verified to exist or
forward anywhere. A non-monitored disclosure address is worse than no address
at all: reports go to a black hole.

Keep the GitHub private security advisory link as the sole disclosure channel.
GitHub Security Advisories is the working path most researchers reach for
first anyway — restricted-access by default, scopes the conversation to
maintainers, and integrates with CVE issuance when needed.

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 17:01:40 -07:00