Skip to content

Commit 20028ae

Browse files
committed
fix(plugins): fail closed on every malformed authorizer result
1 parent a8f27b1 commit 20028ae

4 files changed

Lines changed: 474 additions & 46 deletions

File tree

‎src/permission/gate.ts‎

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { AuthorizeFn } from "@intx/agent";
22
import type { ToolCall } from "@intx/types/runtime";
33
import { getLogger } from "@intx/log";
4-
import { type } from "arktype";
54
import { isAbsolute, resolve } from "node:path";
65
import type {
76
Approval,
@@ -62,6 +61,10 @@ import { canonicalToolName } from "../agent/canonical-tool-name.js";
6261
import { prepareDispatchedToolCall } from "../agent/tool-aliases.js";
6362
import { LOG_NAMESPACE_ROOT } from "../branding.js";
6463
import { scrubSecrets } from "../web/secret-scrub.js";
64+
import {
65+
safePluginDiagnostic,
66+
validatePluginAuthorizeResult,
67+
} from "./plugin-authorize-result.js";
6568

6669
import {
6770
createMcpToolPermissionRegistry,
@@ -354,36 +357,6 @@ const AUTO_ALLOWED_TOOLS = new Set([
354357

355358
const logger = getLogger([LOG_NAMESPACE_ROOT, "permission", "plugin-authz"]);
356359

357-
function safePluginDiagnostic(err: unknown): string {
358-
try {
359-
const diagnostic: unknown = err instanceof Error ? err.message : err;
360-
return typeof diagnostic === "string" ? diagnostic : String(diagnostic);
361-
} catch {
362-
return "[unprintable thrown value]";
363-
}
364-
}
365-
366-
const PluginAuthorizeResult = type({
367-
effect: "'allow' | 'ask' | 'deny' | null",
368-
matchingGrants: "unknown[]",
369-
resolvedBy: "unknown",
370-
"reason?": "string",
371-
});
372-
373-
function materializePluginAuthorizeResult(rawResult: unknown): unknown {
374-
const source = rawResult as Record<string, unknown>;
375-
const effect = source.effect;
376-
const matchingGrants = source.matchingGrants;
377-
const resolvedBy = source.resolvedBy;
378-
const reason = source.reason;
379-
return {
380-
effect,
381-
matchingGrants,
382-
resolvedBy,
383-
...(reason !== undefined ? { reason } : {}),
384-
};
385-
}
386-
387360
export interface PermissionGateOptions {
388361
// Seed approvals; the gate copies them and never mutates the caller's array.
389362
approvals: Approval[];
@@ -1030,13 +1003,16 @@ export function createPermissionGate(
10301003
"invoke",
10311004
call,
10321005
);
1033-
const result = PluginAuthorizeResult(
1034-
materializePluginAuthorizeResult(rawResult),
1035-
);
1036-
if (result instanceof type.errors) {
1037-
logger.warn`plugin authorization returned a malformed result: ${scrubSecrets(result.summary)}`;
1006+
const validated = validatePluginAuthorizeResult(rawResult);
1007+
if (!validated.ok) {
1008+
if (validated.kind === "malformed") {
1009+
logger.warn`plugin authorization returned a malformed result: ${scrubSecrets(validated.diagnostic)}`;
1010+
} else {
1011+
logger.warn`plugin authorization failed: ${scrubSecrets(validated.diagnostic)}`;
1012+
}
10381013
return { kind: "deny", reason: "Plugin authorization failed closed." };
10391014
}
1015+
const { result } = validated;
10401016
if (result.effect === "deny" || result.effect === null) {
10411017
const publicReason =
10421018
result.effect === "deny" &&
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { AuthzCallResult } from "@intx/inference";
2+
import { type } from "arktype";
3+
4+
const PluginAuthorizeResult = type({
5+
effect: "'allow' | 'ask' | 'deny' | null",
6+
matchingGrants: "unknown[]",
7+
resolvedBy: "unknown",
8+
"reason?": "string",
9+
});
10+
11+
export type PluginAuthorizeResultBoundary =
12+
| { ok: true; result: AuthzCallResult }
13+
| { ok: false; kind: "malformed" | "hostile"; diagnostic: string };
14+
15+
export function safePluginDiagnostic(err: unknown): string {
16+
try {
17+
const diagnostic: unknown = err instanceof Error ? err.message : err;
18+
return typeof diagnostic === "string" ? diagnostic : String(diagnostic);
19+
} catch {
20+
return "[unprintable thrown value]";
21+
}
22+
}
23+
24+
export function validatePluginAuthorizeResult(
25+
rawResult: unknown,
26+
): PluginAuthorizeResultBoundary {
27+
try {
28+
const source = rawResult as Record<string, unknown>;
29+
const effect = source.effect;
30+
const matchingGrants = source.matchingGrants;
31+
const resolvedBy = source.resolvedBy;
32+
const reason = source.reason;
33+
const result = PluginAuthorizeResult({
34+
effect,
35+
matchingGrants,
36+
resolvedBy,
37+
...(reason !== undefined ? { reason } : {}),
38+
});
39+
return result instanceof type.errors
40+
? { ok: false, kind: "malformed", diagnostic: result.summary }
41+
: { ok: true, result: result as AuthzCallResult };
42+
} catch (err) {
43+
return {
44+
ok: false,
45+
kind: "hostile",
46+
diagnostic: safePluginDiagnostic(err),
47+
};
48+
}
49+
}

0 commit comments

Comments
 (0)