Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions packages/daemon/src/lib/space/runtime/space-runtime-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Attach the ceiling before publishing new agent sessions

When ensureLongHorizonAgentSession creates a session, SessionLifecycle.create publishes session.created before this method writes promptProvenance.agentId; the event subscriber therefore classifies the new session as an ad-hoc member and starts attaching a generic, uncapped space-agent-tools server. This explicit capped attachment then races that asynchronous generic attachment, and both merge the same server key, so the generic server commonly finishes last and overwrites the capped one. On a restricted agent's first activation in a permissive space, its approval and session-write calls can consequently run without the new ceiling; persist the provenance before session.created is emitted or otherwise prevent the generic attachment from starting.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated β€” no race, because internalEventBus.publish awaits every subscriber before resolving.

internal-event-bus.ts:185 (publish) ends with await Promise.all(handlers) (line :222) β€” the doc comment states it "awaits every local internal handler." So in ensureLongHorizonAgentSession, the await sessionManager.createSession(...) at space-runtime-service.ts:591 does not return until the session.created subscriber (the generic attachSpaceToolsToMemberSession path) has fully resolved β€” including its internal getSessionAsync/getSpace awaits. Only then do lines :610 (provenance write) and :621 (attachLongTermAgentMcpServers, the capped server) run, and mergeRuntimeMcpServers is last-writer-wins for the space-agent-tools key, so the capped variant is the final writer. The session isn't active for tool calls until ensureLongHorizonAgentSession returns, so there is no window in which the uncapped server is live. No change needed.

[`@${agent.handle}`]
);
return session;
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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`
Expand All @@ -762,6 +775,7 @@ export class SpaceRuntimeService {
agentName: string,
sessionId: string,
agent: SpaceWorkerAgent | null,
agentId: string | null,
agentHandleAliases?: string[]
): void {
const mcpServers: Record<string, McpServerConfig> = {
Expand All @@ -770,6 +784,7 @@ export class SpaceRuntimeService {
agentName,
sessionId,
agent,
agentId,
agentHandleAliases
) as unknown as McpServerConfig,
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -852,6 +868,7 @@ export class SpaceRuntimeService {
},
myAgentName: agentName,
myAgentNameAliases: aliases,
myAgentId: agentId ?? undefined,
mySessionId: sessionId,
auditLogRepo: this.auditLogRepo,
scheduleService: this.config.scheduleService,
Expand Down
142 changes: 135 additions & 7 deletions packages/daemon/src/lib/space/tools/space-agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
QuestionDraftResponse,
SpaceGoalStatus,
SpaceGoalType,
SpaceAgentAutonomyLevel,
SpaceLongHorizonAgent,
SpaceLongHorizonAgentStatus,
SpaceTask,
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -561,6 +571,7 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) {
getSpaceAutonomyLevel,
myAgentName,
myAgentNameAliases,
myAgentId,
mySessionId,
replyRoutingRegistry,
messageResolver,
Expand Down Expand Up @@ -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 };
Comment thread
lsm marked this conversation as resolved.
}

/** 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<void> {
const level = getSpaceAutonomyLevel ? await getSpaceAutonomyLevel(spaceId) : 1;
const { level, spaceLevel, agentLevel } = await resolveEffectiveAutonomy();
if (level < SESSION_WRITE_AUTONOMY_LEVEL) {
Comment thread
lsm marked this conversation as resolved.
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.`
);
}
}
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent restricted agents from commandeering trusted peers

Although this revision caps newly created children, a level-1 caller can still bypass the ceiling when the space already contains an uncapped or level-5 agent: update_agent allows it to replace that agent's instructions, and send_message_to_task can route a message to the agent and activate it, after which the target's higher ceiling permits the requested approval or session write. Restrict delegation and mutation of higher-autonomy agents based on the caller's ceiling; inheriting the ceiling only in the two creation paths does not close this bypass.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated β€” the autonomy-escalation half of this is not reachable, and the instruction-rewriting half is real but pre-existing/out-of-scope for this PR.

Autonomy escalation (not reachable): the update_agent MCP tool schema (space-agent-tools.ts:4448-4477) exposes no autonomy_level field β€” only name/status/description/model/thinking_level/provider/custom_prompt/tools/setting_sources. The ceiling is keyed to agent.autonomyLevel (getCallingAgentAutonomyLevel reads repo.getById(myAgentId).autonomyLevel), and the only writer of that column is the human-facing spaceLongHorizonAgent.update RPC handler (rpc-handlers/space-long-horizon-agent-handlers.ts), not an agent MCP tool. So a level-1 agent cannot raise a peer's ceiling.

Instruction rewriting (real, but out of scope): true that update_agent lets a caller rewrite a peer's instructions with no ceiling check. But (a) that predates this PR β€” update_agent always allowed instruction edits; (b) the per-agent ceiling's stated scope is the agent's direct privileged calls (requireSessionWriteAutonomy/approve_gate/approve_task), not an indirect-influence/capability model; (c) the puppeted peer acts under its own identity/task context, not a forced privileged call on the caller's behalf. Worth a separate follow-up issue (capability model bounding which peers a low-trust agent can mutate), but not a regression introduced or left open here.

model: args.model ?? null,
thinkingLevel: args.thinking_level ?? null,
provider: args.provider ?? null,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2796,10 +2883,32 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) {
});

if (!writerMatches) {
Comment thread
lsm marked this conversation as resolved.
// 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:
Expand Down Expand Up @@ -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);
Expand All @@ -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.`,
});
}

Expand Down
Loading
Loading