brain-mcp: one shared bearer for every client, no per-caller read scoping (OWASP MCP07/MCP10) #144

Closed
opened 2026-10-01 16:34:43 +00:00 by mathias · 5 comments
Owner

Found during an OWASP MCP Top 10 (2025) audit, 2026-10-01.

Gap

  1. One identity for all clients. /mcp is guarded by chassisauth.BearerMiddleware(mcpToken, jwtValidator, ...). The opt-in OAuth client_credentials endpoint (cmd/server/main.go, POST /oauth/token) returns AccessToken: mcpToken — the same static bearer. Claude Code on koala, the claude.ai connector, Crush etc. are therefore indistinguishable on the read path; revoking one revokes all.
  2. No read-path isolation. Root CLAUDE.md says "client projects filter to {client} + public". Nothing in internal/mcp/handlers.go enforces that per caller: wing on brain_query is an optional argument the model chooses. Any holder of the token reads every wing. The I1 gate (capture, internal/classification) protects the write side only.
  3. Tool calls are not audited — only capture and governance edits go through internal/audit. brain_query/brain_read leave no record of who read what.

Why it matters

Item 2 is the one that matters: the stated client-confidentiality rule is a convention, not a control. Same failure mode as verify-the-cited-control-not-the-comment.

Decision needed before building

Is the brain meant to hold confidential client content at all? If no (current classification refuses it on capture), then item 2 is low and the fix is to correct CLAUDE.md's claim. If yes, it needs per-principal scopes (JWT claim → allowed wings) enforced in the handler.

Done when

  • CLAUDE.md's scoping claim either matches enforced behaviour or is removed.
  • Per-client tokens (or JWT sub) distinguish callers in logs.
  • One audit line per tools/call with principal + tool + wing.

OWASP refs: MCP07 Insufficient AuthN/AuthZ, MCP10 Context Over-Sharing, MCP08.


Created via git-mcp on behalf of @mathias

Found during an OWASP MCP Top 10 (2025) audit, 2026-10-01. ## Gap 1. **One identity for all clients.** `/mcp` is guarded by `chassisauth.BearerMiddleware(mcpToken, jwtValidator, ...)`. The opt-in OAuth `client_credentials` endpoint (`cmd/server/main.go`, `POST /oauth/token`) returns `AccessToken: mcpToken` — the *same* static bearer. Claude Code on koala, the claude.ai connector, Crush etc. are therefore indistinguishable on the read path; revoking one revokes all. 2. **No read-path isolation.** Root CLAUDE.md says *"client projects filter to `{client}` + `public`"*. Nothing in `internal/mcp/handlers.go` enforces that per caller: `wing` on `brain_query` is an optional argument the model chooses. Any holder of the token reads every wing. The I1 gate (`capture`, `internal/classification`) protects the **write** side only. 3. **Tool calls are not audited** — only `capture` and governance edits go through `internal/audit`. `brain_query`/`brain_read` leave no record of who read what. ## Why it matters Item 2 is the one that matters: the stated client-confidentiality rule is a convention, not a control. Same failure mode as `verify-the-cited-control-not-the-comment`. ## Decision needed before building Is the brain meant to hold confidential client content at all? If **no** (current classification refuses it on capture), then item 2 is low and the fix is to correct CLAUDE.md's claim. If **yes**, it needs per-principal scopes (JWT claim → allowed wings) enforced in the handler. ## Done when - CLAUDE.md's scoping claim either matches enforced behaviour or is removed. - Per-client tokens (or JWT `sub`) distinguish callers in logs. - One audit line per `tools/call` with principal + tool + wing. OWASP refs: MCP07 Insufficient AuthN/AuthZ, MCP10 Context Over-Sharing, MCP08. --- _Created via git-mcp on behalf of @mathias_
mathias added the risk/mediumsize/mtype/research labels 2026-10-01 16:34:43 +00:00
Author
Owner

Decision (Mathias, 2026-10-01): the brain does NOT hold confidential client content

So read-path wing scoping is not needed. Consequences:

  • CLAUDE.md claim ("client projects filter to {client} + public") gets corrected in mathias/local-dev .context/AGENT.md — done separately, not in this repo.
  • Per-client tokens need secret provisioning (ESO/1Password), which a headless executor cannot do — split out to its own issue.
  • This issue is now narrowed to: one audit line per tools/call.

Spec for the executor

In internal/mcp/server.go, around handleCall (dispatch for every tool), emit one structured slog record per call:

  • msg: "mcp tool call", tool, principal, origin (static/jwt/unknown, from principalInfo in ctx), wing/hall when present in args, outcome (ok / error string truncated to 200 chars), duration_ms.
  • Never log content (brain_write/brain_update/capture bodies) — log content_bytes instead. The audit line must not become a second copy of brain content.
  • Use the server's existing logger so it lands in Loki like the rest.

Tests (red first)

  • TestHandleCallLogsOneAuditRecordPerCall — capture log output, call brain_query, assert exactly one record with tool/principal/outcome.
  • TestHandleCallAuditOmitsContent — brain_write with a sentinel content string; assert the sentinel is absent from the log and content_bytes is present.
  • TestHandleCallAuditRecordsErrorOutcome — unknown tool / failing handler → outcome is the error.

Done when

task check green; after deploy, {app="ingestion"} |= "mcp tool call" in Loki shows records for a live brain_query.


Created via git-mcp on behalf of @mathias

## Decision (Mathias, 2026-10-01): the brain does NOT hold confidential client content So read-path wing scoping is not needed. Consequences: - **CLAUDE.md claim** ("client projects filter to `{client}` + `public`") gets corrected in `mathias/local-dev` `.context/AGENT.md` — done separately, not in this repo. - **Per-client tokens** need secret provisioning (ESO/1Password), which a headless executor cannot do — split out to its own issue. - **This issue is now narrowed to: one audit line per `tools/call`.** ## Spec for the executor In `internal/mcp/server.go`, around `handleCall` (dispatch for every tool), emit one structured `slog` record per call: - `msg: "mcp tool call"`, `tool`, `principal`, `origin` (`static`/`jwt`/`unknown`, from `principalInfo` in ctx), `wing`/`hall` when present in args, `outcome` (`ok` / error string truncated to 200 chars), `duration_ms`. - **Never log `content`** (brain_write/brain_update/capture bodies) — log `content_bytes` instead. The audit line must not become a second copy of brain content. - Use the server's existing logger so it lands in Loki like the rest. ## Tests (red first) - `TestHandleCallLogsOneAuditRecordPerCall` — capture log output, call `brain_query`, assert exactly one record with tool/principal/outcome. - `TestHandleCallAuditOmitsContent` — `brain_write` with a sentinel content string; assert the sentinel is absent from the log and `content_bytes` is present. - `TestHandleCallAuditRecordsErrorOutcome` — unknown tool / failing handler → `outcome` is the error. ## Done when `task check` green; after deploy, `{app="ingestion"} |= "mcp tool call"` in Loki shows records for a live `brain_query`. --- _Created via git-mcp on behalf of @mathias_
mathias added the status:dispatch label 2026-10-01 18:33:27 +00:00
Author
Owner

Correction: status:dispatch does nothing here. The dispatch watcher has been suspended since 2026-09-17 (dispatch#56), and the CI agent lane exists only on cobalt-dingo. Taking this interactively instead, via a PR and gatekeeper review. The CLAUDE.md claim is already corrected: local-dev 12f694b (v0.18.1). The label is inert while the watcher is frozen (no unlabel tool, gitea-mcp#61).


Created via git-mcp on behalf of @mathias

Correction: `status:dispatch` does nothing here. The dispatch watcher has been suspended since 2026-09-17 (dispatch#56), and the CI agent lane exists only on cobalt-dingo. Taking this interactively instead, via a PR and gatekeeper review. The CLAUDE.md claim is already corrected: local-dev `12f694b` (v0.18.1). The label is inert while the watcher is frozen (no unlabel tool, gitea-mcp#61). --- _Created via git-mcp on behalf of @mathias_
Author
Owner

pr-gatekeeper: PR #147 BLOCKED at 32dedb3. On the audit-log half, one audit line per tools/call with tool, principal, origin, wing and hall works, verified by execution. But the outcome field copies caller content through error strings: a claim-envelope rejection echoes the frontmatter value, and brain_update echoes the path (E6). The spec says never log content. The 200-char truncation has no test. Details: #147 (comment)

pr-gatekeeper: PR #147 BLOCKED at 32dedb3. On the audit-log half, one audit line per tools/call with tool, principal, origin, wing and hall works, verified by execution. But the outcome field copies caller content through error strings: a claim-envelope rejection echoes the frontmatter value, and brain_update echoes the path (E6). The spec says never log content. The 200-char truncation has no test. Details: https://git.d-ma.be/mathias/hyperguild/pulls/147#issuecomment-13961
Author
Owner

pr-gatekeeper: PR #147 merged as a6da933. One structured mcp tool call slog record per tools/call, carrying tool, principal, origin, wing, hall, content_bytes, duration_ms and outcome (ok/error only, so no argument values are echoed). Verified by execution with a sentinel across capture, brain_write, brain_edit, brain_update, brain_query and brain_read: no content in the record. Tests go red when content is logged or the error text comes back.

Reopening: the narrowed done-when includes "after deploy, {app=\"ingestion\"} |= \"mcp tool call\" in Loki shows records for a live brain_query". That needs a deploy, and the merge auto-closed this issue before it was checked. Close once the Loki query shows a live record.

pr-gatekeeper: PR #147 merged as a6da933. One structured `mcp tool call` slog record per tools/call, carrying tool, principal, origin, wing, hall, content_bytes, duration_ms and outcome (ok/error only, so no argument values are echoed). Verified by execution with a sentinel across capture, brain_write, brain_edit, brain_update, brain_query and brain_read: no content in the record. Tests go red when content is logged or the error text comes back. **Reopening:** the narrowed done-when includes "after deploy, `{app=\"ingestion\"} |= \"mcp tool call\"` in Loki shows records for a live brain_query". That needs a deploy, and the merge auto-closed this issue before it was checked. Close once the Loki query shows a live record.
mathias reopened this issue 2026-10-01 21:07:32 +00:00
Author
Owner

The last done-when clause is verified live. PR #147 deployed as ingestion:a6da933 (infra 36916a3), and the rollout completed. I then ran a live brain_query through the plugin MCP, and Loki (loki-gateway, query {namespace="supervisor"} |= "mcp tool call" |= "brain_query") returned:

2026/10/01 21:09:13 INFO mcp tool call tool=brain_query principal=local-cli origin=static duration_ms=606 outcome=ok

Every clause now holds:

  • the CLAUDE.md claim is corrected (local-dev v0.18.1)
  • the audit line is shipped with tests
  • the record is live in Loki

Per-client tokens are split out to #146. Closing.


Created via git-mcp on behalf of @mathias

The last done-when clause is verified live. PR #147 deployed as `ingestion:a6da933` (infra `36916a3`), and the rollout completed. I then ran a live `brain_query` through the plugin MCP, and Loki (`loki-gateway`, query `{namespace="supervisor"} |= "mcp tool call" |= "brain_query"`) returned: ``` 2026/10/01 21:09:13 INFO mcp tool call tool=brain_query principal=local-cli origin=static duration_ms=606 outcome=ok ``` Every clause now holds: - the CLAUDE.md claim is corrected (local-dev v0.18.1) - the audit line is shipped with tests - the record is live in Loki Per-client tokens are split out to #146. Closing. --- _Created via git-mcp on behalf of @mathias_
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/hyperguild#144