fix(security): restrict leaf subagent control scope

This commit is contained in:
Peter Steinberger
2026-03-11 01:12:22 +00:00
parent 3ba6491659
commit ecdbd8aa52
7 changed files with 217 additions and 31 deletions

View File

@@ -7,7 +7,6 @@ import {
sortSubagentRuns,
type SubagentTargetResolution,
} from "../../auto-reply/reply/subagents-utils.js";
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../../config/agent-limits.js";
import { loadConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
@@ -28,7 +27,6 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import { abortEmbeddedPiRun } from "../pi-embedded.js";
import { optionalStringEnum } from "../schema/typebox.js";
import { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
import {
clearSubagentRunSteerRestart,
countPendingDescendantRuns,
@@ -204,36 +202,28 @@ function resolveRequesterKey(params: {
};
}
// Check if this sub-agent can spawn children (orchestrator).
// If so, it should see its own children, not its parent's children.
const callerDepth = getSubagentDepthFromSessionStore(callerSessionKey, { cfg: params.cfg });
const maxSpawnDepth =
params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
if (callerDepth < maxSpawnDepth) {
// Orchestrator sub-agent: use its own session key as requester
// so it sees children it spawned.
return {
requesterSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: true,
};
}
// Leaf sub-agent: walk up to its parent so it can see sibling runs.
const cache = new Map<string, Record<string, SessionEntry>>();
const callerEntry = resolveSessionEntryForKey({
cfg: params.cfg,
key: callerSessionKey,
cache,
}).entry;
const spawnedBy = typeof callerEntry?.spawnedBy === "string" ? callerEntry.spawnedBy.trim() : "";
return {
requesterSessionKey: spawnedBy || callerSessionKey,
// Subagents can only control runs spawned from their own session key.
// Announce routing still uses SubagentRunRecord.requesterSessionKey elsewhere.
requesterSessionKey: callerSessionKey,
callerSessionKey,
callerIsSubagent: true,
};
}
function ensureSubagentControlsOwnDescendants(params: {
requester: ResolvedRequesterKey;
entry: SubagentRunRecord;
}) {
if (!params.requester.callerIsSubagent) {
return undefined;
}
if (params.entry.requesterSessionKey === params.requester.callerSessionKey) {
return undefined;
}
return "Subagents can only control runs spawned from their own session.";
}
async function killSubagentRun(params: {
cfg: ReturnType<typeof loadConfig>;
entry: SubagentRunRecord;
@@ -499,6 +489,20 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
error: resolved.error ?? "Unknown subagent target.",
});
}
const ownershipError = ensureSubagentControlsOwnDescendants({
requester,
entry: resolved.entry,
});
if (ownershipError) {
return jsonResult({
status: "forbidden",
action: "kill",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
error: ownershipError,
});
}
const killCache = new Map<string, Record<string, SessionEntry>>();
const stopResult = await killSubagentRun({
cfg,
@@ -568,6 +572,20 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
error: resolved.error ?? "Unknown subagent target.",
});
}
const ownershipError = ensureSubagentControlsOwnDescendants({
requester,
entry: resolved.entry,
});
if (ownershipError) {
return jsonResult({
status: "forbidden",
action: "steer",
target,
runId: resolved.entry.runId,
sessionKey: resolved.entry.childSessionKey,
error: ownershipError,
});
}
if (resolved.entry.endedAt) {
return jsonResult({
status: "done",