fix(security): enforce explicit ingress owner context

This commit is contained in:
Peter Steinberger
2026-03-02 23:50:30 +00:00
parent ea3b7dfde5
commit b8b8a5f314
17 changed files with 471 additions and 92 deletions

View File

@@ -15,7 +15,7 @@ import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { RuntimeEnv } from "../runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { agentCommand } from "./agent.js";
import { agentCommand, agentCommandFromIngress } from "./agent.js";
import * as agentDeliveryModule from "./agent/delivery.js";
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
@@ -316,6 +316,27 @@ describe("agentCommand", () => {
expect(callArgs?.senderIsOwner).toBe(expected);
});
it("requires explicit senderIsOwner for ingress runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await expect(
// Runtime guard for non-TS callers; TS callsites are statically typed.
agentCommandFromIngress({ message: "hi", to: "+1555" } as never, runtime),
).rejects.toThrow("senderIsOwner must be explicitly set for ingress agent runs.");
});
});
it("honors explicit senderIsOwner for ingress runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommandFromIngress({ message: "hi", to: "+1555", senderIsOwner: false }, runtime);
const ingressCall = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(ingressCall?.senderIsOwner).toBe(false);
});
});
it("resumes when session-id is provided", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");

View File

@@ -77,7 +77,7 @@ import { deliverAgentCommandResult } from "./agent/delivery.js";
import { resolveAgentRunContext } from "./agent/run-context.js";
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
import { resolveSession } from "./agent/session.js";
import type { AgentCommandOpts } from "./agent/types.js";
import type { AgentCommandIngressOpts, AgentCommandOpts } from "./agent/types.js";
type PersistSessionEntryParams = {
sessionStore: Record<string, SessionEntry>;
@@ -160,7 +160,7 @@ function runAgentAttempt(params: {
resolvedThinkLevel: ThinkLevel;
timeoutMs: number;
runId: string;
opts: AgentCommandOpts;
opts: AgentCommandOpts & { senderIsOwner: boolean };
runContext: ReturnType<typeof resolveAgentRunContext>;
spawnedBy: string | undefined;
messageChannel: ReturnType<typeof resolveMessageChannel>;
@@ -172,7 +172,6 @@ function runAgentAttempt(params: {
sessionStore?: Record<string, SessionEntry>;
storePath?: string;
}) {
const senderIsOwner = params.opts.senderIsOwner ?? true;
const effectivePrompt = resolveFallbackRetryPrompt({
body: params.body,
isFallbackRetry: params.isFallbackRetry,
@@ -292,7 +291,7 @@ function runAgentAttempt(params: {
currentThreadTs: params.runContext.currentThreadTs,
replyToMode: params.runContext.replyToMode,
hasRepliedRef: params.runContext.hasRepliedRef,
senderIsOwner,
senderIsOwner: params.opts.senderIsOwner,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
config: params.cfg,
@@ -318,8 +317,8 @@ function runAgentAttempt(params: {
});
}
export async function agentCommand(
opts: AgentCommandOpts,
async function agentCommandInternal(
opts: AgentCommandOpts & { senderIsOwner: boolean },
runtime: RuntimeEnv = defaultRuntime,
deps: CliDeps = createDefaultDeps(),
) {
@@ -922,3 +921,36 @@ export async function agentCommand(
clearAgentRunContext(runId);
}
}
export async function agentCommand(
opts: AgentCommandOpts,
runtime: RuntimeEnv = defaultRuntime,
deps: CliDeps = createDefaultDeps(),
) {
return await agentCommandInternal(
{
...opts,
senderIsOwner: opts.senderIsOwner ?? true,
},
runtime,
deps,
);
}
export async function agentCommandFromIngress(
opts: AgentCommandIngressOpts,
runtime: RuntimeEnv = defaultRuntime,
deps: CliDeps = createDefaultDeps(),
) {
if (typeof opts.senderIsOwner !== "boolean") {
throw new Error("senderIsOwner must be explicitly set for ingress agent runs.");
}
return await agentCommandInternal(
{
...opts,
senderIsOwner: opts.senderIsOwner,
},
runtime,
deps,
);
}

View File

@@ -81,3 +81,8 @@ export type AgentCommandOpts = {
/** Per-call stream param overrides (best-effort). */
streamParams?: AgentStreamParams;
};
export type AgentCommandIngressOpts = Omit<AgentCommandOpts, "senderIsOwner"> & {
/** Ingress callsites must always pass explicit owner authorization state. */
senderIsOwner: boolean;
};