diff --git a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts index 978079583..503bcad3b 100644 --- a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts +++ b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts @@ -618,9 +618,15 @@ export class SpaceRuntimeService { }, }, }); - this.attachLongTermAgentMcpServers(session, space, agent.displayName, sessionId, null, [ - `@${agent.handle}`, - ]); + this.attachLongTermAgentMcpServers( + session, + space, + agent.displayName, + sessionId, + null, + agentId, + [`@${agent.handle}`] + ); return session; } @@ -706,7 +712,7 @@ export class SpaceRuntimeService { await session.resetQuery({ restartQuery: true }); } if (created || this.missingLongTermAgentMcpServers(session)) { - this.attachLongTermAgentMcpServers(session, space, agent.name, sessionId, agent); + this.attachLongTermAgentMcpServers(session, space, agent.name, sessionId, agent, agentId); } return session; } @@ -740,7 +746,14 @@ export class SpaceRuntimeService { } const agentName = session.metadata.promptProvenance?.agentName ?? persistedAgent?.name ?? 'Space Agent'; - this.attachLongTermAgentMcpServers(agentSession, space, agentName, session.id, persistedAgent); + this.attachLongTermAgentMcpServers( + agentSession, + space, + agentName, + session.id, + persistedAgent, + agentId + ); agentSession.onMissingMemberSpaceMcpServers = async (_sessionId, missing) => { log.warn( `Long-term Space agent session ${session.id} missing MCP servers [${missing.join(', ')}]; re-attaching space-agent-tools before query start` @@ -762,6 +775,7 @@ export class SpaceRuntimeService { agentName: string, sessionId: string, agent: SpaceWorkerAgent | null, + agentId: string | null, agentHandleAliases?: string[] ): void { const mcpServers: Record = { @@ -770,6 +784,7 @@ export class SpaceRuntimeService { agentName, sessionId, agent, + agentId, agentHandleAliases ) as unknown as McpServerConfig, }; @@ -814,6 +829,7 @@ export class SpaceRuntimeService { agentName: string, sessionId: string, agent: SpaceWorkerAgent | null, + agentId: string | null, agentHandleAliases?: string[] ) { const agents = this.config.spaceAgentManager.listBySpaceId(space.id); @@ -852,6 +868,7 @@ export class SpaceRuntimeService { }, myAgentName: agentName, myAgentNameAliases: aliases, + myAgentId: agentId ?? undefined, mySessionId: sessionId, auditLogRepo: this.auditLogRepo, scheduleService: this.config.scheduleService, diff --git a/packages/daemon/src/lib/space/tools/space-agent-tools.ts b/packages/daemon/src/lib/space/tools/space-agent-tools.ts index 6c3737f28..569089675 100644 --- a/packages/daemon/src/lib/space/tools/space-agent-tools.ts +++ b/packages/daemon/src/lib/space/tools/space-agent-tools.ts @@ -36,6 +36,7 @@ import type { QuestionDraftResponse, SpaceGoalStatus, SpaceGoalType, + SpaceAgentAutonomyLevel, SpaceLongHorizonAgent, SpaceLongHorizonAgentStatus, SpaceTask, @@ -471,6 +472,15 @@ export interface SpaceAgentToolsConfig { * writer authorization. */ myAgentNameAliases?: string[]; + /** + * The calling long-term Space agent's id (from `promptProvenance.agentId`). + * When set and the id resolves to a `SpaceLongHorizonAgent` in this space with + * a non-null `autonomyLevel`, that level acts as a ceiling on the agent's + * effective autonomy for its own `space-agent-tools` calls + * (`min(space.autonomyLevel, agent.autonomyLevel)`). Only long-term agent + * sessions pass this — coordinators and ad-hoc members are uncapped. + */ + myAgentId?: string; /** * Session ID of the calling agent. Used to stamp `createdBySession` on tasks * created via `create_standalone_task`. @@ -561,6 +571,7 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { getSpaceAutonomyLevel, myAgentName, myAgentNameAliases, + myAgentId, mySessionId, replyRoutingRegistry, messageResolver, @@ -752,11 +763,75 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { }); } + /** + * Resolves the calling long-term agent's autonomy ceiling, if any. + * + * - No calling agent (`myAgentId` unset) → null (uncapped). + * - Resolves to a `SpaceLongHorizonAgent` in this space → its `autonomyLevel` + * (null when the operator left it unset → uncapped). + * - Resolves to a long-horizon agent in another space → null (unreachable in + * production since sessions are space-scoped; defer to the space level). + * - Resolves to a worker agent (worker-agent long-term session, which has no + * autonomy concept) → null (uncapped). + * - `myAgentId` is set but resolves to no record in either repo (a long-horizon + * agent deleted while its session is still live) → fail closed at level 1, so + * a vanished low-trust agent cannot silently become uncapped. + */ + function getCallingAgentAutonomyLevel(): SpaceAgentAutonomyLevel | null { + if (!myAgentId) return null; + const repo = config.longHorizonAgentRepo; + if (!repo) return null; + const agent = repo.getById(myAgentId); + if (agent) { + if (agent.spaceId !== spaceId) return null; + return agent.autonomyLevel ?? null; + } + // No long-horizon record. A worker-agent long-term session legitimately has + // none → uncapped; anything else (e.g. a deleted long-horizon agent) fails closed. + const workerAgent = spaceAgentManager.getById(myAgentId); + if (workerAgent && workerAgent.spaceId === spaceId) return null; + return 1; + } + + /** + * Effective autonomy = `min(spaceLevel, agentCeiling)`. The agent ceiling only + * binds for long-term agent sessions (where `myAgentId` resolves to a + * `SpaceLongHorizonAgent` with a level). Returns the components so callers can + * produce precise error messages and audit entries. + */ + async function resolveEffectiveAutonomy(): Promise<{ + level: number; + spaceLevel: number; + agentLevel: number | null; + }> { + const spaceLevel = getSpaceAutonomyLevel ? await getSpaceAutonomyLevel(spaceId) : 1; + const agentLevel = getCallingAgentAutonomyLevel(); + const level = agentLevel == null ? spaceLevel : Math.min(spaceLevel, agentLevel); + return { level, spaceLevel, agentLevel }; + } + + /** True when the agent ceiling — not the space level — is the binding constraint. */ + function isAgentCeilingBinding(spaceLevel: number, agentLevel: number | null): boolean { + return agentLevel != null && agentLevel < spaceLevel; + } + async function requireSessionWriteAutonomy(toolName: string): Promise { - const level = getSpaceAutonomyLevel ? await getSpaceAutonomyLevel(spaceId) : 1; + const { level, spaceLevel, agentLevel } = await resolveEffectiveAutonomy(); if (level < SESSION_WRITE_AUTONOMY_LEVEL) { + if (isAgentCeilingBinding(spaceLevel, agentLevel)) { + logAudit(toolName, { + blocked: true, + reason: 'agent_autonomy_ceiling', + agentLevel, + spaceLevel, + required: SESSION_WRITE_AUTONOMY_LEVEL, + }); + throw new Error( + `${toolName} not permitted: agent autonomy ceiling ${agentLevel} (space ${spaceLevel}) < required level ${SESSION_WRITE_AUTONOMY_LEVEL}. Request human approval.` + ); + } throw new Error( - `${toolName} not permitted: space autonomy level ${level} < required level ${SESSION_WRITE_AUTONOMY_LEVEL}. Request human approval.` + `${toolName} not permitted: space autonomy level ${spaceLevel} < required level ${SESSION_WRITE_AUTONOMY_LEVEL}. Request human approval.` ); } } @@ -1066,7 +1141,12 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { if ( mySessionId && args.session_id !== mySessionId && - outboundSenderLevel !== 'space-agent' + // The coordinator exemption applies only to the real space coordinator + // (the space:chat session, which carries no calling agent id). A + // long-term agent that happens to be named "coordinator"/"space-agent" + // must still pass the autonomy ceiling — otherwise the ceiling is + // bypassable by renaming via update_agent. + (outboundSenderLevel !== 'space-agent' || myAgentId) ) { await requireSessionWriteAutonomy('send_session_message'); } @@ -1250,6 +1330,10 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { handle: uniqueLongHorizonAgentHandle(args.name), displayName: args.name, instructions: args.custom_prompt ?? args.description ?? '', + // A restricted caller must not manufacture an uncapped child to bypass + // its own ceiling; the child inherits the caller's ceiling (null when + // the caller itself is uncapped). + autonomyLevel: getCallingAgentAutonomyLevel(), model: args.model ?? null, thinkingLevel: args.thinking_level ?? null, provider: args.provider ?? null, @@ -1296,6 +1380,9 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { displayName: name, templateKey: template.name, instructions: template.customPrompt ?? template.description, + // Inherit the caller's ceiling (see create_agent) — a restricted caller + // cannot spawn a more trusted agent from a preset template either. + autonomyLevel: getCallingAgentAutonomyLevel(), model: args.model ?? null, provider: args.provider ?? null, thinkingLevel: args.thinking_level ?? template.thinkingLevel ?? null, @@ -2796,10 +2883,32 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { }); if (!writerMatches) { - // Autonomy path: this agent is not in the writers list + // Autonomy path: this agent is not in the writers list. The effective + // level is `min(spaceLevel, agentCeiling)` — a low-trust long-term + // agent is blocked even when the space is permissive. const effectiveRequiredLevel = gateDef?.requiredLevel ?? 5; + const agentLevel = getCallingAgentAutonomyLevel(); const spaceLevel = await getSpaceAutonomyLevel(spaceId); - if (spaceLevel < effectiveRequiredLevel) { + const effectiveLevel = agentLevel == null ? spaceLevel : Math.min(spaceLevel, agentLevel); + if (effectiveLevel < effectiveRequiredLevel) { + if (isAgentCeilingBinding(spaceLevel, agentLevel)) { + logAudit('approve_gate', { + blocked: true, + reason: 'agent_autonomy_ceiling', + run_id: args.run_id, + gate_id: args.gate_id, + agentLevel, + spaceLevel, + required: effectiveRequiredLevel, + }); + return jsonResult({ + success: false, + error: + `Agent approval blocked: gate "${args.gate_id}" requires autonomy level ` + + `${effectiveRequiredLevel} but agent autonomy ceiling is ${agentLevel} (space ${spaceLevel}). ` + + `Increase the agent's autonomy level or request human approval.`, + }); + } return jsonResult({ success: false, error: @@ -2950,8 +3059,10 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { } const space = config.spaceManager ? await config.spaceManager.getSpace(spaceId) : null; - const currentLevel = + const spaceLevel = space?.autonomyLevel ?? (getSpaceAutonomyLevel ? await getSpaceAutonomyLevel(spaceId) : 1); + const agentLevel = getCallingAgentAutonomyLevel(); + const currentLevel = agentLevel == null ? spaceLevel : Math.min(spaceLevel, agentLevel); let completionAutonomyLevel = 5; if (task.workflowRunId) { const run = workflowRunRepo.getRun(task.workflowRunId); @@ -2964,9 +3075,26 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { } if (currentLevel < completionAutonomyLevel) { + if (isAgentCeilingBinding(spaceLevel, agentLevel)) { + logAudit( + 'approve_task', + { + blocked: true, + reason: 'agent_autonomy_ceiling', + agentLevel, + spaceLevel, + required: completionAutonomyLevel, + }, + args.task_id + ); + return jsonResult({ + success: false, + error: `approve_task not permitted: agent autonomy ceiling ${agentLevel} (space ${spaceLevel}) < workflow completionAutonomyLevel ${completionAutonomyLevel}. Use submit_for_approval to request human review.`, + }); + } return jsonResult({ success: false, - error: `approve_task not permitted: space autonomy level ${currentLevel} < workflow completionAutonomyLevel ${completionAutonomyLevel}. Use submit_for_approval to request human review.`, + error: `approve_task not permitted: space autonomy level ${spaceLevel} < workflow completionAutonomyLevel ${completionAutonomyLevel}. Use submit_for_approval to request human review.`, }); } diff --git a/packages/daemon/tests/unit/5-space/runtime/space-agent-tools.test.ts b/packages/daemon/tests/unit/5-space/runtime/space-agent-tools.test.ts index ec615b9bf..a4d4d7fef 100644 --- a/packages/daemon/tests/unit/5-space/runtime/space-agent-tools.test.ts +++ b/packages/daemon/tests/unit/5-space/runtime/space-agent-tools.test.ts @@ -7248,3 +7248,505 @@ describe('space-agent-tools: get_external_event', () => { expect(getRegisteredToolNames(withStore)).toContain('get_external_event'); }); }); + +// --------------------------------------------------------------------------- +// Agent-level autonomy ceiling — min(space.autonomyLevel, agent.autonomyLevel) +// +// A long-term Space agent's `autonomyLevel` caps its effective autonomy for its +// own space-agent-tools calls. The ceiling only binds for `long_term_agent` +// role sessions (here: `myAgentId` resolves to a SpaceLongHorizonAgent in this +// space with a non-null level). Coordinators and ad-hoc members are uncapped. +// --------------------------------------------------------------------------- + +describe('createSpaceAgentToolHandlers — agent-level autonomy ceiling', () => { + let ctx: TestCtx; + beforeEach(() => { + ctx = makeCtx(); + }); + afterEach(() => { + ctx.db.close(); + }); + + function seedLongHorizonAgent(level: 1 | 2 | 3 | 4 | 5 | null): string { + return ctx.longHorizonAgentRepo.create({ + spaceId: ctx.spaceId, + handle: `lh-l${level ?? 'null'}-${Math.random().toString(36).slice(2, 6)}`, + displayName: `LH Agent L${level ?? '∅'}`, + autonomyLevel: level, + }).id; + } + + function seedTargetSession(id: string): void { + ctx.db + .prepare( + `INSERT INTO sessions ( + id, title, workspace_path, created_at, last_active_at, status, config, metadata, + is_worktree, git_branch, processing_state, type, session_context + ) VALUES (?, ?, ?, ?, ?, 'active', '{}', '{}', 1, 'feature/x', ?, 'worker', ?)` + ) + .run( + id, + `Session ${id}`, + '/tmp/session-workspace', + new Date(0).toISOString(), + new Date().toISOString(), + JSON.stringify({ status: 'idle' }), + JSON.stringify({ spaceId: ctx.spaceId }) + ); + } + + function createReviewTaskWithCompletionLevel(requiredLevel: 1 | 2 | 3 | 4 | 5): string { + const nodeId = `node-${Math.random().toString(36).slice(2)}`; + const workflow = ctx.workflowManager.createWorkflow({ + spaceId: ctx.spaceId, + name: `Completion ${requiredLevel} WF`, + description: '', + nodes: [{ id: nodeId, name: 'Work', agentId: ctx.agentId }], + transitions: [], + startNodeId: nodeId, + endNodeId: nodeId, + rules: [], + completionAutonomyLevel: requiredLevel, + }); + const run = ctx.workflowRunRepo.createRun({ + spaceId: ctx.spaceId, + workflowId: workflow.id, + title: 'completion run', + description: '', + }); + const task = ctx.taskRepo.createTask({ + spaceId: ctx.spaceId, + title: 'Review task', + description: '', + status: 'review', + workflowRunId: run.id, + }); + return task.id; + } + + function createGatedRun(requiredLevel: 1 | 2 | 3 | 4 | 5, writers: string[] = []): string { + const nodeId = `node-${Math.random().toString(36).slice(2)}`; + const workflow = ctx.workflowManager.createWorkflow({ + spaceId: ctx.spaceId, + name: `Gated ${requiredLevel} WF`, + description: '', + nodes: [{ id: nodeId, name: 'Work', agentId: ctx.agentId }], + transitions: [], + startNodeId: nodeId, + endNodeId: nodeId, + rules: [], + gates: [ + { + id: 'gate-1', + fields: [ + { + name: 'approved', + type: 'boolean', + writers, + check: { op: '==', value: true }, + }, + ], + requiredLevel, + resetOnCycle: false, + }, + ], + }); + const run = ctx.workflowRunRepo.createRun({ + spaceId: ctx.spaceId, + workflowId: workflow.id, + title: 'gated run', + description: '', + }); + // createRun defaults to 'pending', which approve_gate rejects before the + // autonomy check — move it to 'in_progress' so the gate is reachable. + ctx.workflowRunRepo.transitionStatus(run.id, 'in_progress'); + return run.id; + } + + test('level-1 long-horizon agent is blocked from session-write at space level 5', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + seedTargetSession('ceiling-target-blocked'); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + const parsed = parseResult( + await handlers.send_session_message({ + session_id: 'ceiling-target-blocked', + message: 'proceed', + }) + ); + + expect(parsed.success).toBe(false); + expect(String(parsed.error)).toContain('agent autonomy ceiling 1'); + expect(String(parsed.error)).toContain('space 5'); + }); + + test('level-5 long-horizon agent is unaffected by the ceiling at space level 5', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(5); + seedTargetSession('ceiling-target-ok'); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + const parsed = parseResult( + await handlers.send_session_message({ + session_id: 'ceiling-target-ok', + message: 'proceed', + }) + ); + + expect(parsed.success).toBe(true); + }); + + test('ceiling is min(space, agent): level-3 agent at space 5 still cannot session-write (needs 4)', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(3); + seedTargetSession('ceiling-target-min'); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + const parsed = parseResult( + await handlers.send_session_message({ + session_id: 'ceiling-target-min', + message: 'proceed', + }) + ); + + expect(parsed.success).toBe(false); + expect(String(parsed.error)).toContain('agent autonomy ceiling 3'); + }); + + test('long-horizon agent with no autonomyLevel is uncapped — space level governs', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 3 }); + const agentId = seedLongHorizonAgent(null); + seedTargetSession('ceiling-target-uncapped'); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 3, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + const parsed = parseResult( + await handlers.send_session_message({ + session_id: 'ceiling-target-uncapped', + message: 'proceed', + }) + ); + + expect(parsed.success).toBe(false); + const error = String(parsed.error); + expect(error).toContain('space autonomy level 3'); + expect(error).not.toContain('agent autonomy ceiling'); + }); + + test('approve_task: level-1 agent is blocked at space level 5 (completion level 5)', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + const taskId = createReviewTaskWithCompletionLevel(5); + const handlers = makeHandlers(ctx, { myAgentId: agentId, mySessionId: 'caller-session' }); + + const parsed = parseResult(await handlers.approve_task({ task_id: taskId })); + expect(parsed.success).toBe(false); + expect(String(parsed.error)).toContain('agent autonomy ceiling 1'); + expect(ctx.taskRepo.getTask(taskId)?.status).toBe('review'); + }); + + test('approve_task: min() boundary — level-3 agent at space 5 with completion 3 succeeds', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(3); + const taskId = createReviewTaskWithCompletionLevel(3); + const handlers = makeHandlers(ctx, { myAgentId: agentId, mySessionId: 'caller-session' }); + + const parsed = parseResult(await handlers.approve_task({ task_id: taskId, reason: 'ok' })); + expect(parsed.success).toBe(true); + expect(ctx.taskRepo.getTask(taskId)?.status).toBe('done'); + }); + + test('approve_gate: level-1 agent is blocked via the autonomy path at space level 5', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + const runId = createGatedRun(5); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + gateDataRepo: new GateDataRepository(ctx.db), + }); + + const parsed = parseResult( + await handlers.approve_gate({ run_id: runId, gate_id: 'gate-1', approved: true }) + ); + + expect(parsed.success).toBe(false); + const error = String(parsed.error); + expect(error).toContain('agent autonomy ceiling is 1'); + expect(error).toContain('space 5'); + }); + + test('approve_gate: level-5 agent passes the autonomy path at space level 5', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(5); + const runId = createGatedRun(5); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + gateDataRepo: new GateDataRepository(ctx.db), + }); + + const parsed = parseResult( + await handlers.approve_gate({ run_id: runId, gate_id: 'gate-1', approved: true }) + ); + + expect(parsed.success).toBe(true); + }); + + test('ceiling-blocked session-write denial is recorded in the audit log', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + const auditLogRepo = new McpAuditLogRepository(ctx.db); + seedTargetSession('ceiling-target-audit'); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + auditLogRepo, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + await handlers.send_session_message({ + session_id: 'ceiling-target-audit', + message: 'proceed', + }); + + const denial = auditLogRepo + .listBySpace(ctx.spaceId) + .find((entry) => entry.toolName === 'send_session_message'); + expect(denial).toBeDefined(); + const summary = JSON.parse(denial?.paramsSummary ?? '{}') as Record; + expect(summary.blocked).toBe(true); + expect(summary.reason).toBe('agent_autonomy_ceiling'); + expect(summary.agentLevel).toBe(1); + }); + + // ------------------------------------------------------------------------- + // Writers-allowlist override: an explicit gate writer delegation bypasses + // the agent ceiling (the ceiling only binds the autonomy path). + // ------------------------------------------------------------------------- + + test('approve_gate: writers-allowlist overrides the agent ceiling for a level-1 agent', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + // Gate explicitly delegates approval to this agent by name → writerMatches + // is true, so the autonomy path (and thus the ceiling) is skipped entirely. + const runId = createGatedRun(5, ['security-auditor']); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + myAgentName: 'security-auditor', + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + gateDataRepo: new GateDataRepository(ctx.db), + }); + + const parsed = parseResult( + await handlers.approve_gate({ run_id: runId, gate_id: 'gate-1', approved: true }) + ); + + expect(parsed.success).toBe(true); + expect(parsed.gateData?.approved).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Cross-space guard: myAgentId pointing at an LH agent in another space is + // treated as "not this space's agent" → uncapped, space level governs. + // ------------------------------------------------------------------------- + + test('myAgentId resolving to an agent in another space is uncapped', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 3 }); + // Agent lives in a different space. + const otherSpaceId = 'space-ceiling-other'; + seedSpaceRow(ctx.db, otherSpaceId, '/tmp/other'); + const otherAgentId = ctx.longHorizonAgentRepo.create({ + spaceId: otherSpaceId, + handle: `lh-other-${Math.random().toString(36).slice(2, 6)}`, + displayName: 'Other-space Agent', + autonomyLevel: 1, + }).id; + seedTargetSession('ceiling-target-cross-space'); + const handlers = makeHandlers(ctx, { + myAgentId: otherAgentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 3, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + const parsed = parseResult( + await handlers.send_session_message({ + session_id: 'ceiling-target-cross-space', + message: 'proceed', + }) + ); + + // Space level 3 < 4 blocks the call, but via the SPACE constraint — the + // foreign agent contributes no ceiling. + expect(parsed.success).toBe(false); + const error = String(parsed.error); + expect(error).toContain('space autonomy level 3'); + expect(error).not.toContain('agent autonomy ceiling'); + }); + + // ------------------------------------------------------------------------- + // Boundary: exactly agentLevel 4 meets the session-write requirement (4). + // ------------------------------------------------------------------------- + + test('session-write boundary: level-4 agent at space 5 is permitted (effective 4 >= 4)', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(4); + seedTargetSession('ceiling-target-boundary'); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + const parsed = parseResult( + await handlers.send_session_message({ + session_id: 'ceiling-target-boundary', + message: 'proceed', + }) + ); + + expect(parsed.success).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Audit: ceiling denial is recorded for an approve path, not just + // send_session_message. + // ------------------------------------------------------------------------- + + test('ceiling-blocked approve_task denial is recorded in the audit log', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + const taskId = createReviewTaskWithCompletionLevel(5); + const auditLogRepo = new McpAuditLogRepository(ctx.db); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + auditLogRepo, + }); + + await handlers.approve_task({ task_id: taskId }); + + const denial = auditLogRepo + .listByTask(taskId) + .find((entry) => entry.toolName === 'approve_task'); + expect(denial).toBeDefined(); + expect(denial?.taskId).toBe(taskId); + const summary = JSON.parse(denial?.paramsSummary ?? '{}') as Record; + expect(summary.blocked).toBe(true); + expect(summary.reason).toBe('agent_autonomy_ceiling'); + expect(summary.agentLevel).toBe(1); + }); + + // ------------------------------------------------------------------------- + // Bypass hardening: the ceiling must not be evadable by renaming the agent + // to a coordinator-like name. The coordinator exemption only applies to the + // real space coordinator (no calling agent id). + // ------------------------------------------------------------------------- + + test('send_session_message: a level-1 agent named "space-agent" is still ceiling-gated', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + seedTargetSession('ceiling-target-coordinator-name'); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + myAgentName: 'space-agent', + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + const parsed = parseResult( + await handlers.send_session_message({ + session_id: 'ceiling-target-coordinator-name', + message: 'proceed', + }) + ); + + expect(parsed.success).toBe(false); + expect(String(parsed.error)).toContain('agent autonomy ceiling 1'); + }); + + // ------------------------------------------------------------------------- + // Fail closed: a long-horizon agent whose record was deleted while its + // session is still live must not silently become uncapped. + // ------------------------------------------------------------------------- + + test('a deleted long-horizon agent record fails closed at level 1', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + // Simulate the agent being deleted while the session stays live. + ctx.db.prepare('DELETE FROM space_long_horizon_agents WHERE id = ?').run(agentId); + seedTargetSession('ceiling-target-deleted'); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + getRuntimeSession: () => ({ startQueryAndEnqueue: async () => {} }) as never, + }); + + const parsed = parseResult( + await handlers.send_session_message({ + session_id: 'ceiling-target-deleted', + message: 'proceed', + }) + ); + + expect(parsed.success).toBe(false); + expect(String(parsed.error)).toContain('agent autonomy ceiling 1'); + }); + + // ------------------------------------------------------------------------- + // Child ceiling inheritance: a restricted caller cannot spawn an uncapped + // child to bypass its own ceiling. + // ------------------------------------------------------------------------- + + test('create_agent: child inherits the level-1 caller ceiling (cannot bypass)', async () => { + await ctx.spaceManager.updateSpace(ctx.spaceId, { autonomyLevel: 5 }); + const agentId = seedLongHorizonAgent(1); + const handlers = makeHandlers(ctx, { + myAgentId: agentId, + mySessionId: 'caller-session', + getSpaceAutonomyLevel: async () => 5, + }); + + const parsed = parseResult(await handlers.create_agent({ name: 'Child Agent' })); + + expect(parsed.success).toBe(true); + expect((parsed.agent as { autonomyLevel: number | null }).autonomyLevel).toBe(1); + }); + + test('create_agent: an uncapped caller spawns an uncapped (null) child', async () => { + const handlers = makeHandlers(ctx, { mySessionId: 'caller-session' }); + + const parsed = parseResult(await handlers.create_agent({ name: 'Free Child' })); + + expect(parsed.success).toBe(true); + expect((parsed.agent as { autonomyLevel: number | null }).autonomyLevel).toBeNull(); + }); +});