diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1fc92a1..f20534d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -87,7 +87,9 @@ addWorkspaceOption( '', 'Next steps:', `1) Start server: workgraph serve -w "${result.workspacePath}"`, - `2) Register first agent: workgraph agent register agent-1 -w "${result.workspacePath}" --token ${result.bootstrapTrustToken}`, + `2) Preferred registration flow: workgraph agent request agent-1 -w "${result.workspacePath}" --role roles/admin.md`, + ` Approve request: workgraph agent review agent-1 -w "${result.workspacePath}" --decision approved --actor admin-approver`, + ` Bootstrap fallback: workgraph agent register agent-1 -w "${result.workspacePath}" --token ${result.bootstrapTrustToken}`, `3) Create first thread: workgraph thread create "First coordinated task" -w "${result.workspacePath}" --goal "Validate onboarding flow" --actor agent-1`, ]; } @@ -513,7 +515,7 @@ addWorkspaceOption( addWorkspaceOption( agentCmd .command('register ') - .description('Register an agent using the bootstrap trust token') + .description('Register an agent using bootstrap token fallback (legacy/hybrid mode)') .option('--token ', 'Bootstrap trust token (or WORKGRAPH_TRUST_TOKEN env)') .option('--role ', 'Role slug/path override (default from trust token)') .option('--capabilities ', 'Comma-separated extra capabilities') @@ -546,6 +548,144 @@ addWorkspaceOption( `Presence: ${result.presence.path}`, `Policy party: ${result.policyParty.id}`, `Bootstrap token: ${result.trustTokenPath} [${result.trustTokenStatus}]`, + ...(result.credential ? [`Credential: ${result.credential.id} [${result.credential.status}]`] : []), + ...(result.apiKey ? [`API key (store securely, shown once): ${result.apiKey}`] : []), + ], + ) +); + +addWorkspaceOption( + agentCmd + .command('request ') + .description('Submit an approval-based agent registration request') + .option('--role ', 'Requested role slug/path (default: roles/contributor.md)') + .option('--capabilities ', 'Comma-separated requested extra capabilities') + .option('-a, --actor ', 'Actor submitting the request') + .option('--note ', 'Optional request note') + .option('--json', 'Emit structured JSON output') +).action((name, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return workgraph.agent.submitRegistrationRequest(workspacePath, name, { + role: opts.role, + capabilities: csv(opts.capabilities), + actor: opts.actor, + note: opts.note, + }); + }, + (result) => [ + `Submitted registration request for ${result.agentName}`, + `Request: ${result.request.path}`, + `Requested role: ${result.requestedRolePath}`, + `Requested capabilities: ${result.requestedCapabilities.join(', ') || 'none'}`, + ], + ) +); + +addWorkspaceOption( + agentCmd + .command('review ') + .description('Approve or reject a pending registration request') + .requiredOption('--decision ', 'approved | rejected') + .option('-a, --actor ', 'Reviewer actor', DEFAULT_ACTOR) + .option('--role ', 'Approved role slug/path (for approved decisions)') + .option('--capabilities ', 'Comma-separated approved extra capabilities') + .option('--scopes ', 'Comma-separated credential scopes (defaults to approved capabilities)') + .option('--expires-at ', 'Optional credential expiry ISO date') + .option('--note ', 'Optional review note') + .option('--json', 'Emit structured JSON output') +).action((requestRef, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const decision = String(opts.decision ?? '').trim().toLowerCase(); + if (decision !== 'approved' && decision !== 'rejected') { + throw new Error('Invalid --decision value. Expected approved|rejected.'); + } + return workgraph.agent.reviewRegistrationRequest( + workspacePath, + requestRef, + opts.actor, + decision, + { + role: opts.role, + capabilities: csv(opts.capabilities), + scopes: csv(opts.scopes), + expiresAt: opts.expiresAt, + note: opts.note, + }, + ); + }, + (result) => [ + `Reviewed request: ${result.request.path}`, + `Decision: ${result.decision}`, + `Approval record: ${result.approval.path}`, + ...(result.policyParty + ? [`Policy party: ${result.policyParty.id} (${result.policyParty.roles.join(', ')})`] + : []), + ...(result.credential ? [`Credential: ${result.credential.id} [${result.credential.status}]`] : []), + ...(result.apiKey ? [`API key (store securely, shown once): ${result.apiKey}`] : []), + ], + ) +); + +addWorkspaceOption( + agentCmd + .command('credential-list') + .description('List issued agent credentials') + .option('--actor ', 'Filter by actor id') + .option('--json', 'Emit structured JSON output') +).action((opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const credentials = workgraph.agent.listAgentCredentials(workspacePath, opts.actor); + return { + credentials, + count: credentials.length, + }; + }, + (result) => { + if (result.credentials.length === 0) return ['No credentials found.']; + return [ + ...result.credentials.map((credential) => + `${credential.id} actor=${credential.actor} status=${credential.status} scopes=${credential.scopes.join(', ') || 'none'}` + ), + `${result.count} credential(s)`, + ]; + }, + ) +); + +addWorkspaceOption( + agentCmd + .command('credential-revoke ') + .description('Revoke an issued credential') + .option('-a, --actor ', 'Actor revoking the credential', DEFAULT_ACTOR) + .option('--reason ', 'Optional revocation reason') + .option('--json', 'Emit structured JSON output') +).action((credentialId, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return { + credential: workgraph.agent.revokeAgentCredential( + workspacePath, + credentialId, + opts.actor, + opts.reason, + ), + }; + }, + (result) => [ + `Revoked credential: ${result.credential.id}`, + `Actor: ${result.credential.actor}`, + `Status: ${result.credential.status}`, ], ) ); diff --git a/packages/cli/src/cli/core.ts b/packages/cli/src/cli/core.ts index 94f7331..724d2b4 100644 --- a/packages/cli/src/cli/core.ts +++ b/packages/cli/src/cli/core.ts @@ -9,6 +9,7 @@ export type JsonCapableOptions = { workspace?: string; vault?: string; sharedVault?: string; + apiKey?: string; dryRun?: boolean; __dryRunWorkspace?: string; __dryRunWorkspaceRoot?: string; @@ -20,6 +21,7 @@ export function addWorkspaceOption(command: T): T { .option('-w, --workspace ', 'Workgraph workspace path') .option('--vault ', 'Alias for --workspace') .option('--shared-vault ', 'Shared vault path (e.g. mounted via Tailscale)') + .option('--api-key ', 'Agent credential API key (or WORKGRAPH_API_KEY env)') .option('--dry-run', 'Execute against a temporary workspace copy and discard changes'); } @@ -194,7 +196,11 @@ export async function runCommand( renderText: (result: T) => string[], ): Promise { try { - const result = await action(); + const credentialToken = readCredentialToken(opts); + const result = await workgraph.auth.runWithAuthContext({ + ...(credentialToken ? { credentialToken } : {}), + source: 'cli', + }, () => action()); const dryRunMetadata = opts.dryRun ? { dryRun: true, @@ -240,3 +246,17 @@ function cleanupDryRunSandbox(opts: JsonCapableOptions): void { delete opts.__dryRunWorkspace; delete opts.__dryRunOriginal; } + +function readCredentialToken(opts: JsonCapableOptions): string | undefined { + const fromOption = readNonEmptyString((opts as { apiKey?: unknown }).apiKey); + if (fromOption) return fromOption; + const fromEnv = readNonEmptyString(process.env.WORKGRAPH_AGENT_API_KEY) + ?? readNonEmptyString(process.env.WORKGRAPH_API_KEY); + return fromEnv; +} + +function readNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/control-api/src/server.test.ts b/packages/control-api/src/server.test.ts index bac4737..130e729 100644 --- a/packages/control-api/src/server.test.ts +++ b/packages/control-api/src/server.test.ts @@ -2,11 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { store as storeModule, thread as threadModule } from '@versatly/workgraph-kernel'; +import { + agent as agentModule, + store as storeModule, + thread as threadModule, + workspace as workspaceModule, +} from '@versatly/workgraph-kernel'; import { startWorkgraphServer } from './server.js'; +const agent = agentModule; const store = storeModule; const thread = threadModule; +const workspace = workspaceModule; let workspacePath: string; @@ -337,4 +344,73 @@ describe('workgraph server REST API', () => { await handle.close(); } }); + + it('enforces strict credential identity for mutating REST endpoints', async () => { + const init = workspace.initWorkspace(workspacePath, { createReadme: false, createBases: false }); + const registration = agent.registerAgent(workspacePath, 'api-admin', { + token: init.bootstrapTrustToken, + capabilities: ['thread:create', 'thread:update', 'thread:complete'], + }); + expect(registration.apiKey).toBeDefined(); + + const serverConfigPath = path.join(workspacePath, '.workgraph', 'server.json'); + const serverConfig = JSON.parse(fs.readFileSync(serverConfigPath, 'utf-8')) as Record; + serverConfig.auth = { + mode: 'strict', + allowUnauthenticatedFallback: false, + }; + fs.writeFileSync(serverConfigPath, `${JSON.stringify(serverConfig, null, 2)}\n`, 'utf-8'); + + const handle = await startWorkgraphServer({ + workspacePath, + host: '127.0.0.1', + port: 0, + defaultActor: 'system', + }); + try { + const unauthorized = await fetch(`${handle.baseUrl}/api/threads`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + title: 'Strict denied', + goal: 'Missing credential should fail', + }), + }); + expect(unauthorized.status).toBe(403); + + const spoofed = await fetch(`${handle.baseUrl}/api/threads`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${registration.apiKey}`, + }, + body: JSON.stringify({ + title: 'Strict spoofed', + goal: 'Credential actor mismatch should fail', + actor: 'spoofed-actor', + }), + }); + expect(spoofed.status).toBe(403); + + const authorized = await fetch(`${handle.baseUrl}/api/threads`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${registration.apiKey}`, + }, + body: JSON.stringify({ + title: 'Strict allowed', + goal: 'Valid credential actor should pass', + }), + }); + expect(authorized.status).toBe(201); + const body = await authorized.json() as { ok: boolean; thread: { path: string } }; + expect(body.ok).toBe(true); + expect(body.thread.path).toBe('threads/strict-allowed.md'); + } finally { + await handle.close(); + } + }); }); diff --git a/packages/control-api/src/server.ts b/packages/control-api/src/server.ts index 244ba67..35f6d4b 100644 --- a/packages/control-api/src/server.ts +++ b/packages/control-api/src/server.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { + auth as authModule, ledger as ledgerModule, orientation as orientationModule, store as storeModule, @@ -30,6 +31,7 @@ import { } from './server-webhooks.js'; const ledger = ledgerModule; +const auth = authModule; const orientation = orientationModule; const store = storeModule; const thread = threadModule; @@ -99,6 +101,7 @@ interface ThreadCreateRequestBody { } interface WebhookCreateRequestBody { + actor?: unknown; url?: unknown; events?: unknown; secret?: unknown; @@ -127,6 +130,9 @@ export async function startWorkgraphServer(options: WorkgraphServerOptions): Pro bearerToken: options.bearerToken, onApp: ({ app, bearerAuthMiddleware }) => { app.use('/api', bearerAuthMiddleware); + app.use('/api', (req: any, _res: any, next: () => void) => { + auth.runWithAuthContext(buildRequestAuthContext(req), () => next()); + }); registerRestRoutes(app, workspacePath, defaultActor); }, }); @@ -338,7 +344,8 @@ function registerRestRoutes(app: any, workspacePath: string, defaultActor: strin app.post('/api/threads', (req: any, res: any) => { try { const payload = toRecord(req.body); - const created = createThreadFromPayload(workspacePath, payload, defaultActor); + const actor = resolveMutationActor(req, workspacePath, payload.actor, defaultActor); + const created = createThreadFromPayload(workspacePath, payload, actor); res.status(201).json({ ok: true, thread: created, @@ -359,7 +366,8 @@ function registerRestRoutes(app: any, workspacePath: string, defaultActor: strin return; } const payload = toRecord(req.body); - const updated = updateThreadFromPayload(workspacePath, threadId, payload, defaultActor); + const actor = resolveMutationActor(req, workspacePath, payload.actor, defaultActor); + const updated = updateThreadFromPayload(workspacePath, threadId, payload, actor); res.json({ ok: true, thread: updated, @@ -452,6 +460,7 @@ function registerRestRoutes(app: any, workspacePath: string, defaultActor: strin app.post('/api/webhooks', (req: any, res: any) => { try { const payload = toRecord(req.body) as WebhookCreateRequestBody; + const actor = resolveMutationActor(req, workspacePath, payload.actor, defaultActor); const url = readNonEmptyString(payload.url); if (!url) { throw new Error('Missing required field "url".'); @@ -460,11 +469,24 @@ function registerRestRoutes(app: any, workspacePath: string, defaultActor: strin if (!events || events.length === 0) { throw new Error('Missing required field "events".'); } + auth.assertAuthorizedMutation(workspacePath, { + actor, + action: 'webhook.register', + target: '.workgraph/webhooks.json', + requiredCapabilities: ['policy:manage', 'dispatch:run'], + metadata: { + module: 'control-api', + }, + }); const webhook = registerWebhook(workspacePath, { url, events, secret: readNonEmptyString(payload.secret), }); + ledger.append(workspacePath, actor, 'create', `.workgraph/webhooks/${webhook.id}`, 'webhook', { + url: webhook.url, + events: webhook.events, + }); res.status(201).json({ ok: true, webhook, @@ -476,6 +498,7 @@ function registerRestRoutes(app: any, workspacePath: string, defaultActor: strin app.delete('/api/webhooks/:id', (req: any, res: any) => { try { + const actor = resolveMutationActor(req, workspacePath, undefined, defaultActor); const webhookId = readNonEmptyString(req.params?.id); if (!webhookId) { res.status(400).json({ @@ -484,6 +507,15 @@ function registerRestRoutes(app: any, workspacePath: string, defaultActor: strin }); return; } + auth.assertAuthorizedMutation(workspacePath, { + actor, + action: 'webhook.delete', + target: `.workgraph/webhooks.json#${webhookId}`, + requiredCapabilities: ['policy:manage', 'dispatch:run'], + metadata: { + module: 'control-api', + }, + }); const deleted = deleteWebhook(workspacePath, webhookId); if (!deleted) { res.status(404).json({ @@ -492,6 +524,7 @@ function registerRestRoutes(app: any, workspacePath: string, defaultActor: strin }); return; } + ledger.append(workspacePath, actor, 'delete', `.workgraph/webhooks/${webhookId}`, 'webhook'); res.json({ ok: true, id: webhookId, @@ -544,10 +577,9 @@ function listThreads( function createThreadFromPayload( workspacePath: string, payload: Record, - defaultActor: string, + actor: string, ): PrimitiveInstance { const body = payload as ThreadCreateRequestBody; - const actor = readNonEmptyString(body.actor) ?? defaultActor; const goal = readNonEmptyString(body.goal) ?? readNonEmptyString(body.observation); if (!goal) { throw new Error('Missing required field "goal" (or "observation").'); @@ -567,7 +599,7 @@ function updateThreadFromPayload( workspacePath: string, rawThreadId: string, payload: Record, - defaultActor: string, + actor: string, ): PrimitiveInstance { const threadInstance = resolveThreadInstance(workspacePath, rawThreadId); if (!threadInstance) { @@ -575,7 +607,6 @@ function updateThreadFromPayload( } const body = payload as ThreadUpdateRequestBody; - const actor = readNonEmptyString(body.actor) ?? defaultActor; const status = readNonEmptyString(body.status); if (!isThreadStatus(status)) { throw new Error('Invalid or missing field "status". Expected open|active|blocked|done|cancelled.'); @@ -729,6 +760,14 @@ function writeRouteError(res: any, error: unknown): void { function inferHttpStatus(message: string): number { if (message.includes('not found')) return 404; if (message.includes('already claimed') || message.includes('owned by')) return 409; + if ( + message.includes('Identity verification failed') || + message.includes('Policy gate blocked') || + message.includes('Credential scope blocked') || + message.includes('Mutation blocked') + ) { + return 403; + } if ( message.includes('Invalid') || message.includes('Missing') || @@ -771,3 +810,41 @@ function logJson(level: LogLevel, event: string, data: Record): ...data, })); } + +function buildRequestAuthContext(req: any): { credentialToken?: string; source: 'rest' } { + const credentialToken = readBearerToken(req?.headers?.authorization); + return { + ...(credentialToken ? { credentialToken } : {}), + source: 'rest', + }; +} + +function resolveMutationActor( + req: any, + workspacePath: string, + explicitActor: unknown, + defaultActor: string, +): string { + const fromBody = readNonEmptyString(explicitActor); + if (fromBody) return fromBody; + const fromHeader = readNonEmptyString(req?.headers?.['x-workgraph-actor']); + if (fromHeader) return fromHeader; + const bearerToken = readBearerToken(req?.headers?.authorization); + if (bearerToken) { + const verification = auth.verifyAgentCredential(workspacePath, bearerToken, { + touchLastUsed: false, + }); + if (verification.valid && verification.credential) { + return verification.credential.actor; + } + } + return defaultActor; +} + +function readBearerToken(headerValue: unknown): string | undefined { + const authorization = readNonEmptyString(headerValue); + if (!authorization || !authorization.startsWith('Bearer ')) { + return undefined; + } + return readNonEmptyString(authorization.slice('Bearer '.length)); +} diff --git a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap index b147ada..8a869fb 100644 --- a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap +++ b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap @@ -13,6 +13,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], @@ -21,6 +22,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], @@ -34,6 +36,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], @@ -43,6 +46,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], @@ -54,6 +58,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], @@ -64,6 +69,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], @@ -84,6 +90,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], @@ -95,6 +102,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], @@ -106,6 +114,7 @@ exports[`schema drift regression > locks CLI option signatures for critical comm "-w, --workspace ", "--vault ", "--shared-vault ", + "--api-key ", "--dry-run", "-h, --help", ], diff --git a/packages/kernel/src/agent.test.ts b/packages/kernel/src/agent.test.ts index f129dfb..afdc8ba 100644 --- a/packages/kernel/src/agent.test.ts +++ b/packages/kernel/src/agent.test.ts @@ -2,10 +2,13 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import * as auth from './auth.js'; +import * as ledger from './ledger.js'; import { loadRegistry, saveRegistry } from './registry.js'; import * as agent from './agent.js'; import { getParty } from './policy.js'; import * as store from './store.js'; +import * as thread from './thread.js'; import { initWorkspace } from './workspace.js'; let workspacePath: string; @@ -133,4 +136,87 @@ describe('agent presence', () => { token: initResult.bootstrapTrustToken, })).toThrow('already been used'); }); + + it('supports approval-based registration with strict scoped credential enforcement + revocation', () => { + const initResult = initWorkspace(workspacePath, { createReadme: false }); + const admin = agent.registerAgent(workspacePath, 'admin-agent', { + token: initResult.bootstrapTrustToken, + capabilities: ['agent:approve-registration', 'thread:create', 'thread:update', 'thread:complete'], + }); + expect(admin.apiKey).toBeDefined(); + + const serverConfigPath = path.join(workspacePath, '.workgraph', 'server.json'); + const serverConfig = JSON.parse(fs.readFileSync(serverConfigPath, 'utf-8')) as Record; + serverConfig.auth = { + mode: 'strict', + allowUnauthenticatedFallback: false, + }; + serverConfig.registration = { + ...(serverConfig.registration as Record), + mode: 'approval', + allowBootstrapFallback: true, + }; + fs.writeFileSync(serverConfigPath, `${JSON.stringify(serverConfig, null, 2)}\n`, 'utf-8'); + + const request = agent.submitRegistrationRequest(workspacePath, 'agent-beta', { + role: 'roles/contributor.md', + capabilities: ['thread:create'], + note: 'Need contributor access.', + }); + expect(request.request.fields.status).toBe('pending'); + + expect(() => + agent.reviewRegistrationRequest(workspacePath, request.request.path, 'admin-agent', 'approved'), + ).toThrow('strict auth mode requires a valid credential'); + + const reviewed = auth.runWithAuthContext( + { credentialToken: admin.apiKey, source: 'cli' }, + () => + agent.reviewRegistrationRequest( + workspacePath, + request.request.path, + 'admin-agent', + 'approved', + { note: 'Approved for contributor scope.' }, + ), + ); + expect(reviewed.decision).toBe('approved'); + expect(reviewed.apiKey).toBeDefined(); + expect(reviewed.credential?.actor).toBe('agent-beta'); + expect(reviewed.credential?.scopes).toContain('thread:create'); + + expect(() => + thread.createThread(workspacePath, 'Strict no token', 'Denied without credential', 'agent-beta'), + ).toThrow('strict auth mode requires a valid credential'); + + expect(() => + auth.runWithAuthContext( + { credentialToken: reviewed.apiKey, source: 'cli' }, + () => thread.createThread(workspacePath, 'Actor mismatch', 'Denied by identity mismatch', 'agent-gamma'), + ), + ).toThrow('does not match claimed actor'); + + const created = auth.runWithAuthContext( + { credentialToken: reviewed.apiKey, source: 'cli' }, + () => thread.createThread(workspacePath, 'Strict allowed', 'Valid credential actor', 'agent-beta'), + ); + expect(created.path).toBe('threads/strict-allowed.md'); + + const revoked = auth.runWithAuthContext( + { credentialToken: admin.apiKey, source: 'cli' }, + () => agent.revokeAgentCredential(workspacePath, reviewed.credential!.id, 'admin-agent', 'offboarded'), + ); + expect(revoked.status).toBe('revoked'); + + expect(() => + auth.runWithAuthContext( + { credentialToken: reviewed.apiKey, source: 'cli' }, + () => thread.createThread(workspacePath, 'After revoke', 'Should fail', 'agent-beta'), + ), + ).toThrow('Credential is revoked'); + + const authorizeEntries = ledger.readAll(workspacePath).filter((entry) => entry.op === 'authorize'); + expect(authorizeEntries.some((entry) => entry.data?.allowed === true)).toBe(true); + expect(authorizeEntries.some((entry) => entry.data?.allowed === false)).toBe(true); + }); }); diff --git a/packages/kernel/src/agent.ts b/packages/kernel/src/agent.ts index 1b89d2b..be96871 100644 --- a/packages/kernel/src/agent.ts +++ b/packages/kernel/src/agent.ts @@ -3,10 +3,12 @@ */ import path from 'node:path'; +import * as auth from './auth.js'; import * as policy from './policy.js'; +import * as registry from './registry.js'; import * as store from './store.js'; import { loadServerConfig } from './server-config.js'; -import type { PolicyParty, PrimitiveInstance } from './types.js'; +import type { FieldDefinition, PolicyParty, PrimitiveInstance } from './types.js'; export type AgentPresenceStatus = 'online' | 'busy' | 'offline'; @@ -35,11 +37,51 @@ export interface AgentRegistrationResult { trustTokenStatus: string; policyParty: PolicyParty; presence: PrimitiveInstance; + credential?: auth.AgentCredential; + apiKey?: string; +} + +export interface AgentRegistrationRequestOptions { + role?: string; + capabilities?: string[]; + actor?: string; + note?: string; +} + +export interface AgentRegistrationRequestResult { + agentName: string; + requestedRolePath: string; + requestedCapabilities: string[]; + request: PrimitiveInstance; +} + +export type AgentRegistrationDecision = 'approved' | 'rejected'; + +export interface AgentRegistrationReviewOptions { + role?: string; + capabilities?: string[]; + scopes?: string[]; + expiresAt?: string; + note?: string; +} + +export interface AgentRegistrationReviewResult { + request: PrimitiveInstance; + approval: PrimitiveInstance; + decision: AgentRegistrationDecision; + policyParty?: PolicyParty; + presence?: PrimitiveInstance; + credential?: auth.AgentCredential; + apiKey?: string; } const PRESENCE_TYPE = 'presence'; const ROLE_TYPE = 'role'; const TRUST_TOKEN_TYPE = 'trust-token'; +const REGISTRATION_REQUEST_TYPE = 'agent-registration-request'; +const REGISTRATION_APPROVAL_TYPE = 'agent-registration-approval'; +const REGISTRATION_REQUESTS_DIR = 'agent-registration-requests'; +const REGISTRATION_APPROVALS_DIR = 'agent-registration-approvals'; const PRESENCE_STATUS_VALUES = new Set(['online', 'busy', 'offline']); export function heartbeat( @@ -110,6 +152,7 @@ export function registerAgent( name: string, options: AgentRegistrationOptions, ): AgentRegistrationResult { + ensureRegistrationPrimitiveTypes(workspacePath); const registrationToken = String(options.token ?? '').trim(); if (!registrationToken) { throw new Error('Trust token is required for agent registration.'); @@ -122,6 +165,11 @@ export function registerAgent( if (!serverConfig.registration.enabled) { throw new Error('Agent registration is disabled by workspace server config.'); } + if (serverConfig.registration.mode === 'approval' && !serverConfig.registration.allowBootstrapFallback) { + throw new Error( + 'Bootstrap registration is disabled. Submit a registration request and have it approved (`workgraph agent request/register-review`).', + ); + } const trustTokenPath = normalizePathLike(serverConfig.registration.bootstrapTokenPath); const trustToken = store.read(workspacePath, trustTokenPath); @@ -152,6 +200,13 @@ export function registerAgent( if (tokenStatus === 'used' && !usedBy.includes(normalizedAgentName)) { throw new Error(`Trust token at ${trustTokenPath} has already been used.`); } + const expiresAtRaw = readNonEmptyString(trustToken.fields.expires_at); + if (expiresAtRaw) { + const expiresAt = Date.parse(expiresAtRaw); + if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) { + throw new Error(`Trust token at ${trustTokenPath} has expired.`); + } + } const roleRef = options.role ?? readNonEmptyString(trustToken.fields.default_role) @@ -175,6 +230,9 @@ export function registerAgent( const policyParty = policy.upsertParty(workspacePath, normalizedAgentName, { roles: [roleName], capabilities: mergedCapabilities, + }, { + actor: options.actor ?? normalizedAgentName, + skipAuthorization: true, }); const presence = heartbeat(workspacePath, normalizedAgentName, { @@ -191,6 +249,13 @@ export function registerAgent( options.actor ?? normalizedAgentName, ); + const issuedCredential = auth.issueAgentCredential(workspacePath, { + actor: normalizedAgentName, + scopes: mergedCapabilities, + issuedBy: options.actor ?? normalizedAgentName, + note: `bootstrap registration via ${updatedTrustToken.path}`, + }); + return { agentName: normalizedAgentName, rolePath: role.path, @@ -200,9 +265,500 @@ export function registerAgent( trustTokenStatus: String(updatedTrustToken.fields.status ?? 'active'), policyParty, presence, + credential: issuedCredential.credential, + apiKey: issuedCredential.apiKey, + }; +} + +export function submitRegistrationRequest( + workspacePath: string, + name: string, + options: AgentRegistrationRequestOptions = {}, +): AgentRegistrationRequestResult { + ensureRegistrationPrimitiveTypes(workspacePath); + const serverConfig = loadServerConfig(workspacePath); + if (!serverConfig) { + throw new Error('Workspace server config not found. Run `workgraph init` first.'); + } + if (!serverConfig.registration.enabled) { + throw new Error('Agent registration is disabled by workspace server config.'); + } + + const agentName = normalizeAgentId(name); + if (!agentName) { + throw new Error(`Invalid agent name "${name}".`); + } + const requester = normalizeActor(options.actor ?? agentName); + const requestedRolePath = resolveRolePath(options.role ?? 'roles/contributor'); + const requestedCapabilities = dedupeStrings(normalizeCapabilities(options.capabilities)); + const note = normalizeTask(options.note); + const now = new Date().toISOString(); + const requestPath = `${REGISTRATION_REQUESTS_DIR}/${agentName}-${Date.now()}.md`; + + const request = store.create( + workspacePath, + REGISTRATION_REQUEST_TYPE, + { + title: `Agent registration request: ${agentName}`, + agent_name: agentName, + requested_role: requestedRolePath, + requested_capabilities: requestedCapabilities, + requested_by: requester, + requested_at: now, + status: 'pending', + tags: ['registration', 'request'], + }, + renderRegistrationRequestBody({ + agentName, + requestedRolePath, + requestedCapabilities, + requestedBy: requester, + requestedAt: now, + note, + }), + requester, + { + pathOverride: requestPath, + skipAuthorization: true, + action: 'agent.registration.request', + }, + ); + + return { + agentName, + requestedRolePath, + requestedCapabilities, + request, + }; +} + +export function listRegistrationRequests( + workspacePath: string, + status?: 'pending' | 'approved' | 'rejected', +): PrimitiveInstance[] { + ensureRegistrationPrimitiveTypes(workspacePath); + const targetStatus = readNonEmptyString(status)?.toLowerCase(); + return store.list(workspacePath, REGISTRATION_REQUEST_TYPE) + .filter((entry) => { + if (!targetStatus) return true; + return String(entry.fields.status ?? '').toLowerCase() === targetStatus; + }) + .sort((a, b) => + String(b.fields.requested_at ?? b.fields.created ?? '').localeCompare( + String(a.fields.requested_at ?? a.fields.created ?? ''), + ) + ); +} + +export function reviewRegistrationRequest( + workspacePath: string, + requestRef: string, + reviewer: string, + decision: AgentRegistrationDecision, + options: AgentRegistrationReviewOptions = {}, +): AgentRegistrationReviewResult { + ensureRegistrationPrimitiveTypes(workspacePath); + const normalizedReviewer = normalizeActor(reviewer); + if (!normalizedReviewer) { + throw new Error('Reviewer actor is required.'); + } + if (decision !== 'approved' && decision !== 'rejected') { + throw new Error(`Unsupported registration decision "${decision}".`); + } + + const request = resolveRegistrationRequest(workspacePath, requestRef); + if (!request) { + throw new Error(`Registration request not found: ${requestRef}`); + } + if (request.type !== REGISTRATION_REQUEST_TYPE) { + throw new Error(`Expected ${REGISTRATION_REQUEST_TYPE} at ${request.path}, found ${request.type}.`); + } + const requestStatus = String(request.fields.status ?? 'pending').toLowerCase(); + if (requestStatus !== 'pending') { + throw new Error(`Registration request ${request.path} has status "${requestStatus}" and cannot be reviewed.`); + } + auth.assertAuthorizedMutation(workspacePath, { + actor: normalizedReviewer, + action: 'agent.registration.review', + target: request.path, + requiredCapabilities: ['agent:approve-registration', 'agent:register', 'policy:manage'], + metadata: { + module: 'agent', + decision, + }, + }); + assertRegistrationPolicyApproval(workspacePath, normalizedReviewer); + + const agentName = normalizeAgentId(String(request.fields.agent_name ?? '')); + if (!agentName) { + throw new Error(`Registration request ${request.path} has invalid agent_name.`); + } + const requestedRolePath = resolveRolePath( + readNonEmptyString(options.role) + ?? readNonEmptyString(request.fields.requested_role) + ?? 'roles/contributor', + ); + const requestedCapabilities = dedupeStrings([ + ...normalizeCapabilities(request.fields.requested_capabilities), + ...normalizeCapabilities(options.capabilities), + ]); + const reviewNote = normalizeTask(options.note); + const now = new Date().toISOString(); + + let policyParty: PolicyParty | undefined; + let presence: PrimitiveInstance | undefined; + let issuedCredential: auth.IssueAgentCredentialResult | undefined; + let rolePath: string | undefined; + let approvedCapabilities: string[] | undefined; + if (decision === 'approved') { + const role = store.read(workspacePath, requestedRolePath); + if (!role) { + throw new Error(`Role primitive not found: ${requestedRolePath}`); + } + if (role.type !== ROLE_TYPE) { + throw new Error(`Expected role primitive at ${requestedRolePath}, found ${role.type}.`); + } + rolePath = role.path; + const roleName = inferRoleName(role.path); + approvedCapabilities = dedupeStrings([ + ...normalizeCapabilities(role.fields.capabilities), + ...requestedCapabilities, + ]); + policyParty = policy.upsertParty(workspacePath, agentName, { + roles: [roleName], + capabilities: approvedCapabilities, + }, { + actor: normalizedReviewer, + }); + presence = heartbeat(workspacePath, agentName, { + actor: normalizedReviewer, + status: 'online', + capabilities: approvedCapabilities, + }); + const credentialScopes = dedupeStrings(options.scopes ?? approvedCapabilities); + issuedCredential = auth.issueAgentCredential(workspacePath, { + actor: agentName, + scopes: credentialScopes, + issuedBy: normalizedReviewer, + expiresAt: options.expiresAt, + note: `registration approval ${request.path}`, + }); + } + + const approval = store.create( + workspacePath, + REGISTRATION_APPROVAL_TYPE, + { + title: `Registration ${decision}: ${agentName}`, + request_ref: request.path, + agent_name: agentName, + decision, + reviewer: normalizedReviewer, + reviewed_at: now, + role: rolePath, + granted_capabilities: approvedCapabilities ?? [], + granted_scopes: issuedCredential?.credential.scopes ?? [], + credential_id: issuedCredential?.credential.id, + reason: reviewNote, + tags: ['registration', 'approval', decision], + }, + renderRegistrationApprovalBody({ + agentName, + decision, + reviewer: normalizedReviewer, + reviewedAt: now, + rolePath, + approvedCapabilities: approvedCapabilities ?? [], + scopes: issuedCredential?.credential.scopes ?? [], + note: reviewNote, + }), + normalizedReviewer, + { + pathOverride: `${REGISTRATION_APPROVALS_DIR}/${agentName}-${decision}-${Date.now()}.md`, + action: 'agent.registration.approval.create', + }, + ); + + const updatedRequest = store.update( + workspacePath, + request.path, + { + status: decision, + reviewed_by: normalizedReviewer, + reviewed_at: now, + decision_reason: reviewNote, + approval_ref: approval.path, + approved_role: rolePath, + approved_capabilities: approvedCapabilities, + approved_scopes: issuedCredential?.credential.scopes, + credential_id: issuedCredential?.credential.id, + }, + appendReviewSection(request.body, { + decision, + reviewer: normalizedReviewer, + reviewedAt: now, + note: reviewNote, + approvalPath: approval.path, + }), + normalizedReviewer, + { + action: 'agent.registration.request.review', + }, + ); + + return { + request: updatedRequest, + approval, + decision, + ...(policyParty ? { policyParty } : {}), + ...(presence ? { presence } : {}), + ...(issuedCredential + ? { + credential: issuedCredential.credential, + apiKey: issuedCredential.apiKey, + } + : {}), }; } +export function revokeAgentCredential( + workspacePath: string, + credentialId: string, + actor: string, + reason?: string, +): auth.AgentCredential { + const normalizedActor = normalizeActor(actor); + if (!normalizedActor) { + throw new Error('Actor is required to revoke a credential.'); + } + auth.assertAuthorizedMutation(workspacePath, { + actor: normalizedActor, + action: 'agent.credential.revoke', + target: `.workgraph/auth/credentials/${credentialId}`, + requiredCapabilities: ['agent:register', 'policy:manage'], + metadata: { + module: 'agent', + }, + }); + return auth.revokeAgentCredential(workspacePath, credentialId, normalizedActor, reason); +} + +export function listAgentCredentials( + workspacePath: string, + actorFilter?: string, +): auth.AgentCredential[] { + return auth.listAgentCredentials(workspacePath, actorFilter); +} + +function ensureRegistrationPrimitiveTypes(workspacePath: string): void { + ensureType( + workspacePath, + REGISTRATION_REQUEST_TYPE, + 'Agent registration request pending governance approval.', + REGISTRATION_REQUESTS_DIR, + { + agent_name: { type: 'string', required: true }, + requested_role: { type: 'string', required: true }, + requested_capabilities: { type: 'list', default: [] }, + requested_by: { type: 'string', required: true }, + requested_at: { type: 'date', required: true }, + status: { + type: 'string', + required: true, + default: 'pending', + enum: ['pending', 'approved', 'rejected'], + }, + reviewed_by: { type: 'string' }, + reviewed_at: { type: 'date' }, + decision_reason: { type: 'string' }, + approval_ref: { type: 'ref', refTypes: [REGISTRATION_APPROVAL_TYPE] }, + approved_role: { type: 'string' }, + approved_capabilities: { type: 'list', default: [] }, + approved_scopes: { type: 'list', default: [] }, + credential_id: { type: 'string' }, + }, + ); + + ensureType( + workspacePath, + REGISTRATION_APPROVAL_TYPE, + 'Approval/rejection record for an agent registration request.', + REGISTRATION_APPROVALS_DIR, + { + request_ref: { type: 'ref', refTypes: [REGISTRATION_REQUEST_TYPE], required: true }, + agent_name: { type: 'string', required: true }, + decision: { + type: 'string', + required: true, + enum: ['approved', 'rejected'], + }, + reviewer: { type: 'string', required: true }, + reviewed_at: { type: 'date', required: true }, + role: { type: 'string' }, + granted_capabilities: { type: 'list', default: [] }, + granted_scopes: { type: 'list', default: [] }, + credential_id: { type: 'string' }, + reason: { type: 'string' }, + }, + ); +} + +function ensureType( + workspacePath: string, + typeName: string, + description: string, + directory: string, + fields: Record, +): void { + if (registry.getType(workspacePath, typeName)) return; + registry.defineType(workspacePath, typeName, description, fields, 'system', directory); +} + +function resolveRegistrationRequest( + workspacePath: string, + requestRef: string, +): PrimitiveInstance | null { + const normalizedRef = normalizePathLike(requestRef); + if (normalizedRef.includes('/')) { + return store.read(workspacePath, normalizedRef); + } + const normalizedSlug = normalizedRef.endsWith('.md') + ? normalizedRef.slice(0, -3) + : normalizedRef; + const candidates = store.list(workspacePath, REGISTRATION_REQUEST_TYPE); + const byPath = candidates.find((entry) => path.basename(entry.path, '.md') === normalizedSlug); + if (byPath) return byPath; + return candidates.find((entry) => + normalizeAgentId(entry.fields.agent_name) === normalizeAgentId(normalizedSlug) + ) ?? null; +} + +function renderRegistrationRequestBody(input: { + agentName: string; + requestedRolePath: string; + requestedCapabilities: string[]; + requestedBy: string; + requestedAt: string; + note: string | null; +}): string { + const lines = [ + '## Registration Request', + '', + `- agent: ${input.agentName}`, + `- requested_role: ${input.requestedRolePath}`, + `- requested_by: ${input.requestedBy}`, + `- requested_at: ${input.requestedAt}`, + '', + '## Requested Capabilities', + '', + ...(input.requestedCapabilities.length > 0 + ? input.requestedCapabilities.map((capability) => `- ${capability}`) + : ['- none']), + '', + ]; + if (input.note) { + lines.push('## Note'); + lines.push(''); + lines.push(input.note); + lines.push(''); + } + return lines.join('\n'); +} + +function renderRegistrationApprovalBody(input: { + agentName: string; + decision: AgentRegistrationDecision; + reviewer: string; + reviewedAt: string; + rolePath?: string; + approvedCapabilities: string[]; + scopes: string[]; + note: string | null; +}): string { + const lines = [ + '## Registration Review', + '', + `- agent: ${input.agentName}`, + `- decision: ${input.decision}`, + `- reviewer: ${input.reviewer}`, + `- reviewed_at: ${input.reviewedAt}`, + `- role: ${input.rolePath ?? 'n/a'}`, + '', + '## Granted Capabilities', + '', + ...(input.approvedCapabilities.length > 0 + ? input.approvedCapabilities.map((capability) => `- ${capability}`) + : ['- none']), + '', + '## Granted Scopes', + '', + ...(input.scopes.length > 0 + ? input.scopes.map((scope) => `- ${scope}`) + : ['- none']), + '', + ]; + if (input.note) { + lines.push('## Review Note'); + lines.push(''); + lines.push(input.note); + lines.push(''); + } + return lines.join('\n'); +} + +function appendReviewSection( + existingBody: string, + input: { + decision: AgentRegistrationDecision; + reviewer: string; + reviewedAt: string; + note: string | null; + approvalPath: string; + }, +): string { + const lines = [ + existingBody.trimEnd(), + '', + '## Review Decision', + '', + `- decision: ${input.decision}`, + `- reviewer: ${input.reviewer}`, + `- reviewed_at: ${input.reviewedAt}`, + `- approval: [[${input.approvalPath}]]`, + ...(input.note ? [`- note: ${input.note}`] : []), + '', + ]; + return lines.join('\n'); +} + +function assertRegistrationPolicyApproval(workspacePath: string, reviewer: string): void { + const registrationPolicy = store.read(workspacePath, 'policies/registration-approval.md'); + if (!registrationPolicy || registrationPolicy.type !== 'policy') return; + const policyStatus = String(registrationPolicy.fields.status ?? 'active').toLowerCase(); + if (policyStatus !== 'active' && policyStatus !== 'approved') return; + const approverRefs = asStringList(registrationPolicy.fields.approvers); + if (approverRefs.length === 0) return; + + const reviewerParty = policy.getParty(workspacePath, reviewer); + if (!reviewerParty) { + throw new Error( + `Registration approval policy requires reviewer "${reviewer}" to be a registered policy party.`, + ); + } + const allowedRoles = new Set( + approverRefs + .map((ref) => inferRoleName(resolveRolePath(ref))) + .filter(Boolean), + ); + const reviewerRoles = new Set(reviewerParty.roles.map((role) => normalizeActor(role))); + const isAllowed = [...allowedRoles].some((role) => reviewerRoles.has(normalizeActor(role))); + if (!isAllowed) { + throw new Error( + `Registration approval policy blocked reviewer "${reviewer}". Required one of roles [${[...allowedRoles].join(', ')}].`, + ); + } +} + function normalizeStatus(value: unknown): AgentPresenceStatus | null { const normalized = String(value ?? '').trim().toLowerCase() as AgentPresenceStatus; if (!PRESENCE_STATUS_VALUES.has(normalized)) return null; @@ -272,6 +828,10 @@ function normalizeAgentId(value: unknown): string { .replace(/^-|-$/g, ''); } +function normalizeActor(value: unknown): string { + return String(value ?? '').trim().toLowerCase(); +} + function resolveRolePath(roleRef: string): string { const normalizedRef = normalizePathLike(roleRef); if (normalizedRef.includes('/')) return normalizedRef; diff --git a/packages/kernel/src/auth.ts b/packages/kernel/src/auth.ts new file mode 100644 index 0000000..c68067c --- /dev/null +++ b/packages/kernel/src/auth.ts @@ -0,0 +1,734 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import * as ledger from './ledger.js'; +import { loadServerConfig, type WorkgraphAuthMode } from './server-config.js'; + +const CREDENTIAL_STORE_FILE = '.workgraph/auth/credentials.json'; +const CREDENTIAL_STORE_VERSION = 1; +const API_KEY_PREFIX = 'wgk_'; +const POLICY_FILE = '.workgraph/policy.json'; + +interface CredentialStore { + version: number; + credentials: StoredCredential[]; +} + +type CredentialStatus = 'active' | 'revoked'; + +interface StoredCredential { + id: string; + actor: string; + scopes: string[]; + status: CredentialStatus; + issuedAt: string; + issuedBy: string; + expiresAt?: string; + revokedAt?: string; + revokedBy?: string; + note?: string; + lastUsedAt?: string; + secretSalt: string; + secretHash: string; +} + +interface PolicyRegistrySnapshot { + version: number; + parties: Record; +} + +export interface AgentCredential { + id: string; + actor: string; + scopes: string[]; + status: CredentialStatus; + issuedAt: string; + issuedBy: string; + expiresAt?: string; + revokedAt?: string; + revokedBy?: string; + note?: string; + lastUsedAt?: string; +} + +export interface IssueAgentCredentialInput { + actor: string; + scopes: string[]; + issuedBy: string; + expiresAt?: string; + note?: string; +} + +export interface IssueAgentCredentialResult { + apiKey: string; + credential: AgentCredential; +} + +export interface VerifyAgentCredentialOptions { + touchLastUsed?: boolean; +} + +export interface VerifyAgentCredentialResult { + valid: boolean; + reason?: string; + looksLikeCredential: boolean; + credential?: AgentCredential; +} + +export interface WorkgraphAuthContext { + credentialToken?: string; + source?: 'cli' | 'mcp' | 'rest' | 'internal'; +} + +export interface MutationAuthorizationInput { + actor: string; + action: string; + target?: string; + requiredCapabilities?: string[]; + requiredScopes?: string[]; + allowUnauthenticatedFallback?: boolean; + allowSystemActor?: boolean; + metadata?: Record; +} + +export interface MutationAuthorizationDecision { + allowed: boolean; + actor: string; + action: string; + mode: WorkgraphAuthMode; + reason?: string; + credentialId?: string; + identityVerified: boolean; + usedFallback: boolean; +} + +const AUTH_CONTEXT = new AsyncLocalStorage(); + +export function runWithAuthContext( + context: WorkgraphAuthContext, + fn: () => T, +): T { + return AUTH_CONTEXT.run(sanitizeAuthContext(context), fn); +} + +export function getAuthContext(): WorkgraphAuthContext | null { + return AUTH_CONTEXT.getStore() ?? null; +} + +export function issueAgentCredential( + workspacePath: string, + input: IssueAgentCredentialInput, +): IssueAgentCredentialResult { + const actor = normalizeActor(input.actor); + const issuedBy = normalizeActor(input.issuedBy); + if (!actor) { + throw new Error('Cannot issue credential: actor is required.'); + } + if (!issuedBy) { + throw new Error('Cannot issue credential: issuedBy is required.'); + } + + const scopes = dedupeStrings(input.scopes); + if (scopes.length === 0) { + throw new Error('Cannot issue credential: at least one scope is required.'); + } + const expiresAt = normalizeOptionalIsoDate(input.expiresAt); + const note = normalizeOptionalText(input.note); + const now = new Date().toISOString(); + + const credentialId = randomBytes(12).toString('hex'); + const secret = randomBytes(24).toString('hex'); + const secretSalt = randomBytes(16).toString('hex'); + const secretHash = hashCredentialSecret(secretSalt, secret); + + const store = loadCredentialStore(workspacePath); + const credential: StoredCredential = { + id: credentialId, + actor, + scopes, + status: 'active', + issuedAt: now, + issuedBy, + ...(expiresAt ? { expiresAt } : {}), + ...(note ? { note } : {}), + secretSalt, + secretHash, + }; + store.credentials.push(credential); + saveCredentialStore(workspacePath, store); + + ledger.append(workspacePath, issuedBy, 'create', credentialLedgerTarget(credentialId), 'credential', { + credential_id: credentialId, + actor, + scopes, + ...(expiresAt ? { expires_at: expiresAt } : {}), + }); + + return { + apiKey: `${API_KEY_PREFIX}${credentialId}.${secret}`, + credential: toAgentCredential(credential), + }; +} + +export function revokeAgentCredential( + workspacePath: string, + credentialId: string, + revokedBy: string, + reason?: string, +): AgentCredential { + const normalizedId = normalizeCredentialId(credentialId); + const actor = normalizeActor(revokedBy); + if (!normalizedId) { + throw new Error('Credential id is required.'); + } + if (!actor) { + throw new Error('revokedBy actor is required.'); + } + + const store = loadCredentialStore(workspacePath); + const credential = store.credentials.find((entry) => entry.id === normalizedId); + if (!credential) { + throw new Error(`Credential not found: ${normalizedId}`); + } + if (credential.status === 'revoked') { + return toAgentCredential(credential); + } + + const now = new Date().toISOString(); + credential.status = 'revoked'; + credential.revokedAt = now; + credential.revokedBy = actor; + const normalizedReason = normalizeOptionalText(reason); + if (normalizedReason) { + credential.note = credential.note + ? `${credential.note}\nrevocation_reason=${normalizedReason}` + : `revocation_reason=${normalizedReason}`; + } + saveCredentialStore(workspacePath, store); + + ledger.append(workspacePath, actor, 'update', credentialLedgerTarget(normalizedId), 'credential', { + credential_id: normalizedId, + status: 'revoked', + ...(normalizedReason ? { reason: normalizedReason } : {}), + }); + + return toAgentCredential(credential); +} + +export function listAgentCredentials( + workspacePath: string, + actor?: string, +): AgentCredential[] { + const store = loadCredentialStore(workspacePath); + const actorFilter = normalizeActor(actor); + return store.credentials + .filter((credential) => !actorFilter || credential.actor === actorFilter) + .map((credential) => toAgentCredential(credential)); +} + +export function verifyAgentCredential( + workspacePath: string, + apiKey: string | undefined, + options: VerifyAgentCredentialOptions = {}, +): VerifyAgentCredentialResult { + const parsed = parseCredentialToken(apiKey); + if (!parsed) { + return { + valid: false, + reason: 'Credential token is missing.', + looksLikeCredential: false, + }; + } + if (!parsed.looksLikeCredential) { + return { + valid: false, + reason: 'Token is not a WorkGraph credential token.', + looksLikeCredential: false, + }; + } + if (!parsed.id || !parsed.secret) { + return { + valid: false, + reason: 'Credential token format is invalid.', + looksLikeCredential: true, + }; + } + + const store = loadCredentialStore(workspacePath); + const credential = store.credentials.find((entry) => entry.id === parsed.id); + if (!credential) { + return { + valid: false, + reason: 'Credential not found.', + looksLikeCredential: true, + }; + } + + const expected = hashCredentialSecret(credential.secretSalt, parsed.secret); + const expectedBuffer = Buffer.from(expected, 'utf-8'); + const actualBuffer = Buffer.from(credential.secretHash, 'utf-8'); + if ( + expectedBuffer.length !== actualBuffer.length || + !timingSafeEqual(expectedBuffer, actualBuffer) + ) { + return { + valid: false, + reason: 'Credential secret mismatch.', + looksLikeCredential: true, + }; + } + + if (credential.status !== 'active') { + return { + valid: false, + reason: `Credential is ${credential.status}.`, + looksLikeCredential: true, + }; + } + if (credential.expiresAt) { + const expiresAtMs = Date.parse(credential.expiresAt); + if (Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now()) { + return { + valid: false, + reason: 'Credential has expired.', + looksLikeCredential: true, + }; + } + } + + if (options.touchLastUsed !== false) { + credential.lastUsedAt = new Date().toISOString(); + saveCredentialStore(workspacePath, store); + } + + return { + valid: true, + looksLikeCredential: true, + credential: toAgentCredential(credential), + }; +} + +export function authorizeMutation( + workspacePath: string, + input: MutationAuthorizationInput, +): MutationAuthorizationDecision { + const actor = normalizeActor(input.actor); + const action = normalizeAction(input.action); + const target = normalizeOptionalText(input.target); + const mode = resolveAuthMode(workspacePath); + const requiredCapabilities = dedupeStrings(input.requiredCapabilities); + const requiredScopes = dedupeStrings(input.requiredScopes ?? requiredCapabilities); + const allowFallback = resolveAllowFallback(workspacePath, input.allowUnauthenticatedFallback); + const token = resolveCredentialToken(input); + const tokenProvided = !!token; + const verification = tokenProvided + ? verifyAgentCredential(workspacePath, token, { touchLastUsed: true }) + : null; + const verifiedCredential = verification?.valid ? verification.credential : undefined; + + const deny = (reason: string): MutationAuthorizationDecision => + finalizeDecision(workspacePath, { + allowed: false, + actor, + action, + mode, + reason, + identityVerified: !!verifiedCredential, + usedFallback: false, + ...(verifiedCredential ? { credentialId: verifiedCredential.id } : {}), + }, target, requiredCapabilities, requiredScopes, input.metadata); + + if (!actor) { + return deny('Mutation blocked: actor is required.'); + } + if (!action) { + return deny('Mutation blocked: action is required.'); + } + if (input.allowSystemActor && actor === 'system') { + return finalizeDecision(workspacePath, { + allowed: true, + actor, + action, + mode, + identityVerified: true, + usedFallback: false, + }, target, requiredCapabilities, requiredScopes, input.metadata); + } + + if (verification && !verification.valid && verification.looksLikeCredential) { + return deny(`Identity verification failed: ${verification.reason ?? 'invalid credential.'}`); + } + + if (mode === 'strict' && !verifiedCredential) { + return deny('Identity verification failed: strict auth mode requires a valid credential.'); + } + + if (verifiedCredential && verifiedCredential.actor !== actor) { + return deny( + `Identity verification failed: credential actor "${verifiedCredential.actor}" does not match claimed actor "${actor}".`, + ); + } + + const party = getPolicyParty(workspacePath, actor); + const modeAllowsLegacyFallback = (mode === 'legacy' || mode === 'hybrid') && allowFallback; + const mayUseFallback = modeAllowsLegacyFallback && !verifiedCredential; + + if (!party && !mayUseFallback) { + return deny(`Policy gate blocked mutation: actor "${actor}" is not a registered party.`); + } + + if (party && requiredCapabilities.length > 0) { + const hasRequiredCapability = requiredCapabilities.some((capability) => + capabilitySatisfied(party.capabilities, capability) + ); + if (!hasRequiredCapability && !mayUseFallback) { + return deny( + `Policy gate blocked mutation: actor "${actor}" lacks required capability. Required any of [${requiredCapabilities.join(', ')}].`, + ); + } + } + + if (verifiedCredential && requiredScopes.length > 0) { + const hasRequiredScope = requiredScopes.some((scope) => + capabilitySatisfied(verifiedCredential.scopes, scope) + ); + if (!hasRequiredScope) { + return deny( + `Credential scope blocked mutation: credential "${verifiedCredential.id}" lacks required scope. Required any of [${requiredScopes.join(', ')}].`, + ); + } + } + + return finalizeDecision(workspacePath, { + allowed: true, + actor, + action, + mode, + identityVerified: !!verifiedCredential, + usedFallback: mayUseFallback, + ...(verifiedCredential ? { credentialId: verifiedCredential.id } : {}), + }, target, requiredCapabilities, requiredScopes, input.metadata); +} + +export function assertAuthorizedMutation( + workspacePath: string, + input: MutationAuthorizationInput, +): void { + const decision = authorizeMutation(workspacePath, input); + if (!decision.allowed) { + throw new Error(decision.reason ?? `Mutation "${decision.action}" denied for actor "${decision.actor}".`); + } +} + +function finalizeDecision( + workspacePath: string, + decision: MutationAuthorizationDecision, + target: string | undefined, + requiredCapabilities: string[], + requiredScopes: string[], + metadata: Record | undefined, +): MutationAuthorizationDecision { + const context = getAuthContext(); + // Preserve backward-compatible ledger behavior during legacy/hybrid fallback mode. + // Permission decisions are always audited when identity is cryptographically verified + // or when authorization is denied. + const shouldAuditDecision = decision.identityVerified || !decision.allowed; + if (shouldAuditDecision) { + const auditTarget = target ?? `.workgraph/authz/${decision.action}`; + ledger.append(workspacePath, decision.actor || 'anonymous', 'authorize', auditTarget, 'authorization', { + action: decision.action, + mode: decision.mode, + allowed: decision.allowed, + ...(decision.reason ? { reason: decision.reason } : {}), + ...(decision.credentialId ? { credential_id: decision.credentialId } : {}), + identity_verified: decision.identityVerified, + used_fallback: decision.usedFallback, + required_capabilities: requiredCapabilities, + required_scopes: requiredScopes, + ...(context?.source ? { source: context.source } : {}), + ...(metadata ? { metadata } : {}), + }); + } + return decision; +} + +function loadCredentialStore(workspacePath: string): CredentialStore { + const cPath = credentialStorePath(workspacePath); + if (!fs.existsSync(cPath)) { + return { + version: CREDENTIAL_STORE_VERSION, + credentials: [], + }; + } + try { + const parsed = JSON.parse(fs.readFileSync(cPath, 'utf-8')) as CredentialStore; + if (!Array.isArray(parsed.credentials)) { + throw new Error('Invalid credential store shape.'); + } + return { + version: CREDENTIAL_STORE_VERSION, + credentials: parsed.credentials + .map((entry) => sanitizeStoredCredential(entry)) + .filter((entry): entry is StoredCredential => !!entry), + }; + } catch { + return { + version: CREDENTIAL_STORE_VERSION, + credentials: [], + }; + } +} + +function saveCredentialStore(workspacePath: string, store: CredentialStore): void { + const cPath = credentialStorePath(workspacePath); + const dir = path.dirname(cPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const serialized: CredentialStore = { + version: CREDENTIAL_STORE_VERSION, + credentials: store.credentials.map((credential) => ({ + id: credential.id, + actor: credential.actor, + scopes: credential.scopes, + status: credential.status, + issuedAt: credential.issuedAt, + issuedBy: credential.issuedBy, + ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}), + ...(credential.revokedAt ? { revokedAt: credential.revokedAt } : {}), + ...(credential.revokedBy ? { revokedBy: credential.revokedBy } : {}), + ...(credential.note ? { note: credential.note } : {}), + ...(credential.lastUsedAt ? { lastUsedAt: credential.lastUsedAt } : {}), + secretSalt: credential.secretSalt, + secretHash: credential.secretHash, + })), + }; + fs.writeFileSync(cPath, `${JSON.stringify(serialized, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600, + }); + try { + fs.chmodSync(cPath, 0o600); + } catch { + // Best effort: chmod may fail on some platforms/filesystems. + } +} + +function credentialStorePath(workspacePath: string): string { + return path.join(workspacePath, CREDENTIAL_STORE_FILE); +} + +function credentialLedgerTarget(credentialId: string): string { + return `.workgraph/auth/credentials/${credentialId}`; +} + +function resolveAuthMode(workspacePath: string): WorkgraphAuthMode { + const config = loadServerConfig(workspacePath); + return config?.auth.mode ?? 'legacy'; +} + +function resolveAllowFallback( + workspacePath: string, + requested: boolean | undefined, +): boolean { + if (typeof requested === 'boolean') return requested; + const config = loadServerConfig(workspacePath); + return config?.auth.allowUnauthenticatedFallback ?? true; +} + +function resolveCredentialToken(input: MutationAuthorizationInput): string | undefined { + const fromContext = normalizeOptionalText(getAuthContext()?.credentialToken); + if (fromContext) return fromContext; + const fromEnv = normalizeOptionalText(process.env.WORKGRAPH_AGENT_API_KEY) + ?? normalizeOptionalText(process.env.WORKGRAPH_API_KEY); + return fromEnv; +} + +function getPolicyParty( + workspacePath: string, + actor: string, +): { id: string; capabilities: string[] } | null { + const policyPath = path.join(workspacePath, POLICY_FILE); + if (!fs.existsSync(policyPath)) { + return actor === 'system' + ? { + id: 'system', + capabilities: ['promote:sensitive', 'dispatch:run', 'policy:manage', 'gate:manage', 'agent:register'], + } + : null; + } + try { + const parsed = JSON.parse(fs.readFileSync(policyPath, 'utf-8')) as PolicyRegistrySnapshot; + const party = parsed.parties?.[actor]; + if (!party) return null; + return { + id: String(party.id ?? actor), + capabilities: dedupeStrings(asStringList(party.capabilities)), + }; + } catch { + return actor === 'system' + ? { + id: 'system', + capabilities: ['promote:sensitive', 'dispatch:run', 'policy:manage', 'gate:manage', 'agent:register'], + } + : null; + } +} + +function capabilitySatisfied(grantedCapabilities: string[], requiredCapability: string): boolean { + const required = normalizeOptionalText(requiredCapability); + if (!required) return true; + const granted = dedupeStrings(grantedCapabilities); + for (const capability of granted) { + if (capability === '*') return true; + if (capability === required) return true; + if (capability.endsWith(':*') && required.startsWith(`${capability.slice(0, -2)}:`)) { + return true; + } + } + return false; +} + +function parseCredentialToken(rawToken: string | undefined): { + looksLikeCredential: boolean; + id?: string; + secret?: string; +} | null { + const token = normalizeOptionalText(rawToken); + if (!token) return null; + if (!token.startsWith(API_KEY_PREFIX)) { + return { looksLikeCredential: false }; + } + const remainder = token.slice(API_KEY_PREFIX.length); + const separatorIndex = remainder.indexOf('.'); + if (separatorIndex <= 0 || separatorIndex >= remainder.length - 1) { + return { looksLikeCredential: true }; + } + const id = normalizeCredentialId(remainder.slice(0, separatorIndex)); + const secret = normalizeOptionalText(remainder.slice(separatorIndex + 1)); + return { + looksLikeCredential: true, + ...(id ? { id } : {}), + ...(secret ? { secret } : {}), + }; +} + +function sanitizeStoredCredential(value: unknown): StoredCredential | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const entry = value as Partial; + const id = normalizeCredentialId(entry.id); + const actor = normalizeActor(entry.actor); + const scopes = dedupeStrings(asStringList(entry.scopes)); + const issuedAt = normalizeOptionalIsoDate(entry.issuedAt); + const issuedBy = normalizeActor(entry.issuedBy); + const secretSalt = normalizeOptionalText(entry.secretSalt); + const secretHash = normalizeOptionalText(entry.secretHash); + if (!id || !actor || !issuedAt || !issuedBy || !secretSalt || !secretHash) { + return null; + } + const status: CredentialStatus = entry.status === 'revoked' ? 'revoked' : 'active'; + return { + id, + actor, + scopes, + status, + issuedAt, + issuedBy, + ...(normalizeOptionalIsoDate(entry.expiresAt) ? { expiresAt: normalizeOptionalIsoDate(entry.expiresAt)! } : {}), + ...(normalizeOptionalIsoDate(entry.revokedAt) ? { revokedAt: normalizeOptionalIsoDate(entry.revokedAt)! } : {}), + ...(normalizeActor(entry.revokedBy) ? { revokedBy: normalizeActor(entry.revokedBy)! } : {}), + ...(normalizeOptionalText(entry.note) ? { note: normalizeOptionalText(entry.note)! } : {}), + ...(normalizeOptionalIsoDate(entry.lastUsedAt) ? { lastUsedAt: normalizeOptionalIsoDate(entry.lastUsedAt)! } : {}), + secretSalt, + secretHash, + }; +} + +function toAgentCredential(credential: StoredCredential): AgentCredential { + return { + id: credential.id, + actor: credential.actor, + scopes: [...credential.scopes], + status: credential.status, + issuedAt: credential.issuedAt, + issuedBy: credential.issuedBy, + ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}), + ...(credential.revokedAt ? { revokedAt: credential.revokedAt } : {}), + ...(credential.revokedBy ? { revokedBy: credential.revokedBy } : {}), + ...(credential.note ? { note: credential.note } : {}), + ...(credential.lastUsedAt ? { lastUsedAt: credential.lastUsedAt } : {}), + }; +} + +function hashCredentialSecret(salt: string, secret: string): string { + return createHash('sha256') + .update(`${salt}:${secret}`, 'utf-8') + .digest('hex'); +} + +function normalizeActor(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase(); +} + +function normalizeAction(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase(); +} + +function normalizeCredentialId(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); +} + +function normalizeOptionalText(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeOptionalIsoDate(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Date.parse(trimmed); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid ISO date value "${trimmed}".`); + } + return new Date(parsed).toISOString(); +} + +function dedupeStrings(value: unknown): string[] { + return [...new Set(asStringList(value))]; +} + +function asStringList(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((entry) => String(entry).trim()) + .filter(Boolean); + } + if (typeof value === 'string') { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function sanitizeAuthContext(input: WorkgraphAuthContext): WorkgraphAuthContext { + return { + ...(normalizeOptionalText(input.credentialToken) + ? { credentialToken: normalizeOptionalText(input.credentialToken) } + : {}), + ...(input.source ? { source: input.source } : {}), + }; +} diff --git a/packages/kernel/src/dispatch.ts b/packages/kernel/src/dispatch.ts index 36129cd..335f133 100644 --- a/packages/kernel/src/dispatch.ts +++ b/packages/kernel/src/dispatch.ts @@ -5,6 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; +import * as auth from './auth.js'; import * as ledger from './ledger.js'; import * as store from './store.js'; import * as thread from './thread.js'; @@ -62,6 +63,9 @@ export interface DispatchClaimResult { } export function createRun(workspacePath: string, input: DispatchCreateInput): DispatchRun { + assertDispatchMutationAuthorized(workspacePath, input.actor, 'dispatch.run.create', '.workgraph/dispatch-runs', [ + 'dispatch:run', + ]); const state = loadRuns(workspacePath); if (input.idempotencyKey) { const existing = state.runs.find((run) => run.idempotencyKey === input.idempotencyKey); @@ -100,6 +104,10 @@ export function createRun(workspacePath: string, input: DispatchCreateInput): Di } export function claimThread(workspacePath: string, threadRef: string, actor: string): DispatchClaimResult { + assertDispatchMutationAuthorized(workspacePath, actor, 'dispatch.thread.claim', threadRef, [ + 'thread:claim', + 'thread:manage', + ]); const threadPath = resolveThreadRef(threadRef); const gateCheck = gate.checkThreadGates(workspacePath, threadPath); if (!gateCheck.allowed) { @@ -119,6 +127,9 @@ export function status(workspacePath: string, runId: string): DispatchRun { } export function followup(workspacePath: string, runId: string, actor: string, input: string): DispatchRun { + assertDispatchMutationAuthorized(workspacePath, actor, 'dispatch.run.followup', runId, [ + 'dispatch:run', + ]); const state = loadRuns(workspacePath); const run = state.runs.find((entry) => entry.id === runId); if (!run) throw new Error(`Run not found: ${runId}`); @@ -153,6 +164,9 @@ export function markRun( nextStatus: Exclude, options: { output?: string; error?: string; contextPatch?: Record } = {}, ): DispatchRun { + assertDispatchMutationAuthorized(workspacePath, actor, 'dispatch.run.mark', runId, [ + 'dispatch:run', + ]); const run = setStatus(workspacePath, runId, actor, nextStatus, `Run moved to ${nextStatus}.`); if (options.output) run.output = options.output; if (options.error) run.error = options.error; @@ -180,6 +194,9 @@ export function heartbeat( runId: string, input: DispatchHeartbeatInput, ): DispatchRun { + assertDispatchMutationAuthorized(workspacePath, input.actor, 'dispatch.run.heartbeat', runId, [ + 'dispatch:run', + ]); const state = loadRuns(workspacePath); const run = state.runs.find((entry) => entry.id === runId); if (!run) throw new Error(`Run not found: ${runId}`); @@ -210,6 +227,10 @@ export function reconcileExpiredLeases( workspacePath: string, actor: string, ): DispatchReconcileResult { + assertDispatchMutationAuthorized(workspacePath, actor, 'dispatch.run.reconcile', '.workgraph/dispatch-runs', [ + 'dispatch:run', + 'policy:manage', + ]); const state = loadRuns(workspacePath); const nowMs = Date.now(); const nowIso = new Date(nowMs).toISOString(); @@ -255,6 +276,9 @@ export function handoffRun( runId: string, input: DispatchHandoffInput, ): DispatchHandoffResult { + assertDispatchMutationAuthorized(workspacePath, input.actor, 'dispatch.run.handoff', runId, [ + 'dispatch:run', + ]); const sourceRun = status(workspacePath, runId); const now = new Date().toISOString(); const handoffContext: Record = { @@ -314,6 +338,9 @@ export async function executeRun( runId: string, input: DispatchExecuteInput, ): Promise { + assertDispatchMutationAuthorized(workspacePath, input.actor, 'dispatch.run.execute', runId, [ + 'dispatch:run', + ]); const existing = status(workspacePath, runId); if (!['queued', 'running'].includes(existing.status)) { throw new Error(`Run ${runId} is in terminal status "${existing.status}" and cannot be executed.`); @@ -376,6 +403,9 @@ function appendRunLogs( actor: string, logEntries: DispatchAdapterLogEntry[], ): void { + assertDispatchMutationAuthorized(workspacePath, actor, 'dispatch.run.logs', runId, [ + 'dispatch:run', + ]); if (logEntries.length === 0) return; const state = loadRuns(workspacePath); const run = state.runs.find((entry) => entry.id === runId); @@ -396,6 +426,9 @@ function setStatus( statusValue: RunStatus, logMessage: string, ): DispatchRun { + assertDispatchMutationAuthorized(workspacePath, actor, 'dispatch.run.status', runId, [ + 'dispatch:run', + ]); const state = loadRuns(workspacePath); const run = state.runs.find((entry) => entry.id === runId); if (!run) throw new Error(`Run not found: ${runId}`); @@ -607,3 +640,21 @@ function resolveThreadRef(threadRef: string): string { } return `threads/${unwrapped.endsWith('.md') ? unwrapped : `${unwrapped}.md`}`; } + +function assertDispatchMutationAuthorized( + workspacePath: string, + actor: string, + action: string, + target: string, + requiredCapabilities: string[], +): void { + auth.assertAuthorizedMutation(workspacePath, { + actor, + action, + target, + requiredCapabilities, + metadata: { + module: 'dispatch', + }, + }); +} diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index cfa90eb..cf5edbf 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -1,6 +1,7 @@ export * from './types.js'; export * as registry from './registry.js'; export * as ledger from './ledger.js'; +export * as auth from './auth.js'; export * as store from './store.js'; export * as thread from './thread.js'; export * as workspace from './workspace.js'; diff --git a/packages/kernel/src/policy.ts b/packages/kernel/src/policy.ts index 3c9ae52..9a9cd74 100644 --- a/packages/kernel/src/policy.ts +++ b/packages/kernel/src/policy.ts @@ -4,6 +4,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import * as auth from './auth.js'; +import * as ledger from './ledger.js'; import type { PolicyParty, PolicyRegistry } from './types.js'; const POLICY_FILE = '.workgraph/policy.json'; @@ -53,7 +55,25 @@ export function upsertParty( roles?: string[]; capabilities?: string[]; }, + options: { + actor?: string; + skipAuthorization?: boolean; + } = {}, ): PolicyParty { + const actor = String(options.actor ?? 'system').trim() || 'system'; + if (!options.skipAuthorization) { + auth.assertAuthorizedMutation(workspacePath, { + actor, + action: 'policy.party.upsert', + target: `.workgraph/policy.json#party/${partyId}`, + requiredCapabilities: ['policy:manage', 'agent:register'], + allowSystemActor: true, + metadata: { + module: 'policy', + party_id: partyId, + }, + }); + } const registry = loadPolicyRegistry(workspacePath); const now = new Date().toISOString(); const existing = registry.parties[partyId]; @@ -66,6 +86,19 @@ export function upsertParty( }; registry.parties[partyId] = next; savePolicyRegistry(workspacePath, registry); + ledger.append( + workspacePath, + actor, + existing ? 'update' : 'create', + `.workgraph/policy.json#party/${partyId}`, + 'policy-party', + { + party_id: partyId, + roles: next.roles, + capabilities: next.capabilities, + ...(existing ? { replaced: true } : { replaced: false }), + }, + ); return next; } diff --git a/packages/kernel/src/registry.ts b/packages/kernel/src/registry.ts index 2efa359..0cac79e 100644 --- a/packages/kernel/src/registry.ts +++ b/packages/kernel/src/registry.ts @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import * as auth from './auth.js'; import type { FieldDefinition, PrimitiveTypeDefinition, Registry } from './types.js'; import * as ledger from './ledger.js'; @@ -407,6 +408,17 @@ export function defineType( actor: string, directory?: string, ): PrimitiveTypeDefinition { + auth.assertAuthorizedMutation(workspacePath, { + actor, + action: 'registry.define-type', + target: '.workgraph/registry.json', + requiredCapabilities: ['policy:manage'], + allowSystemActor: true, + metadata: { + module: 'registry', + type_name: name, + }, + }); const registry = loadRegistry(workspacePath); const safeName = name.toLowerCase().replace(/[^a-z0-9_-]/g, '-'); @@ -455,8 +467,19 @@ export function extendType( workspacePath: string, name: string, newFields: Record, - _actor: string, + actor: string, ): PrimitiveTypeDefinition { + auth.assertAuthorizedMutation(workspacePath, { + actor, + action: 'registry.extend-type', + target: '.workgraph/registry.json', + requiredCapabilities: ['policy:manage'], + allowSystemActor: true, + metadata: { + module: 'registry', + type_name: name, + }, + }); const registry = loadRegistry(workspacePath); const existing = registry.types[name]; if (!existing) throw new Error(`Type "${name}" not found in registry.`); diff --git a/packages/kernel/src/server-config.ts b/packages/kernel/src/server-config.ts index 6ca2756..5416bbb 100644 --- a/packages/kernel/src/server-config.ts +++ b/packages/kernel/src/server-config.ts @@ -7,10 +7,24 @@ const DEFAULT_SERVER_PORT = 8787; const DEFAULT_SERVER_ENDPOINT_PATH = '/mcp'; const DEFAULT_SERVER_ACTOR = 'system'; const DEFAULT_BOOTSTRAP_TOKEN_PATH = 'trust-tokens/bootstrap-first-agent.md'; +const DEFAULT_AUTH_MODE_EXISTING: WorkgraphAuthMode = 'legacy'; +const DEFAULT_AUTH_MODE_NEW: WorkgraphAuthMode = 'hybrid'; +const DEFAULT_REGISTRATION_MODE_EXISTING: WorkgraphServerRegistrationMode = 'legacy'; +const DEFAULT_REGISTRATION_MODE_NEW: WorkgraphServerRegistrationMode = 'approval'; + +export type WorkgraphAuthMode = 'legacy' | 'hybrid' | 'strict'; +export type WorkgraphServerRegistrationMode = 'legacy' | 'approval'; export interface WorkgraphServerRegistrationConfig { enabled: boolean; + mode: WorkgraphServerRegistrationMode; bootstrapTokenPath: string; + allowBootstrapFallback: boolean; +} + +export interface WorkgraphServerAuthConfig { + mode: WorkgraphAuthMode; + allowUnauthenticatedFallback: boolean; } export interface WorkgraphServerConfig { @@ -19,6 +33,7 @@ export interface WorkgraphServerConfig { endpointPath: string; defaultActor: string; bearerToken?: string; + auth: WorkgraphServerAuthConfig; registration: WorkgraphServerRegistrationConfig; } @@ -43,7 +58,7 @@ export function loadServerConfig(workspacePath: string): WorkgraphServerConfig | try { const parsed = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as Record; - return normalizeServerConfig(parsed); + return normalizeServerConfig(parsed, 'existing'); } catch { return null; } @@ -63,7 +78,7 @@ export function ensureServerConfig( registration: { bootstrapTokenPath: desiredBootstrapTokenPath, }, - }); + }, 'new'); writeServerConfig(workspacePath, createdConfig); return { config: createdConfig, @@ -108,19 +123,35 @@ function writeServerConfig(workspacePath: string, config: WorkgraphServerConfig) fs.writeFileSync(targetPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); } -function normalizeServerConfig(input: Record): WorkgraphServerConfig { +function normalizeServerConfig( + input: Record, + profile: 'existing' | 'new' = 'existing', +): WorkgraphServerConfig { const registrationInput = asRecord(input.registration); + const authInput = asRecord(input.auth); + const defaultAuthMode = profile === 'new' + ? DEFAULT_AUTH_MODE_NEW + : DEFAULT_AUTH_MODE_EXISTING; + const defaultRegistrationMode = profile === 'new' + ? DEFAULT_REGISTRATION_MODE_NEW + : DEFAULT_REGISTRATION_MODE_EXISTING; return { host: readString(input.host) ?? DEFAULT_SERVER_HOST, port: normalizePort(input.port), endpointPath: normalizeEndpointPath(readString(input.endpointPath)), defaultActor: readString(input.defaultActor) ?? DEFAULT_SERVER_ACTOR, bearerToken: readString(input.bearerToken), + auth: { + mode: readAuthMode(authInput.mode) ?? defaultAuthMode, + allowUnauthenticatedFallback: readBoolean(authInput.allowUnauthenticatedFallback) ?? true, + }, registration: { enabled: readBoolean(registrationInput.enabled) ?? true, + mode: readRegistrationMode(registrationInput.mode) ?? defaultRegistrationMode, bootstrapTokenPath: normalizePathRef( readString(registrationInput.bootstrapTokenPath) ?? DEFAULT_BOOTSTRAP_TOKEN_PATH, ), + allowBootstrapFallback: readBoolean(registrationInput.allowBootstrapFallback) ?? true, }, }; } @@ -174,3 +205,21 @@ function readBoolean(value: unknown): boolean | undefined { } return undefined; } + +function readAuthMode(value: unknown): WorkgraphAuthMode | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === 'legacy' || normalized === 'hybrid' || normalized === 'strict') { + return normalized; + } + return undefined; +} + +function readRegistrationMode(value: unknown): WorkgraphServerRegistrationMode | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === 'legacy' || normalized === 'approval') { + return normalized; + } + return undefined; +} diff --git a/packages/kernel/src/store.ts b/packages/kernel/src/store.ts index 205b3c5..e92b9f3 100644 --- a/packages/kernel/src/store.ts +++ b/packages/kernel/src/store.ts @@ -9,6 +9,7 @@ import matter from 'gray-matter'; import { loadRegistry, getType } from './registry.js'; import * as ledger from './ledger.js'; import * as graph from './graph.js'; +import * as auth from './auth.js'; import * as policy from './policy.js'; import type { PrimitiveInstance, PrimitiveTypeDefinition } from './types.js'; @@ -19,13 +20,20 @@ const TYPE_HINT_FIELD = '_wg_type'; // Create // --------------------------------------------------------------------------- +export interface PrimitiveCreateOptions { + pathOverride?: string; + skipAuthorization?: boolean; + requiredCapabilities?: string[]; + action?: string; +} + export function create( workspacePath: string, typeName: string, fields: Record, body: string, actor: string, - options: { pathOverride?: string } = {}, + options: PrimitiveCreateOptions = {}, ): PrimitiveInstance { const typeDef = getType(workspacePath, typeName); if (!typeDef) { @@ -52,6 +60,18 @@ export function create( if (!createPolicyDecision.allowed) { throw new Error(createPolicyDecision.reason ?? 'Policy gate blocked create transition.'); } + if (!options.skipAuthorization) { + auth.assertAuthorizedMutation(workspacePath, { + actor, + action: options.action ?? `store.${typeName}.create`, + target: options.pathOverride, + requiredCapabilities: options.requiredCapabilities ?? capabilitiesForStoreMutation(typeName, 'create'), + metadata: { + primitive_type: typeName, + mutation: 'create', + }, + }); + } validateFields(workspacePath, typeDef, mergedWithTypeHint, 'create'); const relDir = typeDef.directory; @@ -119,6 +139,9 @@ export function list(workspacePath: string, typeName: string): PrimitiveInstance export interface PrimitiveUpdateOptions { expectedEtag?: string; concurrentConflictMode?: 'warn' | 'error'; + skipAuthorization?: boolean; + requiredCapabilities?: string[]; + action?: string; } export function update( @@ -174,6 +197,18 @@ export function update( if (!transitionDecision.allowed) { throw new Error(transitionDecision.reason ?? 'Policy gate blocked status transition.'); } + if (!options.skipAuthorization) { + auth.assertAuthorizedMutation(workspacePath, { + actor, + action: options.action ?? `store.${existing.type}.update`, + target: existing.path, + requiredCapabilities: options.requiredCapabilities ?? capabilitiesForStoreMutation(existing.type, 'update'), + metadata: { + primitive_type: existing.type, + mutation: 'update', + }, + }); + } validateFields(workspacePath, typeDef, newFields, 'update'); const newBody = bodyUpdate ?? existing.body; @@ -202,9 +237,33 @@ export function update( // Delete (soft — moves to .workgraph/archive/) // --------------------------------------------------------------------------- -export function remove(workspacePath: string, relPath: string, actor: string): void { +export interface PrimitiveRemoveOptions { + skipAuthorization?: boolean; + requiredCapabilities?: string[]; + action?: string; +} + +export function remove( + workspacePath: string, + relPath: string, + actor: string, + options: PrimitiveRemoveOptions = {}, +): void { const absPath = path.join(workspacePath, relPath); if (!fs.existsSync(absPath)) throw new Error(`Not found: ${relPath}`); + const typeName = inferType(workspacePath, relPath, {}); + if (!options.skipAuthorization) { + auth.assertAuthorizedMutation(workspacePath, { + actor, + action: options.action ?? `store.${typeName}.delete`, + target: relPath, + requiredCapabilities: options.requiredCapabilities ?? capabilitiesForStoreMutation(typeName, 'delete'), + metadata: { + primitive_type: typeName, + mutation: 'delete', + }, + }); + } const archiveDir = path.join(workspacePath, '.workgraph', 'archive'); if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true }); @@ -212,7 +271,6 @@ export function remove(workspacePath: string, relPath: string, actor: string): v const archivePath = path.join(archiveDir, path.basename(relPath)); fs.renameSync(absPath, archivePath); - const typeName = inferType(workspacePath, relPath, {}); ledger.append(workspacePath, actor, 'delete', relPath, typeName); graph.refreshWikiLinkGraphIndex(workspacePath); } @@ -413,6 +471,35 @@ function normalizeRefPath(value: unknown): string { return unwrapped.endsWith('.md') ? unwrapped : `${unwrapped}.md`; } +function capabilitiesForStoreMutation( + typeName: string, + mutation: 'create' | 'update' | 'delete', +): string[] { + switch (typeName) { + case 'thread': + return mutation === 'create' + ? ['thread:create', 'thread:manage', 'policy:manage'] + : ['thread:update', 'thread:manage', 'thread:complete', 'policy:manage']; + case 'run': + return ['dispatch:run', 'policy:manage']; + case 'policy': + return ['policy:manage']; + case 'policy-gate': + return ['gate:manage', 'policy:manage']; + case 'checkpoint': + return ['checkpoint:create', 'dispatch:run', 'policy:manage']; + case 'role': + case 'trust-token': + case 'agent-registration-request': + case 'agent-registration-approval': + return ['agent:register', 'policy:manage']; + case 'presence': + return ['agent:heartbeat', 'agent:register', 'policy:manage']; + default: + return []; + } +} + function validateFields( workspacePath: string, typeDef: PrimitiveTypeDefinition, diff --git a/packages/kernel/src/thread.ts b/packages/kernel/src/thread.ts index f31e441..ad074fb 100644 --- a/packages/kernel/src/thread.ts +++ b/packages/kernel/src/thread.ts @@ -7,6 +7,7 @@ import path from 'node:path'; import crypto from 'node:crypto'; import * as ledger from './ledger.js'; import * as store from './store.js'; +import * as auth from './auth.js'; import * as claimLease from './claim-lease.js'; import * as triggerEngine from './trigger-engine.js'; import * as gate from './gate.js'; @@ -34,6 +35,11 @@ export function createThread( tags?: string[]; } = {}, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.create', 'threads', [ + 'thread:create', + 'thread:manage', + 'policy:manage', + ]); const normalizedSpace = opts.space ? normalizeWorkspaceRef(opts.space) : undefined; const contextRefs = opts.context_refs ?? []; const mergedContextRefs = normalizedSpace && !contextRefs.includes(normalizedSpace) @@ -54,7 +60,11 @@ export function createThread( space: normalizedSpace, context_refs: mergedContextRefs, tags: opts.tags ?? [], - }, `## Goal\n\n${goal}\n`, actor); + }, `## Goal\n\n${goal}\n`, actor, { + skipAuthorization: true, + action: 'thread.create.store', + requiredCapabilities: ['thread:create', 'thread:manage', 'policy:manage'], + }); } export function mintThreadId(title: string): string { @@ -144,10 +154,18 @@ export function claim( actor: string, options: { leaseTtlMinutes?: number } = {}, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.claim', threadPath, [ + 'thread:claim', + 'thread:manage', + ]); return withThreadClaimLock(workspacePath, threadPath, () => { const thread = store.read(workspacePath, threadPath); if (!thread) throw new Error(`Thread not found: ${threadPath}`); assertThreadNotTerminallyLocked(workspacePath, thread, actor, 'claim'); + const gateCheck = gate.checkThreadGates(workspacePath, threadPath); + if (!gateCheck.allowed) { + throw new Error(gate.summarizeGateFailures(gateCheck)); + } const status = thread.fields.status as ThreadStatus; if (status !== 'open') { @@ -163,7 +181,11 @@ export function claim( const claimed = store.update(workspacePath, threadPath, { status: 'active', owner: actor, - }, undefined, actor); + }, undefined, actor, { + skipAuthorization: true, + action: 'thread.claim.store', + requiredCapabilities: ['thread:claim', 'thread:manage'], + }); claimLease.setClaimLease(workspacePath, threadPath, actor, { ttlMinutes: options.leaseTtlMinutes, }); @@ -177,6 +199,10 @@ export function release( actor: string, reason?: string, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.release', threadPath, [ + 'thread:update', + 'thread:manage', + ]); const thread = store.read(workspacePath, threadPath); if (!thread) throw new Error(`Thread not found: ${threadPath}`); assertThreadNotTerminallyLocked(workspacePath, thread, actor, 'release'); @@ -189,7 +215,11 @@ export function release( const released = store.update(workspacePath, threadPath, { status: 'open', owner: null, - }, undefined, actor); + }, undefined, actor, { + skipAuthorization: true, + action: 'thread.release.store', + requiredCapabilities: ['thread:update', 'thread:manage'], + }); claimLease.removeClaimLease(workspacePath, threadPath); return released; } @@ -200,6 +230,10 @@ export function heartbeat( actor: string, leaseMinutes: number = 15, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.heartbeat', threadPath, [ + 'thread:update', + 'thread:manage', + ]); const thread = store.read(workspacePath, threadPath); if (!thread) throw new Error(`Thread not found: ${threadPath}`); assertThreadNotTerminallyLocked(workspacePath, thread, actor, 'heartbeat'); @@ -230,6 +264,11 @@ export function heartbeat( }, undefined, actor, + { + skipAuthorization: true, + action: 'thread.heartbeat.store', + requiredCapabilities: ['thread:update', 'thread:manage'], + }, ); } @@ -240,6 +279,10 @@ export function handoff( toActor: string, note?: string, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, fromActor, 'thread.handoff', threadPath, [ + 'thread:update', + 'thread:manage', + ]); const normalizedToActor = toActor.trim(); if (!normalizedToActor) { throw new Error('Handoff target actor must be a non-empty string.'); @@ -274,6 +317,11 @@ export function handoff( }, extraBody, fromActor, + { + skipAuthorization: true, + action: 'thread.handoff.store', + requiredCapabilities: ['thread:update', 'thread:manage'], + }, ); } @@ -288,6 +336,10 @@ export function block( blockedBy: string, reason?: string, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.block', threadPath, [ + 'thread:update', + 'thread:manage', + ]); const thread = store.read(workspacePath, threadPath); if (!thread) throw new Error(`Thread not found: ${threadPath}`); assertThreadNotTerminallyLocked(workspacePath, thread, actor, 'block'); @@ -308,7 +360,11 @@ export function block( return store.update(workspacePath, threadPath, { status: 'blocked', deps: updatedDeps, - }, undefined, actor); + }, undefined, actor, { + skipAuthorization: true, + action: 'thread.block.store', + requiredCapabilities: ['thread:update', 'thread:manage'], + }); } export function unblock( @@ -316,6 +372,10 @@ export function unblock( threadPath: string, actor: string, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.unblock', threadPath, [ + 'thread:update', + 'thread:manage', + ]); const thread = store.read(workspacePath, threadPath); if (!thread) throw new Error(`Thread not found: ${threadPath}`); assertThreadNotTerminallyLocked(workspacePath, thread, actor, 'unblock'); @@ -326,7 +386,11 @@ export function unblock( return store.update(workspacePath, threadPath, { status: 'active', - }, undefined, actor); + }, undefined, actor, { + skipAuthorization: true, + action: 'thread.unblock.store', + requiredCapabilities: ['thread:update', 'thread:manage'], + }); } export function done( @@ -336,9 +400,17 @@ export function done( output?: string, options: ThreadDoneOptions = {}, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.done', threadPath, [ + 'thread:complete', + 'thread:manage', + ]); const thread = store.read(workspacePath, threadPath); if (!thread) throw new Error(`Thread not found: ${threadPath}`); assertThreadNotTerminallyLocked(workspacePath, thread, actor, 'done'); + const gateCheck = gate.checkThreadGates(workspacePath, threadPath); + if (!gateCheck.allowed) { + throw new Error(gate.summarizeGateFailures(gateCheck)); + } const descendantCheck = gate.checkRequiredDescendants(workspacePath, threadPath); if (!descendantCheck.ok) { @@ -373,7 +445,11 @@ export function done( const completed = store.update(workspacePath, threadPath, { status: 'done', - }, newBody, actor); + }, newBody, actor, { + skipAuthorization: true, + action: 'thread.done.store', + requiredCapabilities: ['thread:complete', 'thread:manage'], + }); claimLease.removeClaimLease(workspacePath, threadPath); // Cascade trigger failures should not roll back a successful thread completion. @@ -392,6 +468,10 @@ export function cancel( actor: string, reason?: string, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.cancel', threadPath, [ + 'thread:update', + 'thread:manage', + ]); const thread = store.read(workspacePath, threadPath); if (!thread) throw new Error(`Thread not found: ${threadPath}`); assertThreadNotTerminallyLocked(workspacePath, thread, actor, 'cancel'); @@ -404,7 +484,11 @@ export function cancel( const cancelled = store.update(workspacePath, threadPath, { status: 'cancelled', owner: null, - }, undefined, actor); + }, undefined, actor, { + skipAuthorization: true, + action: 'thread.cancel.store', + requiredCapabilities: ['thread:update', 'thread:manage'], + }); claimLease.removeClaimLease(workspacePath, threadPath); return cancelled; } @@ -415,6 +499,10 @@ export function reopen( actor: string, reason?: string, ): PrimitiveInstance { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.reopen', threadPath, [ + 'thread:update', + 'thread:manage', + ]); const thread = store.read(workspacePath, threadPath); if (!thread) throw new Error(`Thread not found: ${threadPath}`); const status = String(thread.fields.status ?? '') as ThreadStatus; @@ -430,7 +518,11 @@ export function reopen( return store.update(workspacePath, threadPath, { status: 'open', owner: null, - }, undefined, actor); + }, undefined, actor, { + skipAuthorization: true, + action: 'thread.reopen.store', + requiredCapabilities: ['thread:update', 'thread:manage'], + }); } export interface ThreadHeartbeatResult { @@ -445,6 +537,10 @@ export function heartbeatClaim( threadPath?: string, options: { ttlMinutes?: number } = {}, ): ThreadHeartbeatResult { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.heartbeat-claim', threadPath ?? 'threads', [ + 'thread:update', + 'thread:manage', + ]); const targets = threadPath ? [threadPath] : store.list(workspacePath, 'thread') @@ -501,6 +597,10 @@ export function reapStaleClaims( actor: string, options: { limit?: number } = {}, ): ReapStaleClaimsResult { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.reap-stale-claims', '.workgraph/claim-leases', [ + 'thread:manage', + 'policy:manage', + ]); const staleLeases = claimLease .listClaimLeases(workspacePath) .filter((lease) => lease.stale) @@ -541,7 +641,11 @@ export function reapStaleClaims( store.update(workspacePath, lease.target, { status: 'open', owner: null, - }, undefined, actor); + }, undefined, actor, { + skipAuthorization: true, + action: 'thread.reap-stale.store', + requiredCapabilities: ['thread:manage', 'policy:manage'], + }); claimLease.removeClaimLease(workspacePath, lease.target); reaped.push({ threadPath: lease.target, @@ -572,6 +676,10 @@ export function decompose( subthreads: Array<{ title: string; goal: string; deps?: string[] }>, actor: string, ): PrimitiveInstance[] { + assertThreadMutationAuthorized(workspacePath, actor, 'thread.decompose', parentPath, [ + 'thread:update', + 'thread:manage', + ]); const parent = store.read(workspacePath, parentPath); if (!parent) throw new Error(`Thread not found: ${parentPath}`); @@ -589,7 +697,11 @@ export function decompose( const childRefs = created.map(c => `[[${c.path}]]`); const decomposeNote = `\n\n## Sub-threads\n\n${childRefs.map(r => `- ${r}`).join('\n')}\n`; - store.update(workspacePath, parentPath, {}, parent.body + decomposeNote, actor); + store.update(workspacePath, parentPath, {}, parent.body + decomposeNote, actor, { + skipAuthorization: true, + action: 'thread.decompose.store', + requiredCapabilities: ['thread:update', 'thread:manage'], + }); ledger.append(workspacePath, actor, 'decompose', parentPath, 'thread', { children: created.map(c => c.path), @@ -749,6 +861,24 @@ function withThreadClaimLock( } } +function assertThreadMutationAuthorized( + workspacePath: string, + actor: string, + action: string, + target: string, + requiredCapabilities: string[], +): void { + auth.assertAuthorizedMutation(workspacePath, { + actor, + action, + target, + requiredCapabilities, + metadata: { + module: 'thread', + }, + }); +} + function tryAcquireLock(lockPath: string, target: string): boolean { try { const fd = fs.openSync(lockPath, 'wx'); diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index 33c6e83..025832c 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -61,6 +61,7 @@ export type LedgerOp = | 'create' | 'update' | 'delete' + | 'authorize' | 'claim' | 'heartbeat' | 'release' diff --git a/packages/kernel/src/workspace.ts b/packages/kernel/src/workspace.ts index 3af1391..75c2bf8 100644 --- a/packages/kernel/src/workspace.ts +++ b/packages/kernel/src/workspace.ts @@ -203,6 +203,15 @@ Default server config is in \`.workgraph/server.json\` (host: ${input.serverHost Bootstrap trust token path: \`${input.bootstrapTrustTokenPath}\` Bootstrap trust token value: \`${input.bootstrapTrustToken}\` +Preferred (approval flow): + +\`\`\`bash +workgraph agent request agent-1 -w "${input.workspacePath}" --role roles/admin.md +workgraph agent review agent-1 -w "${input.workspacePath}" --decision approved --actor admin-approver +\`\`\` + +Bootstrap fallback (legacy/hybrid migration mode): + \`\`\`bash workgraph agent register agent-1 -w "${input.workspacePath}" --token ${input.bootstrapTrustToken} \`\`\` diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json index 79e486b..07eb465 100644 --- a/packages/kernel/tsconfig.json +++ b/packages/kernel/tsconfig.json @@ -4,5 +4,8 @@ "composite": true, "noEmit": true }, - "include": ["src/**/*"] + "include": [ + "src/**/*", + "../../tests/helpers/cli-build.ts" + ] } diff --git a/packages/mcp-server/src/mcp-http-server.test.ts b/packages/mcp-server/src/mcp-http-server.test.ts index e34ef71..1ca3f40 100644 --- a/packages/mcp-server/src/mcp-http-server.test.ts +++ b/packages/mcp-server/src/mcp-http-server.test.ts @@ -5,15 +5,19 @@ import path from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { + agent as agentModule, registry as registryModule, policy as policyModule, thread as threadModule, + workspace as workspaceModule, } from '@versatly/workgraph-kernel'; import { startWorkgraphMcpHttpServer } from './mcp-http-server.js'; +const agent = agentModule; const registry = registryModule; const policy = policyModule; const thread = threadModule; +const workspace = workspaceModule; let workspacePath: string; @@ -91,6 +95,92 @@ describe('mcp streamable http server', () => { await handle.close(); } }); + + it('enforces strict credential identity for MCP write tools', async () => { + const init = workspace.initWorkspace(workspacePath, { createReadme: false, createBases: false }); + const registration = agent.registerAgent(workspacePath, 'mcp-admin', { + token: init.bootstrapTrustToken, + capabilities: ['mcp:write', 'thread:claim', 'thread:done', 'dispatch:run', 'agent:approve-registration'], + }); + expect(registration.apiKey).toBeDefined(); + policy.upsertParty(workspacePath, 'mcp-admin', { + roles: ['admin'], + capabilities: ['mcp:write', 'thread:claim', 'thread:done', 'dispatch:run', 'agent:approve-registration'], + }, { + actor: 'mcp-admin', + skipAuthorization: true, + }); + thread.createThread(workspacePath, 'Strict MCP thread', 'Strict credential enforcement', 'seed'); + + const serverConfigPath = path.join(workspacePath, '.workgraph', 'server.json'); + const serverConfig = JSON.parse(fs.readFileSync(serverConfigPath, 'utf-8')) as Record; + serverConfig.auth = { + mode: 'strict', + allowUnauthenticatedFallback: false, + }; + fs.writeFileSync(serverConfigPath, `${JSON.stringify(serverConfig, null, 2)}\n`, 'utf-8'); + + const handle = await startWorkgraphMcpHttpServer({ + workspacePath, + defaultActor: 'system', + host: '127.0.0.1', + port: 0, + }); + const authClient = new Client({ + name: 'workgraph-mcp-http-strict-auth-client', + version: '1.0.0', + }); + const authTransport = new StreamableHTTPClientTransport(new URL(handle.url), { + requestInit: { + headers: { + authorization: `Bearer ${registration.apiKey}`, + }, + }, + }); + const anonymousClient = new Client({ + name: 'workgraph-mcp-http-strict-anon-client', + version: '1.0.0', + }); + const anonymousTransport = new StreamableHTTPClientTransport(new URL(handle.url), { + requestInit: { + headers: {}, + }, + }); + + await authClient.connect(authTransport); + await anonymousClient.connect(anonymousTransport); + try { + const spoofed = await authClient.callTool({ + name: 'workgraph_thread_claim', + arguments: { + threadPath: 'threads/strict-mcp-thread.md', + actor: 'spoofed-actor', + }, + }); + expect(isToolError(spoofed)).toBe(true); + + const claimed = await authClient.callTool({ + name: 'workgraph_thread_claim', + arguments: { + threadPath: 'threads/strict-mcp-thread.md', + actor: 'mcp-admin', + }, + }); + expect(isToolError(claimed)).toBe(false); + + const noCredentialWrite = await anonymousClient.callTool({ + name: 'workgraph_dispatch_create', + arguments: { + objective: 'strict mode should deny anonymous mutation', + }, + }); + expect(isToolError(noCredentialWrite)).toBe(true); + } finally { + await authClient.close(); + await anonymousClient.close(); + await handle.close(); + } + }); }); function extractStructured(result: unknown): T { diff --git a/packages/mcp-server/src/mcp-http-server.ts b/packages/mcp-server/src/mcp-http-server.ts index c7656f0..e3a384e 100644 --- a/packages/mcp-server/src/mcp-http-server.ts +++ b/packages/mcp-server/src/mcp-http-server.ts @@ -3,6 +3,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { auth as kernelAuth } from '@versatly/workgraph-kernel'; import { createWorkgraphMcpServer } from './mcp-server.js'; export interface WorkgraphMcpHttpServerOptions { @@ -41,6 +42,7 @@ export interface WorkgraphMcpHttpServerAppContext { interface SessionBinding { transport: StreamableHTTPServerTransport; server: McpServer; + authContext: kernelAuth.WorkgraphAuthContext; } export async function startWorkgraphMcpHttpServer( @@ -54,7 +56,7 @@ export async function startWorkgraphMcpHttpServer( allowedHosts: options.allowedHosts, }); const sessions: Record = {}; - const bearerAuthMiddleware = createBearerAuthMiddleware(options.bearerToken); + const bearerAuthMiddleware = createBearerAuthMiddleware(options.workspacePath, options.bearerToken); app.get('/health', (_req: unknown, res: any) => { res.json({ @@ -77,17 +79,20 @@ export async function startWorkgraphMcpHttpServer( app.post(endpointPath, async (req: any, res: any) => { const sessionId = readSessionId(req.headers['mcp-session-id']); try { + const requestAuthContext = buildRequestAuthContext(req, 'mcp'); let binding: SessionBinding | undefined; if (sessionId && sessions[sessionId]) { binding = sessions[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { let transport: StreamableHTTPServerTransport; + const sessionAuthContext = requestAuthContext; transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (generatedSessionId) => { sessions[generatedSessionId] = { transport, server: binding!.server, + authContext: sessionAuthContext, }; }, }); @@ -98,7 +103,7 @@ export async function startWorkgraphMcpHttpServer( name: options.name, version: options.version, }); - binding = { transport, server }; + binding = { transport, server, authContext: sessionAuthContext }; transport.onclose = () => { const sid = transport.sessionId; if (sid && sessions[sid]) { @@ -118,7 +123,12 @@ export async function startWorkgraphMcpHttpServer( return; } - await binding.transport.handleRequest(req, res, req.body); + const effectiveContext = requestAuthContext.credentialToken + ? requestAuthContext + : binding.authContext; + await kernelAuth.runWithAuthContext(effectiveContext, async () => { + await binding!.transport.handleRequest(req, res, req.body); + }); } catch (error) { if (!res.headersSent) { res.status(500).json({ @@ -139,7 +149,13 @@ export async function startWorkgraphMcpHttpServer( res.status(400).send('Invalid or missing MCP session ID.'); return; } - await sessions[sessionId].transport.handleRequest(req, res); + const requestAuthContext = buildRequestAuthContext(req, 'mcp'); + const effectiveContext = requestAuthContext.credentialToken + ? requestAuthContext + : sessions[sessionId].authContext; + await kernelAuth.runWithAuthContext(effectiveContext, async () => { + await sessions[sessionId].transport.handleRequest(req, res); + }); }); app.delete(endpointPath, async (req: any, res: any) => { @@ -148,7 +164,13 @@ export async function startWorkgraphMcpHttpServer( res.status(400).send('Invalid or missing MCP session ID.'); return; } - await sessions[sessionId].transport.handleRequest(req, res); + const requestAuthContext = buildRequestAuthContext(req, 'mcp'); + const effectiveContext = requestAuthContext.credentialToken + ? requestAuthContext + : sessions[sessionId].authContext; + await kernelAuth.runWithAuthContext(effectiveContext, async () => { + await sessions[sessionId].transport.handleRequest(req, res); + }); const binding = sessions[sessionId]; delete sessions[sessionId]; await binding.server.close(); @@ -236,20 +258,29 @@ function formatHostForUrl(host: string): string { return host; } -function createBearerAuthMiddleware(rawToken: string | undefined): WorkgraphMcpBearerAuthMiddleware { +function createBearerAuthMiddleware( + workspacePath: string, + rawToken: string | undefined, +): WorkgraphMcpBearerAuthMiddleware { const authToken = readString(rawToken); return (req: any, res: any, next: () => void) => { + const providedToken = readBearerToken(req.headers.authorization); if (!authToken) return next(); - const authorization = readString(req.headers.authorization); - if (!authorization || !authorization.startsWith('Bearer ')) { + if (!providedToken) { res.status(401).json({ ok: false, error: 'Missing bearer token.', }); return; } - const providedToken = authorization.slice('Bearer '.length); - if (providedToken !== authToken) { + if (providedToken === authToken) { + next(); + return; + } + const verified = kernelAuth.verifyAgentCredential(workspacePath, providedToken, { + touchLastUsed: false, + }); + if (!verified.valid) { res.status(403).json({ ok: false, error: 'Invalid bearer token.', @@ -259,3 +290,22 @@ function createBearerAuthMiddleware(rawToken: string | undefined): WorkgraphMcpB next(); }; } + +function readBearerToken(headerValue: unknown): string | undefined { + const authorization = readString(headerValue); + if (!authorization || !authorization.startsWith('Bearer ')) { + return undefined; + } + return readString(authorization.slice('Bearer '.length)); +} + +function buildRequestAuthContext( + req: any, + source: 'mcp' | 'rest', +): kernelAuth.WorkgraphAuthContext { + const credentialToken = readBearerToken(req?.headers?.authorization); + return { + ...(credentialToken ? { credentialToken } : {}), + source, + }; +} diff --git a/packages/mcp-server/src/mcp/auth.ts b/packages/mcp-server/src/mcp/auth.ts index c47cdf0..dcfa310 100644 --- a/packages/mcp-server/src/mcp/auth.ts +++ b/packages/mcp-server/src/mcp/auth.ts @@ -1,15 +1,32 @@ -import * as policy from '@versatly/workgraph-policy'; +import { auth as kernelAuth } from '@versatly/workgraph-kernel'; import { type WorkgraphMcpServerOptions } from './types.js'; -export function resolveActor(actor: string | undefined, defaultActor: string | undefined): string { - const resolved = actor ?? defaultActor ?? 'anonymous'; - return String(resolved); +export function resolveActor( + workspacePath: string, + actor: string | undefined, + defaultActor: string | undefined, +): string { + if (actor) return String(actor); + const contextToken = kernelAuth.getAuthContext()?.credentialToken; + if (contextToken) { + const verification = kernelAuth.verifyAgentCredential(workspacePath, contextToken, { + touchLastUsed: false, + }); + if (verification.valid && verification.credential) { + return verification.credential.actor; + } + } + return String(defaultActor ?? 'anonymous'); } export function checkWriteGate( options: WorkgraphMcpServerOptions, actor: string, requiredCapabilities: string[], + context: { + action: string; + target?: string; + }, ): { allowed: true } | { allowed: false; reason: string } { if (options.readOnly) { return { @@ -17,26 +34,22 @@ export function checkWriteGate( reason: 'MCP server is configured read-only; write tool is disabled.', }; } - - if (actor === 'system') { - return { allowed: true }; - } - - const party = policy.getParty(options.workspacePath, actor); - if (!party) { - return { - allowed: false, - reason: `Policy gate blocked MCP write: actor "${actor}" is not a registered party.`, - }; - } - - const hasCapability = requiredCapabilities.some((capability) => party.capabilities.includes(capability)); - if (!hasCapability) { + const decision = kernelAuth.authorizeMutation(options.workspacePath, { + actor, + action: context.action, + target: context.target, + requiredCapabilities, + requiredScopes: requiredCapabilities, + allowUnauthenticatedFallback: false, + metadata: { + surface: 'mcp', + }, + }); + if (!decision.allowed) { return { allowed: false, - reason: `Policy gate blocked MCP write: actor "${actor}" lacks capabilities [${requiredCapabilities.join(', ')}].`, + reason: decision.reason ?? 'Policy gate blocked MCP write.', }; } - return { allowed: true }; } diff --git a/packages/mcp-server/src/mcp/tools/read-tools.ts b/packages/mcp-server/src/mcp/tools/read-tools.ts index 6c4fb18..605eb8e 100644 --- a/packages/mcp-server/src/mcp/tools/read-tools.ts +++ b/packages/mcp-server/src/mcp/tools/read-tools.ts @@ -61,7 +61,7 @@ export function registerReadTools(server: McpServer, options: WorkgraphMcpServer }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); const brief = orientation.brief(options.workspacePath, actor, { recentCount: args.recentCount, nextCount: args.nextCount, diff --git a/packages/mcp-server/src/mcp/tools/write-tools.ts b/packages/mcp-server/src/mcp/tools/write-tools.ts index 3d6aae3..6a096cf 100644 --- a/packages/mcp-server/src/mcp/tools/write-tools.ts +++ b/packages/mcp-server/src/mcp/tools/write-tools.ts @@ -34,8 +34,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['thread:claim', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['thread:claim', 'mcp:write'], { + action: 'mcp.thread.claim', + target: args.threadPath, + }); if (!gate.allowed) return errorResult(gate.reason); const updated = thread.claim(options.workspacePath, args.threadPath, actor); return okResult({ thread: updated }, `Claimed ${updated.path} as ${actor}.`); @@ -63,8 +66,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['thread:done', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['thread:done', 'mcp:write'], { + action: 'mcp.thread.done', + target: args.threadPath, + }); if (!gate.allowed) return errorResult(gate.reason); const updated = thread.done(options.workspacePath, args.threadPath, actor, args.output, { evidence: args.evidence, @@ -95,8 +101,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['checkpoint:create', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['checkpoint:create', 'mcp:write'], { + action: 'mcp.checkpoint.create', + target: 'checkpoints', + }); if (!gate.allowed) return errorResult(gate.reason); const checkpoint = orientation.checkpoint(options.workspacePath, actor, args.summary, { next: args.next, @@ -128,8 +137,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write'], { + action: 'mcp.dispatch.create', + target: '.workgraph/dispatch-runs', + }); if (!gate.allowed) return errorResult(gate.reason); const run = dispatch.createRun(options.workspacePath, { actor, @@ -165,8 +177,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write'], { + action: 'mcp.dispatch.execute', + target: `.workgraph/runs/${args.runId}`, + }); if (!gate.allowed) return errorResult(gate.reason); const run = await dispatch.executeRun(options.workspacePath, args.runId, { actor, @@ -200,8 +215,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write'], { + action: 'mcp.dispatch.followup', + target: `.workgraph/runs/${args.runId}`, + }); if (!gate.allowed) return errorResult(gate.reason); const run = dispatch.followup(options.workspacePath, args.runId, actor, args.input); return okResult({ run }, `Follow-up recorded for ${run.id}.`); @@ -227,8 +245,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write'], { + action: 'mcp.dispatch.stop', + target: `.workgraph/runs/${args.runId}`, + }); if (!gate.allowed) return errorResult(gate.reason); const run = dispatch.stop(options.workspacePath, args.runId, actor); return okResult({ run }, `Stopped run ${run.id}.`); @@ -253,8 +274,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write'], { + action: 'mcp.trigger.cycle', + target: '.workgraph/trigger-state.json', + }); if (!gate.allowed) return errorResult(gate.reason); const result = triggerEngine.runTriggerEngineCycle(options.workspacePath, { actor, @@ -294,8 +318,11 @@ export function registerWriteTools(server: McpServer, options: WorkgraphMcpServe }, async (args) => { try { - const actor = resolveActor(args.actor, options.defaultActor); - const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write']); + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['dispatch:run', 'mcp:write'], { + action: 'mcp.autonomy.run', + target: '.workgraph/autonomy', + }); if (!gate.allowed) return errorResult(gate.reason); const result = await autonomy.runAutonomyLoop(options.workspacePath, { actor, diff --git a/src/server-events-and-lenses.test.ts b/src/server-events-and-lenses.test.ts deleted file mode 100644 index c95b490..0000000 --- a/src/server-events-and-lenses.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import matter from 'gray-matter'; -import * as ledger from './ledger.js'; -import * as thread from './thread.js'; -import { startWorkgraphServer } from './server.js'; - -let workspacePath: string; - -beforeEach(() => { - workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-server-events-')); -}); - -afterEach(() => { - fs.rmSync(workspacePath, { recursive: true, force: true }); -}); - -describe('workgraph server reactive dashboard endpoints', () => { - it('streams SSE events with Last-Event-ID replay semantics', async () => { - const handle = await startWorkgraphServer({ - workspacePath, - host: '127.0.0.1', - port: 0, - bearerToken: 'secret', - }); - - try { - const first = thread.createThread(workspacePath, 'Replay first', 'Goal', 'agent-a'); - const second = thread.createThread(workspacePath, 'Replay second', 'Goal', 'agent-a'); - const recentEntries = ledger.recent(workspacePath, 2); - const firstHash = recentEntries[0]?.hash; - expect(firstHash).toBeTruthy(); - - const client = await connectSse(`${handle.baseUrl}/api/events`, { - authorization: 'Bearer secret', - 'last-event-id': firstHash!, - }); - try { - await waitFor(() => - client.text().includes(`"path":"${second.path}"`) && - !client.text().includes(`"path":"${first.path}"`), - ); - - thread.claim(workspacePath, second.path, 'agent-live'); - await waitFor(() => - client.text().includes('event: thread.claimed') && - client.text().includes(`"path":"${second.path}"`) && - client.text().includes('"status":"active"'), - ); - } finally { - client.close(); - } - } finally { - await handle.close(); - } - }); - - it('returns smart lens aggregations with optional space filter', async () => { - const handle = await startWorkgraphServer({ - workspacePath, - host: '127.0.0.1', - port: 0, - }); - try { - const blocked = thread.createThread(workspacePath, 'Infra blocked', 'Blocked goal', 'seed', { - space: 'spaces/infrastructure', - priority: 'high', - }); - thread.claim(workspacePath, blocked.path, 'agent-blocked'); - thread.block(workspacePath, blocked.path, 'agent-blocked', 'external/vendor-api', 'Waiting on vendor'); - - thread.createThread(workspacePath, 'Infra urgent open', 'Urgent unclaimed', 'seed', { - space: 'spaces/infrastructure', - priority: 'urgent', - }); - - const stale = thread.createThread(workspacePath, 'Infra stale active', 'Stale active', 'seed', { - space: 'spaces/infrastructure', - }); - thread.claim(workspacePath, stale.path, 'agent-stale'); - setThreadUpdatedTimestamp(workspacePath, stale.path, new Date(Date.now() - (48 * 60 * 60 * 1_000)).toISOString()); - - thread.createThread(workspacePath, 'Dependency source', 'Still open dependency', 'seed', { - space: 'spaces/infrastructure', - }); - thread.createThread(workspacePath, 'Needs dependency', 'Blocked by [[threads/dependency-source.md]]', 'seed', { - space: 'spaces/infrastructure', - }); - - const frontendDone = thread.createThread(workspacePath, 'Frontend done', 'Frontend goal', 'seed', { - space: 'spaces/frontend', - }); - thread.claim(workspacePath, frontendDone.path, 'agent-frontend'); - thread.done(workspacePath, frontendDone.path, 'agent-frontend', 'Done from test https://github.com/Versatly/workgraph/pull/9'); - - const attentionResponse = await fetch(`${handle.baseUrl}/api/lens/attention?space=spaces/infrastructure.md`); - const attentionBody = await attentionResponse.json() as { - ok: boolean; - threads: Array<{ path: string; reason: string }>; - summary: { blocked: number; stale: number; urgent_unclaimed: number }; - }; - expect(attentionResponse.status).toBe(200); - expect(attentionBody.ok).toBe(true); - expect(attentionBody.summary).toEqual({ - blocked: 1, - stale: 1, - urgent_unclaimed: 1, - }); - expect(attentionBody.threads[0]).toMatchObject({ - path: blocked.path, - reason: 'blocked', - }); - expect(attentionBody.threads.some((item) => item.reason === 'unresolved_dependencies')).toBe(true); - - const agentsResponse = await fetch(`${handle.baseUrl}/api/lens/agents?space=spaces/infrastructure.md`); - const agentsBody = await agentsResponse.json() as { - ok: boolean; - agents: Array<{ name: string; actionCount: number; claimedThreads: string[]; online: boolean }>; - }; - expect(agentsResponse.status).toBe(200); - expect(agentsBody.ok).toBe(true); - const blockedAgent = agentsBody.agents.find((agent) => agent.name === 'agent-blocked'); - expect(blockedAgent).toBeTruthy(); - expect(blockedAgent!.actionCount).toBeGreaterThan(0); - expect(blockedAgent!.claimedThreads).toContain(blocked.path); - expect(typeof blockedAgent!.online).toBe('boolean'); - - const spacesResponse = await fetch(`${handle.baseUrl}/api/lens/spaces`); - const spacesBody = await spacesResponse.json() as { - ok: boolean; - spaces: Array<{ name: string; total: number; done: number; progress: number }>; - }; - expect(spacesResponse.status).toBe(200); - expect(spacesBody.ok).toBe(true); - const frontend = spacesBody.spaces.find((space) => space.name === 'spaces/frontend.md'); - expect(frontend).toBeTruthy(); - expect(frontend).toMatchObject({ - total: 1, - done: 1, - progress: 100, - }); - - const timelineResponse = await fetch(`${handle.baseUrl}/api/lens/timeline?space=spaces/infrastructure.md`); - const timelineBody = await timelineResponse.json() as { - ok: boolean; - events: Array<{ actor: string; operation: string; threadTitle?: string; changedFields: Record }>; - }; - expect(timelineResponse.status).toBe(200); - expect(timelineBody.ok).toBe(true); - expect(timelineBody.events.length).toBeGreaterThan(0); - expect( - timelineBody.events.some((event) => - event.operation === 'block' && - event.threadTitle === 'Infra blocked' && - event.changedFields.status === 'blocked', - ), - ).toBe(true); - } finally { - await handle.close(); - } - }); - - it('registers/lists/deletes webhooks and dispatches signed matching events', async () => { - const capture = await startWebhookCaptureServer(); - const handle = await startWorkgraphServer({ - workspacePath, - host: '127.0.0.1', - port: 0, - }); - try { - const registerResponse = await fetch(`${handle.baseUrl}/api/webhooks`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - url: capture.url, - events: ['thread.*'], - secret: 'top-secret', - }), - }); - const registerBody = await registerResponse.json() as { - ok: boolean; - webhook: { id: string; hasSecret: boolean }; - }; - expect(registerResponse.status).toBe(201); - expect(registerBody.ok).toBe(true); - expect(registerBody.webhook.hasSecret).toBe(true); - - const listResponse = await fetch(`${handle.baseUrl}/api/webhooks`); - const listBody = await listResponse.json() as { - ok: boolean; - count: number; - webhooks: Array<{ id: string }>; - }; - expect(listResponse.status).toBe(200); - expect(listBody.ok).toBe(true); - expect(listBody.count).toBe(1); - expect(listBody.webhooks[0].id).toBe(registerBody.webhook.id); - - thread.createThread(workspacePath, 'Webhook thread', 'Send webhook event', 'agent-webhook'); - await waitFor(() => capture.received.length >= 1); - const dispatched = capture.received[0]; - expect(dispatched.body).toContain('"type":"thread.created"'); - const expectedSignature = `sha256=${crypto.createHmac('sha256', 'top-secret').update(dispatched.body).digest('hex')}`; - expect(dispatched.headers['x-workgraph-signature']).toBe(expectedSignature); - - const deleteResponse = await fetch(`${handle.baseUrl}/api/webhooks/${registerBody.webhook.id}`, { - method: 'DELETE', - }); - expect(deleteResponse.status).toBe(200); - - const countAfterDelete = capture.received.length; - thread.createThread(workspacePath, 'Webhook no dispatch', 'Should not dispatch', 'agent-webhook'); - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(capture.received.length).toBe(countAfterDelete); - } finally { - await handle.close(); - await capture.close(); - } - }); -}); - -async function connectSse( - url: string, - headers: Record = {}, -): Promise<{ - text: () => string; - close: () => void; -}> { - const chunks: string[] = []; - const request = http.request(url, { - method: 'GET', - headers, - }); - request.end(); - - const response = await new Promise((resolve, reject) => { - request.once('response', resolve); - request.once('error', reject); - }); - response.setEncoding('utf8'); - response.on('data', (chunk: string) => { - chunks.push(chunk); - }); - - return { - text: () => chunks.join(''), - close: () => { - request.destroy(); - response.destroy(); - }, - }; -} - -async function startWebhookCaptureServer(): Promise<{ - url: string; - received: Array<{ headers: http.IncomingHttpHeaders; body: string }>; - close: () => Promise; -}> { - const received: Array<{ headers: http.IncomingHttpHeaders; body: string }> = []; - const server = http.createServer((req, res) => { - const bodyChunks: Buffer[] = []; - req.on('data', (chunk) => bodyChunks.push(Buffer.from(chunk))); - req.on('end', () => { - received.push({ - headers: req.headers, - body: Buffer.concat(bodyChunks).toString('utf8'), - }); - res.statusCode = 200; - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ ok: true })); - }); - }); - - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolve()); - }); - - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('Failed to start webhook capture server.'); - } - - return { - url: `http://127.0.0.1:${address.port}/webhook`, - received, - close: async () => { - await new Promise((resolve) => { - server.close(() => resolve()); - }); - }, - }; -} - -function setThreadUpdatedTimestamp(workspaceRoot: string, threadPath: string, timestamp: string): void { - const absolutePath = path.join(workspaceRoot, threadPath); - const parsed = matter(fs.readFileSync(absolutePath, 'utf8')); - const nextData = { - ...(parsed.data as Record), - updated: timestamp, - }; - fs.writeFileSync(absolutePath, matter.stringify(parsed.content, nextData), 'utf8'); -} - -async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - throw new Error('Timed out waiting for condition'); -}