Skip to content

Commit 2576ae5

Browse files
Merge pull request #1543 from corbitsdev/cl-10868-plugin-authorize-on-main
feat(plugins): authorize installed tool calls before dispatch
2 parents 8787873 + abd8171 commit 2576ae5

24 files changed

Lines changed: 1851 additions & 58 deletions

‎docs/PLUGINS.md‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,48 @@ shape.
254254

255255
- `kind: "tool"` plugins export `createToolPlugin(credentials)` and contribute
256256
posix `ToolPlugin`s, resolved in `src/plugins/tool-plugins.ts` and wired into
257-
the toolset in `src/tui/runner.ts` and `tools.ts` (appended last, so they cannot
258-
shadow core middleware).
259-
- A tool plugin is wired in only when **enabled AND consented**. Enabling one in
260-
`/plugins` prompts a one-time y/n consent recorded in `settings.plugins[id]`.
257+
the toolset by the shared session runners and `src/agent/tools.ts`.
258+
- The installed-plugin contract is the exported `ToolPlugin` type from
259+
`@intx/tools-posix`. In addition to `tools`, `middleware`, and `dispose`, it
260+
may provide `authorize?: AuthorizeFn` (the `AuthorizeFn` type is exported by
261+
`@intx/agent`). Corbits calls it before a tool invocation as
262+
`authorize(resource, action, context)`, with `resource` set to
263+
`tool:<canonical-name>`, `action` set to `"invoke"`, and `context` set to the
264+
canonical `ToolCall`. Aliases and their arguments are normalized first (for
265+
example, `bash` is presented as `run_shell`). The callback returns the
266+
exported `AuthzCallResult` shape: an `effect` of `"allow"`, `"ask"`,
267+
`"deny"`, or `null`, plus `matchingGrants` and `resolvedBy`; a deny may also
268+
include a non-empty `reason`.
269+
- A tool plugin participates only after its module is trusted and loaded and
270+
when it is **enabled AND consented**. Enabling one in `/plugins` prompts a
271+
one-time y/n consent recorded in `settings.plugins[id]`; manifest
272+
`defaultEnabled` does not activate tool plugins by itself. Disabled,
273+
unconsented, and metadata-only untrusted plugins contribute neither
274+
middleware nor authorization.
275+
- Authorization composes conservatively. Across authorizing plugins, deny (and
276+
the fail-closed `null` effect) blocks, ask is stronger than allow, and the
277+
first result at the strongest effect is retained. Corbits then combines that
278+
result with its own `PermissionGate` monotonically: a plugin deny blocks; a
279+
plugin ask can strengthen a Corbits allow into an approval request; and a
280+
plugin allow cannot override a Corbits deny or ask, standing-grant rules, or
281+
any other Corbits guard. Corbits hard denials short-circuit before plugin
282+
authorization runs.
283+
- `null`, a malformed result, or a thrown/rejected callback fails closed as a
284+
deny. Exception and validation details are logged only through scrubbed
285+
diagnostics and are not exposed as the public denial reason. By contrast, a
286+
plugin's explicit non-empty `reason` on an `effect: "deny"` result is used as
287+
the public block reason; omit it for the generic denial.
288+
- A plugin `ask` uses the existing approval flow. Because plugin asks provide
289+
no grant scopes, they are **Allow Once** only and cannot create or persist a
290+
standing grant. If no interactive approval path is available, the ask is
291+
denied. Existing Corbits asks keep their own scopes and persistence behavior.
292+
- `authorize` is independent of `middleware`: an allowed invocation still runs
293+
the existing middleware chain, and tool-result secret scrubbing and
294+
truncation remain outermost after installed middleware returns. Installed
295+
middleware is still appended after core middleware, so it cannot bypass
296+
post-tool redaction. If no active tool plugin provides `authorize`, Corbits
297+
does not install a plugin-authorizer wrapper on the permission hot path; the
298+
pre-existing gate and middleware behavior is unchanged.
261299
- Per-kind verify in `/plugins` covers web (trial search) and tool (load check).
262300
- `plugins/example-tool` is a worked example of a `tool` plugin.
263301

‎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 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

0 commit comments

Comments
 (0)