Skip to content

Commit 71fd98b

Browse files
fix(permission): authorize aliased tool names and grant trusted plugin reads (#1103)
* fix(permission): alias tool names and grant trusted plugin reads Classify, grants, denial memory, and the execution cache use the same canonical name as dispatch so a default. prefix cannot force a second ask. Worker control-plane tools keep that allow after canonicalizing. Reads under trusted user plugin roots are not path-escape hard-denies; writes still are. * fix(approval): canonicalize parked tool names on resume Resume rebuilt requests from the raw parked name, so a default. prefix fell through to a generic tool grant instead of the file scopes live decide already uses. * refactor(tui): drop test-only canonicalToolName re-export
1 parent 9311342 commit 71fd98b

18 files changed

Lines changed: 568 additions & 63 deletions

‎docs/PLUGINS.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,7 @@ Discovered modules still require `settings.plugins[id].enabled` before agents or
176176
tools wire into the session. `search_agents` labels those profiles with
177177
`[source: claude]`. Default results are id, description, and spawn metadata;
178178
pass `include_body=true` to include the loaded system prompt / body so the
179-
parent never needs `read_file` on `~/.claude/plugins/...` (path-escape still
180-
blocks those roots for path tools; writes/deletes outside cwd stay denied). JS
179+
parent never needs `read_file` on `~/.claude/plugins/...` for profile bodies (`include_body=true`). Writes/deletes outside cwd stay denied. Reads of trusted user plugin dirs (`~/.corbits/plugins`) can be granted — path-escape does not hard-deny those reads before grants; writes remain denied. JS
181180
Claude plugins (if any) stay on explicit `pluginPaths`.
182181

183182
`settings.workflowPlugins` / `settings.agentPlugins` (specifier arrays) are

‎src/agent/canonical-tool-name.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const DEFAULT_PREFIX = "default.";
2+
3+
// Muse Spark emits `default.<name>` and duplicated `<name>.<name>`. Dispatch
4+
// already strips those onto catalog keys; classify, grants, and the execution
5+
// cache must use the same name so an alias cannot force a second ask/deny.
6+
export function canonicalToolName(requested: string): string {
7+
let name = requested;
8+
if (name.startsWith(DEFAULT_PREFIX)) {
9+
const stripped = name.slice(DEFAULT_PREFIX.length);
10+
if (stripped.length > 0) name = stripped;
11+
}
12+
return undoubledName(name) ?? name;
13+
}
14+
15+
function undoubledName(requested: string): string | undefined {
16+
if (requested.length < 3 || requested.length % 2 === 0) return undefined;
17+
const name = requested.slice(0, (requested.length - 1) / 2);
18+
if (requested !== `${name}.${name}`) return undefined;
19+
return name;
20+
}

‎src/agent/posix-tool-plugins.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ export function buildCorePosixToolPlugins(
114114
return [
115115
resultTruncationPlugin(truncationOptions),
116116
toolResultSecretScrubPlugin(),
117-
pathEscapePlugin(cwd, rootsProvider, { allowOutside }),
117+
pathEscapePlugin(cwd, rootsProvider, {
118+
allowOutside,
119+
trustedPluginRoots: () => [...permissionGate.getTrustedPluginRoots()],
120+
}),
118121
evidenceArchivePathGuardPlugin(),
119122
deleteFilePlugin(cwd, { allowOutside, rootsProvider }),
120123
toolOutputUriPlugin(),

‎src/permission/authz-grants.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { evaluateGrants, type GrantRule } from "@intx/authz";
22

3+
import { canonicalToolName } from "../agent/canonical-tool-name.js";
34
import type { Approval } from "./types.js";
45
import { matchesPattern } from "./matcher.js";
56
import { realpathOr } from "./worktree-roots.js";
@@ -23,7 +24,7 @@ export function approvalToGrantRule(
2324
origin: "invoker",
2425
// resource = subject pattern (command or path); action = tool name.
2526
resource: approval.pattern,
26-
action: approval.tool,
27+
action: canonicalToolName(approval.tool),
2728
conditions: null,
2829
expiresAt: null,
2930
};
@@ -81,7 +82,7 @@ export function grantScopeMatches(
8182
workspace: GrantWorkspace,
8283
): boolean {
8384
return (
84-
approval.tool === tool &&
85+
canonicalToolName(approval.tool) === canonicalToolName(tool) &&
8586
(approval.providerModel === undefined ||
8687
approval.providerModel === activeProviderModel) &&
8788
cwdMatchesGrant(approval.cwd, requestCwd, workspace)
@@ -115,8 +116,9 @@ export async function approvalCoversSubject(
115116
requestCwd,
116117
workspace,
117118
} = input;
119+
const action = canonicalToolName(tool);
118120
const scoped = approvals.filter((a) =>
119-
grantScopeMatches(a, tool, activeProviderModel, requestCwd, workspace),
121+
grantScopeMatches(a, action, activeProviderModel, requestCwd, workspace),
120122
);
121123
if (scoped.length === 0) return false;
122124

@@ -134,7 +136,7 @@ export async function approvalCoversSubject(
134136
.map((a, i) => approvalToGrantRule(a, i));
135137
if (grants.length === 0) return false;
136138

137-
const decision = await evaluateGrants(grants, subject, tool);
139+
const decision = await evaluateGrants(grants, subject, action);
138140
return decision.effect === "allow";
139141
}
140142

‎src/permission/classify.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
productMutationPaths,
3030
} from "../agent/product-mutation-tools.js";
3131
import { AUTO_ALLOW_READ_TOOLS as READ_ONLY_TOOLS } from "../agent/tool-classification.js";
32+
import { canonicalToolName } from "../agent/canonical-tool-name.js";
3233

3334
// Read-only tools never need approval as long as they don't touch a restricted
3435
// path; they cannot change the workspace. `lsp` is included here even though
@@ -91,11 +92,12 @@ export function classifyTool(
9192
toolName: string,
9293
mcpTiers?: McpToolPermissionRegistry,
9394
): Tier {
94-
if (READ_ONLY_TOOLS.has(toolName)) return "allow";
95-
if (isMcpToolName(toolName)) {
96-
const registered = mcpTiers?.tierFor(toolName);
95+
const name = canonicalToolName(toolName);
96+
if (READ_ONLY_TOOLS.has(name)) return "allow";
97+
if (isMcpToolName(name)) {
98+
const registered = mcpTiers?.tierFor(name);
9799
if (registered !== undefined) return registered;
98-
if (isReadOnlyMcpTool(toolName)) return "allow";
100+
if (isReadOnlyMcpTool(name)) return "allow";
99101
return "ask";
100102
}
101103
return "ask";
@@ -109,10 +111,12 @@ export function restrictedPathArg(
109111
call: ToolCall,
110112
isRestricted: (path: string, isWrite: boolean) => boolean,
111113
): string | undefined {
112-
if (!PATH_ARG_TOOLS.has(call.name)) return undefined;
113-
const path = stringArg(call, pathArgKey(call.name));
114+
if (!PATH_ARG_TOOLS.has(canonicalToolName(call.name))) return undefined;
115+
const path = stringArg(call, pathArgKey(canonicalToolName(call.name)));
114116
if (path.length === 0) return undefined;
115-
return isRestricted(path, isWriteTool(call.name)) ? path : undefined;
117+
return isRestricted(path, isWriteTool(canonicalToolName(call.name)))
118+
? path
119+
: undefined;
116120
}
117121

118122
// Outside-workspace path arguments are fine for pure-listing programs — listing
@@ -256,10 +260,11 @@ export function callTargetsRestricted(
256260
call: ToolCall,
257261
isRestricted: (path: string, isWrite: boolean) => boolean,
258262
): boolean {
259-
if (call.name === "run_shell")
263+
const name = canonicalToolName(call.name);
264+
if (name === "run_shell")
260265
return commandTargetsRestricted(stringArg(call, "command"), isRestricted);
261-
if (call.name === "apply_patch") {
262-
return productMutationPaths(call.name, call.arguments).some((path) =>
266+
if (name === "apply_patch") {
267+
return productMutationPaths(name, call.arguments).some((path) =>
263268
isRestricted(path, true),
264269
);
265270
}
@@ -481,7 +486,7 @@ export function isAutoAllowedShellCall(
481486
cwd: string = process.cwd(),
482487
rootsProvider: RootsProvider = NO_ROOTS,
483488
): boolean {
484-
if (call.name !== "run_shell") return false;
489+
if (canonicalToolName(call.name) !== "run_shell") return false;
485490
return isAutoAllowedShellCommand(
486491
stringArg(call, "command"),
487492
cwd,

‎src/permission/denial-memory.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,14 @@ export function stableRequestId(
5252
call: ToolCall,
5353
cwd: string = process.cwd(),
5454
rootsProvider: RootsProvider = () => [],
55+
trustedPluginRoots?: RootsProvider,
5556
): string {
56-
const identity = normalizePathArguments(call.arguments, cwd, rootsProvider);
57+
const identity = normalizePathArguments(
58+
call.arguments,
59+
cwd,
60+
rootsProvider,
61+
trustedPluginRoots,
62+
);
5763
return (
5864
call.name +
5965
NULL_SEPARATOR +

‎src/permission/gate.ts‎

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { OPERATOR_DECLINED_PREFIX } from "./decline-markers.js";
4747
import { DenialMemory, stableRequestId } from "./denial-memory.js";
4848
import { getSubAgentIdentity } from "../subagent/identity-context.js";
4949
import { PRODUCT_MUTATION_TOOLS } from "../agent/product-mutation-tools.js";
50+
import { canonicalToolName } from "../agent/canonical-tool-name.js";
5051

5152
import {
5253
createMcpToolPermissionRegistry,
@@ -368,6 +369,11 @@ export interface PermissionGateOptions {
368369
// already knows about — so a worktree created mid-session is picked up
369370
// without a restart.
370371
rootsProvider?: RootsProvider;
372+
// Directories of trusted (fully loaded) plugins. Reads under these roots are
373+
// not path-escape hard-denies — they stay restricted and can be granted.
374+
// Writes and deletes remain path-escape denies; plugin trust is not write
375+
// consent.
376+
trustedPluginRoots?: RootsProvider;
371377
// Tiers learned from connected MCP servers (tools/list annotations). Tests may
372378
// inject a shared registry; production gates create one when omitted.
373379
mcpTiers?: McpToolPermissionRegistry;
@@ -470,6 +476,10 @@ export interface PermissionGate {
470476
setProviderIdentity: (providerName: string, model: string) => void;
471477
registerMcpClient: (client: MCPClient) => void;
472478
unregisterMcpServer: (serverName: string) => void;
479+
// Live trusted plugin directories for path-escape's read exception. The
480+
// posix plugin stack reads this so authorize-time and execution-time
481+
// containment share one list.
482+
getTrustedPluginRoots: () => readonly string[];
473483
}
474484

475485
// True when splitChainedCommand can be trusted to yield only real segments for
@@ -489,8 +499,16 @@ function identityArguments(
489499
args: ToolCall["arguments"],
490500
cwd: string,
491501
rootsProvider: RootsProvider,
502+
trustedPluginRoots?: RootsProvider,
492503
): string {
493-
return JSON.stringify(normalizePathArguments(args, cwd, rootsProvider));
504+
return JSON.stringify(
505+
normalizePathArguments(args, cwd, rootsProvider, trustedPluginRoots),
506+
);
507+
}
508+
509+
function withCanonicalToolName(call: ToolCall): ToolCall {
510+
const name = canonicalToolName(call.name);
511+
return name === call.name ? call : { ...call, name };
494512
}
495513

496514
export function createPermissionGate(
@@ -505,6 +523,7 @@ export function createPermissionGate(
505523
const resolvedCwd = cwd ?? process.cwd();
506524
const rootsProvider =
507525
options.rootsProvider ?? createWorktreeRootsProvider(resolvedCwd);
526+
const trustedPluginRoots = options.trustedPluginRoots ?? (() => []);
508527
const pathRestriction = createPathRestriction(resolvedCwd, rootsProvider);
509528
const isRestricted = pathRestriction.isRestricted;
510529
// This gate's project boundary for grant matching (see cwdMatchesGrant):
@@ -642,6 +661,14 @@ export function createPermissionGate(
642661
// provider-identity switches). Timeouts and aborts are never recorded.
643662
const denialMemory = new DenialMemory();
644663

664+
const denialFingerprint = (call: ToolCall, cwd: string): string =>
665+
stableRequestId(
666+
withCanonicalToolName(call),
667+
cwd,
668+
rootsProvider,
669+
trustedPluginRoots,
670+
);
671+
645672
// Non-blocking policy decision for one tool call: everything the gate owns —
646673
// tier pre-filter, auto rules, pre-grant guards, grants, headless denial —
647674
// resolved WITHOUT waiting on an operator. `ask` carries the fully-built
@@ -659,7 +686,8 @@ export function createPermissionGate(
659686
segmentCount: number;
660687
};
661688

662-
const decide = async (call: ToolCall): Promise<GateDecision> => {
689+
const decide = async (rawCall: ToolCall): Promise<GateDecision> => {
690+
const call = withCanonicalToolName(rawCall);
663691
// Catastrophic shell commands are hard-denied here, at the top of the
664692
// single verdict path every entry (evaluate, authorizeCall,
665693
// executionVerdict) flows through — this is the owning enforcement point
@@ -692,7 +720,7 @@ export function createPermissionGate(
692720
// deny below: the decision is the same deny either way, but the reason
693721
// text is the originally recorded one, not a freshly computed escape
694722
// reason.
695-
const stableId = stableRequestId(call, effectiveCwd, rootsProvider);
723+
const stableId = denialFingerprint(call, effectiveCwd);
696724
const cachedDenial = denialMemory.isDenied(stableId);
697725
if (cachedDenial !== undefined)
698726
return { kind: "deny", reason: cachedDenial };
@@ -711,6 +739,7 @@ export function createPermissionGate(
711739
effectiveCwd,
712740
escapeRoots,
713741
call.name,
742+
trustedPluginRoots,
714743
);
715744
if (escapeReason !== undefined) {
716745
return { kind: "deny", reason: escapeReason };
@@ -1003,11 +1032,7 @@ export function createPermissionGate(
10031032
classifyOutcome(outcome) === "deny"
10041033
) {
10051034
denialMemory.record(
1006-
stableRequestId(
1007-
call,
1008-
getSubAgentIdentity()?.cwd ?? resolvedCwd,
1009-
rootsProvider,
1010-
),
1035+
denialFingerprint(call, getSubAgentIdentity()?.cwd ?? resolvedCwd),
10111036
reason,
10121037
);
10131038
}
@@ -1037,8 +1062,13 @@ export function createPermissionGate(
10371062
const verdict = mapAuthorizeVerdict(await decide(call));
10381063
const identityCwd = getSubAgentIdentity()?.cwd ?? resolvedCwd;
10391064
authorizedByCallId.set(call.id, {
1040-
name: call.name,
1041-
arguments: identityArguments(call.arguments, identityCwd, rootsProvider),
1065+
name: canonicalToolName(call.name),
1066+
arguments: identityArguments(
1067+
call.arguments,
1068+
identityCwd,
1069+
rootsProvider,
1070+
trustedPluginRoots,
1071+
),
10421072
verdict,
10431073
});
10441074
return verdict;
@@ -1051,9 +1081,14 @@ export function createPermissionGate(
10511081
const identityCwd = getSubAgentIdentity()?.cwd ?? resolvedCwd;
10521082
if (
10531083
cached !== undefined &&
1054-
cached.name === call.name &&
1084+
cached.name === canonicalToolName(call.name) &&
10551085
cached.arguments ===
1056-
identityArguments(call.arguments, identityCwd, rootsProvider)
1086+
identityArguments(
1087+
call.arguments,
1088+
identityCwd,
1089+
rootsProvider,
1090+
trustedPluginRoots,
1091+
)
10571092
) {
10581093
authorizedByCallId.delete(call.id);
10591094
return cached.verdict;
@@ -1096,10 +1131,9 @@ export function createPermissionGate(
10961131
classifyOutcome(outcome) === "deny"
10971132
) {
10981133
denialMemory.record(
1099-
stableRequestId(
1134+
denialFingerprint(
11001135
{ id: "", name: request.tool, arguments: request.arguments ?? {} },
11011136
request.cwd ?? getSubAgentIdentity()?.cwd ?? resolvedCwd,
1102-
rootsProvider,
11031137
),
11041138
declineReason(request, outcome),
11051139
);
@@ -1185,5 +1219,6 @@ export function createPermissionGate(
11851219
},
11861220
registerMcpClient,
11871221
unregisterMcpServer,
1222+
getTrustedPluginRoots: () => trustedPluginRoots(),
11881223
};
11891224
}

0 commit comments

Comments
 (0)