feat(comments): attribute MCP agent comments as AI (unspoofable provenance) #143

Merged
vvzvlad merged 6 commits from feat/mcp-comments-ai-attribution into develop 2026-06-24 02:05:07 +03:00

Implements the design in docs/backlog/mcp-comments-ai-attribution.md: comments (and pages) created via MCP under a flagged agent service-account are attributed as AI — unspoofable (derived from server identity), additive (the author stays, an AI badge is shown next to it).

Backend (B1) — unspoofable agent identity

  • New additive migration 20260623T120000-user-is-agent (users.is_agent boolean, default false) + db.d.ts/user.repo baseFields so the strategy actually receives it.
  • jwt.strategy.ts derives provenance from the signed identity: req.raw.actor = user.isAgent ? 'agent' : (payload.actor ?? 'user'), aiChatId = payload.aiChatId ?? null. A flagged service-account stamps every write 'agent'; external MCP has no internal ai_chats row so aiChatId is null. The existing comment.service stamping (created_source='agent' / resolved_source='agent') needed no change — a null aiChatId already flows into the nullable column. Provenance aiChatId type loosened to string | null (the internal AI-chat path still passes a real id).
  • Security invariant (acceptance criterion 3): actor is never read from the request body; the only actor='agent' sources are a flagged identity or a server-signed token claim (internal AI chat) — a plain login can't forge either. Defense-in-depth: the provenance decorator hard-defaults to 'user'.

Frontend (B2) — AI badge in the comments sidebar

  • Extracted the existing AiAgentBadge from page-history into a shared components/ui/ai-agent-badge.tsx (clickable deep-link when aiChatId present, plain label when null). page-history behavior unchanged.
  • IComment gains createdSource/aiChatId/resolvedSource (backend already returns them); comment-list-item renders the badge when createdSource==='agent'.

Tests + verification

  • jwt.strategy.spec (anti-spoof: is_agent→agent, plain user→user, server-signed claim honored, disabled-agent rejected); comment.service behavior (agent + null aiChatId → created_source='agent'); client AiAgentBadge + comment-list-item rendering branches. All green; client+server tsc clean; migration applies.
  • Live SQL proof on the stand: same account, is_agent=true comment → created_source='agent', ai_chat_id=null; is_agent=falsecreated_source='user'. Confirms unspoofability (provenance from identity, not request).

Skipped (doc marks optional): the "Resolved by AI" marker — resolved_source is already stamped + resolvedSource wired into IComment, so it's a small follow-up.

🤖 Generated with Claude Code

Implements the design in `docs/backlog/mcp-comments-ai-attribution.md`: comments (and pages) created via MCP under a flagged agent service-account are attributed as **AI** — unspoofable (derived from server identity), additive (the author stays, an AI badge is shown next to it). ### Backend (B1) — unspoofable agent identity - New additive migration `20260623T120000-user-is-agent` (`users.is_agent` boolean, default false) + `db.d.ts`/`user.repo` baseFields so the strategy actually receives it. - `jwt.strategy.ts` derives provenance from the **signed identity**: `req.raw.actor = user.isAgent ? 'agent' : (payload.actor ?? 'user')`, `aiChatId = payload.aiChatId ?? null`. A flagged service-account stamps every write `'agent'`; external MCP has no internal `ai_chats` row so `aiChatId` is null. The existing `comment.service` stamping (`created_source='agent'` / `resolved_source='agent'`) needed no change — a null `aiChatId` already flows into the nullable column. Provenance `aiChatId` type loosened to `string | null` (the internal AI-chat path still passes a real id). - **Security invariant (acceptance criterion 3):** `actor` is never read from the request body; the only `actor='agent'` sources are a flagged identity or a server-signed token claim (internal AI chat) — a plain login can't forge either. Defense-in-depth: the provenance decorator hard-defaults to `'user'`. ### Frontend (B2) — AI badge in the comments sidebar - Extracted the existing `AiAgentBadge` from page-history into a shared `components/ui/ai-agent-badge.tsx` (clickable deep-link when `aiChatId` present, plain label when null). page-history behavior unchanged. - `IComment` gains `createdSource`/`aiChatId`/`resolvedSource` (backend already returns them); `comment-list-item` renders the badge when `createdSource==='agent'`. ### Tests + verification - jwt.strategy.spec (anti-spoof: is_agent→agent, plain user→user, server-signed claim honored, disabled-agent rejected); comment.service behavior (agent + null aiChatId → `created_source='agent'`); client `AiAgentBadge` + `comment-list-item` rendering branches. All green; client+server `tsc` clean; migration applies. - **Live SQL proof** on the stand: same account, `is_agent=true` comment → `created_source='agent', ai_chat_id=null`; `is_agent=false` → `created_source='user'`. Confirms unspoofability (provenance from identity, not request). Skipped (doc marks optional): the "Resolved by AI" marker — `resolved_source` is already stamped + `resolvedSource` wired into `IComment`, so it's a small follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-23 04:29:46 +03:00
Mark comments (and, via existing page provenance, pages) created under an
is_agent service account as authored by AI, derived from the SIGNED server
identity rather than any client field, and render the existing AI badge in
the comments sidebar.

Backend (B1):
- Add additive users.is_agent boolean (default false) migration; reflect in
  the Users Kysely type, the user repo baseFields, and (via Selectable) the
  User entity.
- jwt.strategy: derive req.raw.actor from user.isAgent (an is_agent account
  stamps every write 'agent'); external MCP has no internal ai_chats row so
  aiChatId stays null. Non-spoofable: a plain user cannot obtain
  created_source='agent'.
- Loosen the provenance aiChatId type to string|null across token.service and
  the JwtPayload/JwtCollabPayload claims (type-level only; the internal AI-chat
  path still passes a real aiChatId).

Frontend (B2):
- Extend IComment with createdSource/aiChatId/resolvedSource (backend already
  returns them via selectAll).
- Extract the local AiAgentBadge from history-item into a shared
  components/ui/ai-agent-badge.tsx (clickable deep-link when aiChatId present,
  plain label when null/absent); reuse it in history-item and render it in
  comment-list-item next to the author name when createdSource==='agent'.

Tests: comment.service agent/null-aiChatId provenance, jwt.strategy provenance
derivation + anti-spoof, AiAgentBadge clickable/non-clickable branches, and
comment-list-item badge render/no-render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-23 23:56:38 +03:00
- [warn 1] Document the is_agent operator setup so it survives plan deletion:
  added an AI-agent block to .env.example (use a DEDICATED account, set is_agent
  via SQL, never flag a human/shared account) + a CHANGELOG "Added" entry.
- [warn 2] Test the badge deep-link side effects: ai-agent-badge.test.tsx now
  renders inside an explicit jotai store, clicks the badge, and asserts the
  active chat id, window-open, cleared draft, closed history modal, AND that
  stopPropagation keeps a parent onClick from firing.
- [suggestion 3] Hoist the window.matchMedia stub into vitest.setup.ts and drop
  the duplicated beforeAll block from the three test files (ai-agent-badge,
  comment-list-item, role-cards).
- [suggestion 4] Merge the two near-duplicate "non-clickable" cases via it.each.
- [follow-up 6] Introduce a single ProvenanceSource = 'user' | 'agent' type in
  jwt-payload.ts and reference it from AuthProvenanceData, JwtPayload/
  JwtCollabPayload, and resolveSource() — so a typo can't slip through as a bare
  string. (Server auth chain; client IComment mirroring left as a follow-up.)

Follow-up 5 (shared agentSourceFields write-stamp helper) is deferred as the
review marked it — the 6 REST sites use varied shapes (create-spread vs
resolve-conditional-null vs page move), so it's a separate focused refactor.

Tests: client badge/comment/role-cards suites 11/11 pass; server auth+comment
suites 62 pass; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-24 00:06:07 +03:00
The agent write-stamp idiom — `...(isAgent ? { <source>: 'agent', <chat>: aiChatId } : {})`
— was hand-reimplemented at every REST write site, so each new path risked a
wrong literal or a forgotten aiChatId. Extract a single
`agentSourceFields(provenance, sourceKey, chatKey)` next to AuthProvenanceData and
call it at the 5 uniform spread sites:

- comment.service create  -> createdSource / aiChatId
- page.service create/update/orphan-move/move -> lastUpdatedSource / lastUpdatedAiChatId

Sites that must CLEAR the source on a non-agent action keep their own conditional
(comment un-resolve writes an explicit null), and the collab persistence path keeps
its sticky-window logic — both noted in the helper's doc.

Behavior-preserving (the helper returns the identical object/`{}`). Typecheck
clean; server comment/page/auth/collab suites 246 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-24 00:27:19 +03:00
Architecture & design:
- Arch A: introduce resolveProvenance() as the single source of truth for
  deriving a write's actor/aiChatId from the SIGNED identity, and wire it into
  BOTH transport seams — the REST jwt.strategy and the collab
  authentication.extension. Previously the collab seam derived actor from the
  token claim alone and ignored user.isAgent, so a flagged service account's
  page-content edits over the websocket persisted as lastUpdatedSource='user',
  drifting from REST. The seams now share one resolver and can't diverge.
- Arch B: drop AiAgentBadge's page-history coupling. The generic ui/ badge no
  longer imports historyAtoms; it exposes an onActivate callback fired after the
  deep-link, and the history row passes onActivate to close its own modal.

Suggestions/warnings:
- S1: soften the jwt.strategy provenance comment (applies to every REST write).
- S2/suggestion-3: drop the redundant comment-list-item null-aiChatId test
  (covered by ai-agent-badge.test.tsx).
- S3: de-duplicate jwt.strategy.spec test #3 (the no-claim→'user' half
  duplicated test #2); keep only the signed actor='agent' claim assertion.
- W2: add keyboard-activation tests for the badge (Enter/Space, unrelated key).
- W3: flip the design doc status to "реализовано (#143)".

Tests:
- new auth-provenance.decorator.spec.ts unit-tests resolveProvenance +
  agentSourceFields.
- new collab-seam test: is_agent user with no claim → actor='agent'
  (Arch A regression guard).

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

После исправления можно вливать

После исправления можно вливать
vvzvlad added 2 commits 2026-06-24 02:04:46 +03:00
Resolves the open items from the latest PR #143 code review:

- test(page): cover the four agentSourceFields stamp sites (create, update,
  movePage, movePageToSpace) with agent + normal-user payload assertions;
  add findById({ includeIsAgent: true }) wiring guards to the JWT and collab
  auth-seam specs so a future drop of the option is caught.
- fix(privacy): drop `isAgent` from UserRepo.baseFields and gate it behind a
  new opt-in `findById({ includeIsAgent })`, requested only by the two auth
  seams that derive provenance — stops the flag leaking via the workspace
  member list and generic user payloads.
- docs: correct the agentSourceFields JSDoc and the two UPDATE-site comments
  to distinguish INSERT (omitted column → DB default 'user') from UPDATE
  (omitted column → existing value kept, Kysely writes only present keys).
- style(page): collapse three stray double blank lines left by an earlier edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per AGENTS.md §5, a task's backlog plan is deleted once implemented; this PR
ships that design, so the plan leaves the work queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad merged commit f11c8d7bf1 into develop 2026-06-24 02:05:07 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#143