diff --git a/.env.example b/.env.example index ae932ee5..f13d81cd 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,14 @@ MCP_DOCMOST_PASSWORD= # expose the port publicly). # MCP_TOKEN= # MCP_SESSION_IDLE_MS=1800000 +# +# AI-AGENT ATTRIBUTION (comments/pages written via MCP are badged as "AI"): +# attribution is driven by a per-user `is_agent` flag on the users row. There is +# NO admin UI/API for it — set it out-of-band with SQL. Use a DEDICATED service +# account for the MCP fallback above and flag ONLY that account, e.g.: +# UPDATE users SET is_agent = true WHERE email = 'mcp-bot@your-domain'; +# NEVER set is_agent on a human or shared account — every action by that account +# (including normal human edits) would then be mis-attributed as AI. # Per-embedding-call timeout in milliseconds for the RAG indexer. # A slow/hung embeddings endpoint fails after this and the batch continues. diff --git a/CHANGELOG.md b/CHANGELOG.md index 43255596..1f03b74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **AI-agent attribution for MCP writes.** Comments (and pages) created through + the MCP endpoint by a dedicated agent account are now badged as "AI", with + unspoofable provenance derived from a per-user `is_agent` flag (not from the + request body). **Operator setup:** use a *dedicated* service account for the + MCP fallback and set the flag with SQL — + `UPDATE users SET is_agent = true WHERE email = ''`. Never flag a + human or shared account, or its normal edits get mis-attributed as AI. See the + AI-agent block in `.env.example`. (#143) + ### Changed - **Public share AI: default per-workspace hourly assistant cap lowered diff --git a/apps/client/src/components/ui/ai-agent-badge.test.tsx b/apps/client/src/components/ui/ai-agent-badge.test.tsx index 108883e6..8932d0a2 100644 --- a/apps/client/src/components/ui/ai-agent-badge.test.tsx +++ b/apps/client/src/components/ui/ai-agent-badge.test.tsx @@ -1,25 +1,16 @@ -import { describe, it, expect, vi, beforeAll } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; +import { Provider, createStore } from "jotai"; import { AiAgentBadge } from "./ai-agent-badge"; +import { + activeAiChatIdAtom, + aiChatWindowOpenAtom, + aiChatDraftAtom, +} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; +import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; -// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom -// does not implement. Provide a minimal stub so the provider can render. -beforeAll(() => { - Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - }), - }); -}); +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. function renderBadge(props: { authorName?: string; aiChatId?: string | null }) { return render( @@ -40,21 +31,42 @@ describe("AiAgentBadge", () => { const badge = screen.getByRole("button"); expect(badge).toBeDefined(); expect(badge.textContent).toContain("AI-agent"); - // Clicking does not throw — the deep-link handler runs against the default - // jotai store. (Asserting the badge exposes an interactive role is the - // observable contract; the atom side-effects are covered by the history UI.) - fireEvent.click(badge); }); - it("is a plain non-clickable label when aiChatId is null (external MCP agent)", () => { - renderBadge({ authorName: "Bot", aiChatId: null }); - expect(screen.getByText("AI-agent")).toBeDefined(); - // No interactive role is exposed when there is no chat to deep-link into. - expect(screen.queryByRole("button")).toBeNull(); + it("deep-links on click: sets the active chat, clears the draft, opens the AI-chat window, closes the history modal — and stops propagation", () => { + const store = createStore(); + // Pre-set the state the click must change, so the assertions are meaningful. + store.set(historyAtoms, true); // history modal open + store.set(aiChatDraftAtom, "leftover draft from another chat"); + const onParentClick = vi.fn(); + + render( + + + {/* Parent click handler must NOT fire — the badge stops propagation. */} +
+ +
+
+
, + ); + + fireEvent.click(screen.getByRole("button")); + + expect(store.get(activeAiChatIdAtom)).toBe("chat-1"); + expect(store.get(aiChatWindowOpenAtom)).toBe(true); + expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared + expect(store.get(historyAtoms)).toBe(false); // history modal closed + expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click }); - it("is non-clickable when aiChatId is absent", () => { - renderBadge({ authorName: "Bot" }); - expect(screen.queryByRole("button")).toBeNull(); - }); + it.each([{ aiChatId: null }, {}])( + "is a plain non-clickable label without a chat target (%o)", + (props) => { + renderBadge({ authorName: "Bot", ...props }); + expect(screen.getByText("AI-agent")).toBeDefined(); + // No interactive role is exposed when there is no chat to deep-link into. + expect(screen.queryByRole("button")).toBeNull(); + }, + ); }); diff --git a/apps/client/src/features/ai-chat/components/role-cards.test.tsx b/apps/client/src/features/ai-chat/components/role-cards.test.tsx index dda72aea..d8213d8d 100644 --- a/apps/client/src/features/ai-chat/components/role-cards.test.tsx +++ b/apps/client/src/features/ai-chat/components/role-cards.test.tsx @@ -1,26 +1,10 @@ -import { describe, it, expect, vi, beforeAll } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import RoleCards from "./role-cards"; import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts"; -// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom -// does not implement. Provide a minimal stub so the provider can render. -beforeAll(() => { - Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - }), - }); -}); +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. const roles: IAiRole[] = [ { diff --git a/apps/client/src/features/comment/components/comment-list-item.test.tsx b/apps/client/src/features/comment/components/comment-list-item.test.tsx index 726ef3f0..53796bc9 100644 --- a/apps/client/src/features/comment/components/comment-list-item.test.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.test.tsx @@ -1,24 +1,9 @@ -import { describe, it, expect, vi, beforeAll } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { IComment } from "@/features/comment/types/comment.types"; -// MantineProvider reads window.matchMedia on mount, which jsdom lacks. -beforeAll(() => { - Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - }), - }); -}); +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. // The comment mutation hooks reach out to react-query/network — stub them so the // component renders in isolation. We only assert the AI-badge rendering branch. diff --git a/apps/client/vitest.setup.ts b/apps/client/vitest.setup.ts index 33d3d142..c89777b1 100644 --- a/apps/client/vitest.setup.ts +++ b/apps/client/vitest.setup.ts @@ -49,3 +49,17 @@ function createStorage(): Storage { // `window.localStorage` resolve to the same working stub. vi.stubGlobal("localStorage", createStorage()); vi.stubGlobal("sessionStorage", createStorage()); + +// MantineProvider (and other components) read `window.matchMedia` on mount, which +// jsdom does not implement. Provide a minimal stub here so any test rendering +// Mantine works without re-stubbing matchMedia in every file. +vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), +})); diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index dd462877..e9119358 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -21,6 +21,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types'; import { executeTx } from '@docmost/db/utils'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { ProvenanceSource } from '../../core/auth/dto/jwt-payload'; import { Queue } from 'bullmq'; import { extractMentions, @@ -50,7 +51,7 @@ import { TransclusionService } from '../../core/page/transclusion/transclusion.s export function resolveSource( stickyTouched: boolean, contextActor?: string, -): 'agent' | 'user' { +): ProvenanceSource { return stickyTouched || contextActor === 'agent' ? 'agent' : 'user'; } diff --git a/apps/server/src/common/decorators/auth-provenance.decorator.ts b/apps/server/src/common/decorators/auth-provenance.decorator.ts index c0c67328..3e49b6d4 100644 --- a/apps/server/src/common/decorators/auth-provenance.decorator.ts +++ b/apps/server/src/common/decorators/auth-provenance.decorator.ts @@ -1,4 +1,5 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { ProvenanceSource } from '../../core/auth/dto/jwt-payload'; /** * The agent-edit provenance carried by the request, read from the SIGNED access @@ -8,7 +9,7 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; * cannot fake an 'agent' marker. */ export interface AuthProvenanceData { - actor: 'user' | 'agent'; + actor: ProvenanceSource; aiChatId: string | null; } diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index 58a9ceab..b6a9f980 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -1,3 +1,11 @@ +/** + * Provenance actor for a write: who the action is attributed to. Derived only + * from the SIGNED token claim (never a request body), so 'agent' is unspoofable. + * Single source of truth so a typo like 'agnet' can't slip through as a bare + * string (#143 review). Distinct from `ActorType` (auth principal kind). + */ +export type ProvenanceSource = 'user' | 'agent'; + export enum JwtType { ACCESS = 'access', COLLAB = 'collab', @@ -19,7 +27,7 @@ export type JwtPayload = { // mints a provenance access token so REST writes (create/rename/move page, // comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15 // C3 / §14 N2). - actor?: 'user' | 'agent'; + actor?: ProvenanceSource; // Nullable: an external MCP agent has no internal ai_chats row, so it carries // an 'agent' actor with a null aiChatId. aiChatId?: string | null; @@ -32,7 +40,7 @@ export type JwtCollabPayload = { // Optional agent-edit provenance, signed into the collab token. Absent for // the human collab path (treated as 'user'); set only when the internal agent // mints a provenance collab token (§6.6 / §15 C2). - actor?: 'user' | 'agent'; + actor?: ProvenanceSource; // Nullable: an external MCP agent has no internal ai_chats row, so it carries // an 'agent' actor with a null aiChatId. aiChatId?: string | null;