Skip to content

Commit bd824d8

Browse files
committed
feat(runtime): wire installed plugin authorization
1 parent 40807cc commit bd824d8

3 files changed

Lines changed: 247 additions & 30 deletions

File tree

‎e2e/reactor-approval-suspend.test.ts‎

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { describe, expect, test } from "bun:test";
2+
import { mkdtemp, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25

6+
import type { AuthorizeFn } from "@intx/agent";
37
import type { ToolCall } from "@intx/types/runtime";
48

59
import { createPermissionGate } from "../src/permission/gate.js";
610
import { createReactorAuthorize } from "../src/permission/reactor-authorize.js";
11+
import type { PermissionRequest } from "../src/permission/types.js";
12+
import type { PluginModule } from "../src/plugins/loader.js";
13+
import {
14+
collectToolPlugins,
15+
resolveToolPlugins,
16+
} from "../src/plugins/tool-plugins.js";
717
import {
818
createApprovalResume,
919
resolveParkedCallIdFromStore,
@@ -12,6 +22,7 @@ import {
1222
import {
1323
closeIntegrationSession,
1424
openIntegrationSession,
25+
runUntilDone,
1526
runUntilSuspended,
1627
toolDoneEvents,
1728
} from "./integration-harness.js";
@@ -65,6 +76,208 @@ async function waitForAsk(
6576
}
6677

6778
describe("integration — reactor approval suspend/resume", () => {
79+
test.serial(
80+
"installed tool-plugin authorization asks before read dispatch and denies without dispatch",
81+
async () => {
82+
const authorizationCalls: {
83+
resource: string;
84+
action: string;
85+
context: unknown;
86+
}[] = [];
87+
const pluginAuthorize: AuthorizeFn = async (
88+
resource,
89+
action,
90+
context,
91+
) => {
92+
authorizationCalls.push({ resource, action, context });
93+
const path =
94+
typeof context === "object" &&
95+
context !== null &&
96+
"arguments" in context &&
97+
typeof context.arguments === "object" &&
98+
context.arguments !== null &&
99+
"path" in context.arguments &&
100+
typeof context.arguments.path === "string"
101+
? context.arguments.path
102+
: "";
103+
if (path.endsWith("deny.txt")) {
104+
return {
105+
effect: "deny",
106+
reason: "installed guard blocked deny.txt",
107+
matchingGrants: [],
108+
resolvedBy: null,
109+
};
110+
}
111+
return {
112+
effect: "ask",
113+
matchingGrants: [],
114+
resolvedBy: null,
115+
};
116+
};
117+
const installedGuard: PluginModule = {
118+
manifest: {
119+
id: "installed-guard",
120+
name: "Installed guard",
121+
kind: "tool",
122+
},
123+
createToolPlugin: () => ({ authorize: pluginAuthorize }),
124+
};
125+
const resolved = await resolveToolPlugins({
126+
candidates: collectToolPlugins([installedGuard]),
127+
pluginConfig: {
128+
"installed-guard": { enabled: true, consented: true },
129+
},
130+
});
131+
const authorize = defined(resolved.authorize, "resolved authorizer");
132+
133+
const approvalRequests: PermissionRequest[] = [];
134+
let approve: (() => void) | undefined;
135+
const askCwd = await mkdtemp(join(tmpdir(), "corbits-plugin-ask-"));
136+
const askGate = createPermissionGate({
137+
approvals: [],
138+
cwd: askCwd,
139+
interactive: true,
140+
skipPermissions: false,
141+
reactorGated: true,
142+
pluginAuthorize: authorize,
143+
requestApproval: (request) => {
144+
approvalRequests.push(request);
145+
return new Promise((resolve) => {
146+
approve = () => resolve({ allow: true });
147+
});
148+
},
149+
});
150+
const askSession = await openIntegrationSession({
151+
permissionGate: askGate,
152+
authorize: createReactorAuthorize(askGate),
153+
cwd: askCwd,
154+
});
155+
try {
156+
const askPath = join(askSession.cwd, "ask.txt");
157+
await writeFile(askPath, "plugin ask content");
158+
askSession.harness.scenario.replyOnce("anthropic", {
159+
toolCalls: [{ name: "read_file", args: { path: askPath } }],
160+
});
161+
askSession.harness.scenario.replyOnce("anthropic", {
162+
text: "Read after approval.",
163+
});
164+
165+
const turn = await runUntilSuspended(
166+
askSession,
167+
"Read ask.txt after authorization.",
168+
);
169+
expect(turn.result.type).toBe("suspended");
170+
if (turn.result.type !== "suspended") return;
171+
expect(turn.result.approvalSnapshot?.approvalMode).toBe("allow-once");
172+
expect(
173+
turn.events.filter(
174+
(event) =>
175+
event.type === "tool.start" || event.type === "tool.done",
176+
),
177+
).toHaveLength(0);
178+
expect(approvalRequests).toHaveLength(0);
179+
180+
const resume = createApprovalResume({
181+
getAgent: () => askSession.agent,
182+
resolveParkedCallId: (correlationId) =>
183+
resolveParkedCallIdFromStore(askSession.storage, correlationId),
184+
gate: askGate,
185+
});
186+
const handling = resume.handle(turn.result);
187+
for (let i = 0; i < 100 && approvalRequests.length === 0; i++) {
188+
await new Promise((resolve) => setTimeout(resolve, 5));
189+
}
190+
expect(approvalRequests).toHaveLength(1);
191+
expect(defined(approvalRequests[0]).scopes).toEqual([]);
192+
defined(approve, "approval resolver")();
193+
expect(await handling).toBe(true);
194+
expect(await turn.reply()).toBe("Read after approval.");
195+
196+
expect(approvalRequests).toHaveLength(1);
197+
expect(
198+
turn.events.filter(
199+
(event) =>
200+
event.type === "tool.start" &&
201+
event.data.call.name === "read_file",
202+
),
203+
).toHaveLength(1);
204+
const askDone = toolDoneEvents(turn.events);
205+
expect(askDone).toHaveLength(1);
206+
expect(defined(askDone[0]).data.result.content).toContain(
207+
"plugin ask content",
208+
);
209+
expect(authorizationCalls).toHaveLength(2);
210+
for (const call of authorizationCalls) {
211+
expect(call).toEqual({
212+
resource: "tool:read_file",
213+
action: "invoke",
214+
context: expect.objectContaining({
215+
name: "read_file",
216+
arguments: { path: askPath },
217+
}),
218+
});
219+
}
220+
} finally {
221+
await closeIntegrationSession(askSession);
222+
}
223+
224+
const denyRequests: unknown[] = [];
225+
const denyCwd = await mkdtemp(join(tmpdir(), "corbits-plugin-deny-"));
226+
const denyGate = createPermissionGate({
227+
approvals: [],
228+
cwd: denyCwd,
229+
interactive: true,
230+
skipPermissions: false,
231+
reactorGated: true,
232+
pluginAuthorize: authorize,
233+
requestApproval: async (request) => {
234+
denyRequests.push(request);
235+
return { allow: true };
236+
},
237+
});
238+
const denySession = await openIntegrationSession({
239+
permissionGate: denyGate,
240+
authorize: createReactorAuthorize(denyGate),
241+
cwd: denyCwd,
242+
});
243+
try {
244+
const denyPath = join(denySession.cwd, "deny.txt");
245+
await writeFile(denyPath, "content must not dispatch");
246+
denySession.harness.scenario.replyOnce("anthropic", {
247+
toolCalls: [{ name: "read_file", args: { path: denyPath } }],
248+
});
249+
denySession.harness.scenario.replyOnce("anthropic", {
250+
text: "Read was blocked.",
251+
});
252+
253+
const turn = await runUntilDone(denySession, "Try to read deny.txt.");
254+
expect(
255+
turn.events.filter((event) => event.type === "tool.start"),
256+
).toHaveLength(0);
257+
const denyDone = toolDoneEvents(turn.events);
258+
expect(denyDone).toHaveLength(1);
259+
const denied = defined(denyDone[0]).data.result;
260+
expect(denied.isError).toBe(true);
261+
expect(denied.content).toContain(
262+
"Denied by policy: installed guard blocked deny.txt",
263+
);
264+
expect(denied.content).not.toContain("content must not dispatch");
265+
expect(denyRequests).toHaveLength(0);
266+
expect(authorizationCalls).toHaveLength(3);
267+
expect(defined(authorizationCalls[2])).toEqual({
268+
resource: "tool:read_file",
269+
action: "invoke",
270+
context: expect.objectContaining({
271+
name: "read_file",
272+
arguments: { path: denyPath },
273+
}),
274+
});
275+
} finally {
276+
await closeIntegrationSession(denySession);
277+
}
278+
},
279+
);
280+
68281
test.serial(
69282
"ask-tier call suspends with a correlationId, resumes on approval, and re-dispatches without re-asking",
70283
async () => {

‎src/exec/runner.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -630,10 +630,11 @@ export async function runExec(config: Config): Promise<ExecResult> {
630630
const pluginConfig = config.settings?.plugins ?? {};
631631

632632
const toolPluginCandidates = collectToolPlugins(executablePlugins());
633-
const { plugins: extraToolPlugins } = await resolveToolPlugins({
634-
candidates: toolPluginCandidates,
635-
pluginConfig,
636-
});
633+
const { plugins: extraToolPlugins, authorize: pluginAuthorize } =
634+
await resolveToolPlugins({
635+
candidates: toolPluginCandidates,
636+
pluginConfig,
637+
});
637638

638639
const skillDirs = skillDirsFromEnabledPlugins(
639640
executablePlugins(),
@@ -697,6 +698,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
697698
// Main session: gating rides the reactor's approval-suspend seam. In
698699
// headless exec an ask resolves to a deny effect, so the hook blocks.
699700
reactorGated: true,
701+
...(pluginAuthorize !== undefined ? { pluginAuthorize } : {}),
700702
permissionsPath: config.permissionsPath,
701703
});
702704

‎src/tui/runner/session.ts‎

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,33 @@ export async function assembleTUISession(
202202
correlationAcceptance.settleAll();
203203
});
204204

205+
const executablePlugins = () =>
206+
pluginState.modules.filter((m) => m.metadataOnly !== true);
207+
pluginState.webCandidates = collectWebPlugins(executablePlugins());
208+
// Tool plugins are wired in only when enabled AND consented.
209+
pluginState.toolCandidates = collectToolPlugins(executablePlugins());
210+
// Web and tool plugin resolution are independent, so resolve them
211+
// concurrently.
212+
const toolPluginDiag = createPluginLoadDiagnostics();
213+
const [activeWeb, resolvedToolPlugins] = await Promise.all([
214+
resolveWebProviderFromPlugins({
215+
candidates: pluginState.webCandidates,
216+
pluginConfig: config.settings?.plugins ?? {},
217+
webOverride: config.settings?.web,
218+
}),
219+
resolveToolPlugins({
220+
candidates: pluginState.toolCandidates,
221+
pluginConfig: config.settings?.plugins ?? {},
222+
diagnostics: toolPluginDiag,
223+
}),
224+
]);
225+
const { plugins: extraToolPlugins, authorize: pluginAuthorize } =
226+
resolvedToolPlugins;
227+
if (activeWeb !== undefined)
228+
setActiveWebProviderBrand(webBrand(activeWeb.name));
229+
emitPluginWarningLog(toolPluginDiag);
230+
state.standingPluginWarnings.push(...toolPluginDiag.warnings);
231+
205232
const { gate: permissionGate } = await assembleSessionGate({
206233
cwd: config.cwd,
207234
sessionId: state.sessionId,
@@ -224,6 +251,7 @@ export async function assembleTUISession(
224251
auto: config.auto,
225252
// Main session: gating rides the reactor's approval-suspend seam.
226253
reactorGated: true,
254+
...(pluginAuthorize !== undefined ? { pluginAuthorize } : {}),
227255
permissionsPath: config.permissionsPath,
228256
onGrant: (approval, covers) =>
229257
emitter.emit("permission.grant", { approval, covers }),
@@ -249,32 +277,6 @@ export async function assembleTUISession(
249277
admission: getProcessAdmissionQueue(),
250278
});
251279

252-
const executablePlugins = () =>
253-
pluginState.modules.filter((m) => m.metadataOnly !== true);
254-
pluginState.webCandidates = collectWebPlugins(executablePlugins());
255-
// Tool plugins are wired in only when enabled AND consented.
256-
pluginState.toolCandidates = collectToolPlugins(executablePlugins());
257-
// Web and tool plugin resolution are independent, so resolve them
258-
// concurrently.
259-
const toolPluginDiag = createPluginLoadDiagnostics();
260-
const [activeWeb, resolvedToolPlugins] = await Promise.all([
261-
resolveWebProviderFromPlugins({
262-
candidates: pluginState.webCandidates,
263-
pluginConfig: config.settings?.plugins ?? {},
264-
webOverride: config.settings?.web,
265-
}),
266-
resolveToolPlugins({
267-
candidates: pluginState.toolCandidates,
268-
pluginConfig: config.settings?.plugins ?? {},
269-
diagnostics: toolPluginDiag,
270-
}),
271-
]);
272-
const { plugins: extraToolPlugins } = resolvedToolPlugins;
273-
if (activeWeb !== undefined)
274-
setActiveWebProviderBrand(webBrand(activeWeb.name));
275-
emitPluginWarningLog(toolPluginDiag);
276-
state.standingPluginWarnings.push(...toolPluginDiag.warnings);
277-
278280
// Descriptors mirror the mutable module list so plugins added by path
279281
// mid-session appear without a restart.
280282
pluginState.descriptors = pluginState.modules

0 commit comments

Comments
 (0)