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:
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
125
apps/server/src/core/auth/strategies/jwt.strategy.spec.ts
Normal file
125
apps/server/src/core/auth/strategies/jwt.strategy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user