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

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>
This commit is contained in:
claude code agent 227
2026-06-23 04:25:40 +03:00
parent 7884dc2e1a
commit 989f99abae
14 changed files with 445 additions and 106 deletions

View File

@@ -20,7 +20,9 @@ export type JwtPayload = {
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
// C3 / §14 N2).
actor?: 'user' | 'agent';
aiChatId?: string;
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
// an 'agent' actor with a null aiChatId.
aiChatId?: string | null;
};
export type JwtCollabPayload = {
@@ -31,7 +33,9 @@ export type JwtCollabPayload = {
// 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';
aiChatId?: string;
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
// an 'agent' actor with a null aiChatId.
aiChatId?: string | null;
};
export type JwtExchangePayload = {

View File

@@ -34,7 +34,9 @@ export class TokenService {
// token carries no actor/aiChatId and is treated as 'user' downstream. The
// internal agent passes { actor:'agent', aiChatId } so REST writes record a
// non-spoofable 'agent' marker off the signed claim (§6.5 / §15 C3 / §14 N2).
provenance?: { actor: 'agent'; aiChatId: string },
// aiChatId is nullable: an external MCP agent has no internal ai_chats row,
// so it stamps 'agent' with a null aiChatId.
provenance?: { actor: 'agent'; aiChatId: string | null },
): Promise<string> {
if (isUserDisabled(user)) {
throw new ForbiddenException();
@@ -58,7 +60,8 @@ export class TokenService {
workspaceId: string,
// Optional agent-edit provenance. When omitted (the human collab path), the
// token carries no actor/aiChatId and is treated as 'user' downstream.
provenance?: { actor: 'agent'; aiChatId: string },
// aiChatId is nullable for an external agent with no internal ai_chats row.
provenance?: { actor: 'agent'; aiChatId: string | null },
): Promise<string> {
if (isUserDisabled(user)) {
throw new ForbiddenException();

View File

@@ -0,0 +1,125 @@
import { UnauthorizedException } from '@nestjs/common';
import { JwtStrategy } from './jwt.strategy';
import { JwtType } from '../dto/jwt-payload';
/**
* Provenance derivation in JwtStrategy.validate (jwt.strategy.ts).
*
* The strategy must derive the agent-edit provenance from the SIGNED server-side
* identity, never from a client-controlled field. The security invariant under
* test: a user flagged is_agent stamps 'agent'; an ordinary user resolves to
* 'user'; and an `actor` claim in the token CANNOT escalate a non-agent user
* past the existing internal-AI-chat claim semantics (anti-spoof — a plain user
* cannot obtain created_source='agent').
*
* The strategy is constructed directly with stub deps. The PassportStrategy base
* only needs a secret at construction time; validate() is exercised on its own.
*/
describe('JwtStrategy — provenance derivation', () => {
function makeStrategy(user: any) {
const userRepo: any = { findById: jest.fn(async () => user) };
const workspaceRepo: any = { findById: jest.fn(async () => ({ id: 'ws-1' })) };
const userSessionRepo: any = { findActiveById: jest.fn() };
const sessionActivityService: any = { trackActivity: jest.fn() };
const environmentService: any = { getAppSecret: () => 'test-secret' };
const moduleRef: any = {};
const strategy = new JwtStrategy(
userRepo,
workspaceRepo,
userSessionRepo,
sessionActivityService,
environmentService,
moduleRef,
);
return { strategy, userRepo };
}
// A bare request whose `raw` collects the provenance the strategy stamps.
const makeReq = () => ({ raw: {} as Record<string, any> });
const accessPayload = (over?: Record<string, any>) => ({
sub: 'user-1',
email: 'u@test.local',
workspaceId: 'ws-1',
type: JwtType.ACCESS,
...over,
});
it("stamps actor='agent' for an is_agent user (derived from the signed identity)", async () => {
const { strategy } = makeStrategy({
id: 'user-1',
isAgent: true,
deactivatedAt: null,
deletedAt: null,
});
const req = makeReq();
await strategy.validate(req, accessPayload() as any);
expect(req.raw.actor).toBe('agent');
// External MCP agent: no internal ai_chats row → null.
expect(req.raw.aiChatId).toBeNull();
});
it("stamps actor='user' for an ordinary user", async () => {
const { strategy } = makeStrategy({
id: 'user-1',
isAgent: false,
deactivatedAt: null,
deletedAt: null,
});
const req = makeReq();
await strategy.validate(req, accessPayload() as any);
expect(req.raw.actor).toBe('user');
expect(req.raw.aiChatId).toBeNull();
});
it("does NOT let an 'actor' claim escalate a non-agent user beyond the existing claim semantics", async () => {
// A non-agent user. The only way the token carries actor='agent' is the
// internal AI-chat's server-minted token (the claim cannot be set by a
// client on a plain login). We assert the derivation falls back to the
// claim ONLY when is_agent is false — i.e. an is_agent=false user is never
// forced to 'agent' by anything other than that signed claim, and a plain
// user (no claim) stays 'user'.
const { strategy } = makeStrategy({
id: 'user-1',
isAgent: false,
deactivatedAt: null,
deletedAt: null,
});
const req = makeReq();
// No actor claim (the plain-user login case): stays 'user'.
await strategy.validate(req, accessPayload() as any);
expect(req.raw.actor).toBe('user');
// A token that DOES carry actor='agent' resolves to 'agent' — BY DESIGN:
// that claim can only exist on a SERVER-MINTED provenance token (the internal
// AI chat), never on a plain login token, because the token is signed with
// the app secret. The security guarantee is that a client cannot forge this
// signed claim, NOT that the strategy ignores it. (A plain user therefore
// still cannot obtain 'agent' — they have no way to get such a token.)
const req2 = makeReq();
await strategy.validate(req2, accessPayload({ actor: 'agent', aiChatId: 'chat-1' }) as any);
expect(req2.raw.actor).toBe('agent');
expect(req2.raw.aiChatId).toBe('chat-1');
});
it('rejects a disabled is_agent user (Unauthorized) before stamping provenance', async () => {
const { strategy } = makeStrategy({
id: 'user-1',
isAgent: true,
deactivatedAt: new Date('2026-01-01'),
deletedAt: null,
});
const req = makeReq();
await expect(strategy.validate(req, accessPayload() as any)).rejects.toThrow(
UnauthorizedException,
);
expect(req.raw.actor).toBeUndefined();
});
});

View File

@@ -71,13 +71,18 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
}
// Propagate the signed agent-edit provenance claim onto the request so REST
// services/controllers can set the 'agent' marker off it. A normal user
// token carries no actor claim and resolves to 'user' (unchanged behaviour);
// only the internal agent's minted token sets actor='agent' + aiChatId. This
// is read server-side from the SIGNED token, never from a client body field,
// so a normal user cannot fake an 'agent' badge.
req.raw.actor = (payload as JwtPayload).actor ?? 'user';
// Propagate the agent-edit provenance onto the request so REST
// services/controllers can set the 'agent' marker off it. Provenance is
// derived from the SIGNED server-side identity, never from a client body
// field, so a normal user cannot fake an 'agent' badge:
// - An account flagged is_agent (an MCP service account) stamps EVERY write
// as 'agent'. It has no internal ai_chats row, so aiChatId stays null.
// - Otherwise fall back to the actor claim minted into the internal AI
// agent's token (actor='agent' + aiChatId); a normal user token carries
// no claim and resolves to 'user' (unchanged behaviour).
req.raw.actor = user.isAgent
? 'agent'
: ((payload as JwtPayload).actor ?? 'user');
req.raw.aiChatId = (payload as JwtPayload).aiChatId ?? null;
return { user, workspace };

View File

@@ -147,6 +147,24 @@ describe('CommentService — behavior', () => {
expect(insertArg.creatorId).toBe('user-1');
});
it('stamps createdSource:"agent" with a null aiChatId (external MCP agent) without breaking insert', async () => {
const { service, commentRepo } = makeService();
// An external MCP agent is flagged is_agent server-side but has no
// internal ai_chats row, so provenance carries actor='agent' + a null
// aiChatId. The insert must still record the agent marker.
await service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{ content: JSON.stringify(docMentioning()) } as any,
{ actor: 'agent', aiChatId: null },
);
const insertArg = commentRepo.insertComment.mock.calls[0][0];
expect(insertArg.createdSource).toBe('agent');
expect(insertArg.aiChatId).toBeNull();
expect(insertArg.creatorId).toBe('user-1');
});
it('leaves source default (no agent stamp) for a normal user', async () => {
const { service, commentRepo } = makeService();