Skip to content

Commit 0452663

Browse files
Anarchidclaude
andcommitted
feat(mcpl): per-server tool allow/deny policy
Adds enabledTools/disabledTools to McplServerConfig — bare tool names with `*` substring wildcards. Filtered both at tool-list time (model never sees the tool) and at dispatch time (call rejected with a tool-result error). The dual gate is deliberate: schema omission alone doesn't stop a model from imitating a denied call it sees in its own prior message history. Deny wins over allow on overlap. Regex metacharacters in patterns are escaped, so only `*` is special. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cf882e1 commit 0452663

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

src/framework.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
5151
import { ChannelRegistry } from './mcpl/channel-registry.js';
5252
import { safeSlice } from './safe-slice.js';
5353
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
54+
import { isToolAllowed } from './mcpl/tool-policy.js';
5455
import { EventGate } from './gate/event-gate.js';
5556
import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
5657
import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
@@ -2111,6 +2112,13 @@ export class AgentFramework {
21112112
}
21122113
const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
21132114
const toolName = call.name.slice(prefix.length + 2); // Strip prefix + '--'
2115+
if (!isToolAllowed(toolName, config)) {
2116+
return {
2117+
success: false,
2118+
error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
2119+
isError: true,
2120+
};
2121+
}
21142122
try {
21152123
const result = await server.sendToolsCall(toolName, call.input as Record<string, unknown>);
21162124
return {
@@ -2660,6 +2668,7 @@ export class AgentFramework {
26602668
try {
26612669
const result = await server.sendToolsList();
26622670
for (const tool of result.tools) {
2671+
if (!isToolAllowed(tool.name, config)) continue;
26632672
// MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
26642673
const schema = tool.inputSchema as import('./types/index.js').ToolDefinition['inputSchema'];
26652674
tools.push({
@@ -2749,6 +2758,23 @@ export class AgentFramework {
27492758
return;
27502759
}
27512760

2761+
const config = this.mcplServerConfigs.get(serverId);
2762+
if (!isToolAllowed(toolName, config)) {
2763+
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
2764+
this.pushEvent({
2765+
type: 'tool-result',
2766+
callId: call.id,
2767+
agentName,
2768+
moduleName: `mcpl:${serverId}`,
2769+
result: {
2770+
success: false,
2771+
error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
2772+
isError: true,
2773+
},
2774+
});
2775+
return;
2776+
}
2777+
27522778
this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
27532779
const startTime = Date.now();
27542780
const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};

src/mcpl/tool-policy.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Tool allow/deny policy for MCPL servers.
3+
*
4+
* `enabledTools` and `disabledTools` on McplServerConfig hold bare tool names
5+
* (no toolPrefix) with `*` substring wildcards. This module owns the predicate
6+
* used at both tool-list time and dispatch time.
7+
*
8+
* Semantics:
9+
* - No fields set → all tools allowed.
10+
* - Only enabledTools set → tool must match at least one pattern.
11+
* - Only disabledTools set → tool must NOT match any pattern.
12+
* - Both set → must match enabledTools AND not match disabledTools.
13+
* (deny wins on overlap.)
14+
*/
15+
16+
/**
17+
* Compile a pattern with `*` substring wildcards into a regex.
18+
* `*` matches any run of characters (including empty); other characters are literal.
19+
*/
20+
function compilePattern(pattern: string): RegExp {
21+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
22+
return new RegExp(`^${escaped}$`);
23+
}
24+
25+
function anyMatch(patterns: string[], name: string): boolean {
26+
for (const p of patterns) {
27+
if (compilePattern(p).test(name)) return true;
28+
}
29+
return false;
30+
}
31+
32+
export interface ToolPolicy {
33+
enabledTools?: string[];
34+
disabledTools?: string[];
35+
}
36+
37+
/**
38+
* Returns true if `bareToolName` (server-native name, no prefix) is allowed
39+
* under the supplied policy.
40+
*/
41+
export function isToolAllowed(bareToolName: string, policy: ToolPolicy | undefined): boolean {
42+
if (!policy) return true;
43+
const { enabledTools, disabledTools } = policy;
44+
45+
if (disabledTools && disabledTools.length > 0 && anyMatch(disabledTools, bareToolName)) {
46+
return false;
47+
}
48+
if (enabledTools && enabledTools.length > 0) {
49+
return anyMatch(enabledTools, bareToolName);
50+
}
51+
return true;
52+
}

src/mcpl/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,24 @@ export interface McplServerConfig {
180180
/** Feature sets to explicitly disable on connect */
181181
disabledFeatureSets?: string[];
182182

183+
/**
184+
* Tool allow-list (bare tool names as the server exports them, no toolPrefix).
185+
* Supports `*` as a substring wildcard (e.g. `read_*`, `*_file`, `*`).
186+
* If set, only tools matching at least one pattern are exposed.
187+
* `disabledTools` takes precedence on conflict.
188+
*
189+
* Filter is applied in two places: at tool-list time (model never sees the
190+
* tool) and at dispatch time (call rejected with a tool-result error, in
191+
* case the model imitates a prior call from message history).
192+
*/
193+
enabledTools?: string[];
194+
195+
/**
196+
* Tool deny-list (bare tool names, same wildcard syntax as enabledTools).
197+
* Wins over enabledTools on conflict.
198+
*/
199+
disabledTools?: string[];
200+
183201
/** Scope configurations per feature set */
184202
scopes?: Record<string, ScopeConfig>;
185203

test/tool-policy.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert';
3+
import { isToolAllowed } from '../src/mcpl/tool-policy.js';
4+
5+
describe('isToolAllowed', () => {
6+
it('allows everything when no policy is set', () => {
7+
assert.strictEqual(isToolAllowed('upload_file', undefined), true);
8+
assert.strictEqual(isToolAllowed('upload_file', {}), true);
9+
});
10+
11+
it('allow-list: only listed tools pass', () => {
12+
const policy = { enabledTools: ['list_files', 'read_file'] };
13+
assert.strictEqual(isToolAllowed('list_files', policy), true);
14+
assert.strictEqual(isToolAllowed('read_file', policy), true);
15+
assert.strictEqual(isToolAllowed('upload_file', policy), false);
16+
});
17+
18+
it('deny-list: listed tools blocked, rest allowed', () => {
19+
const policy = { disabledTools: ['delete_file', 'rename'] };
20+
assert.strictEqual(isToolAllowed('delete_file', policy), false);
21+
assert.strictEqual(isToolAllowed('rename', policy), false);
22+
assert.strictEqual(isToolAllowed('read_file', policy), true);
23+
});
24+
25+
it('deny wins over allow on overlap', () => {
26+
const policy = { enabledTools: ['*'], disabledTools: ['upload_*'] };
27+
assert.strictEqual(isToolAllowed('upload_file', policy), false);
28+
assert.strictEqual(isToolAllowed('read_file', policy), true);
29+
});
30+
31+
it('wildcards: prefix, suffix, and bare *', () => {
32+
assert.strictEqual(isToolAllowed('read_file', { enabledTools: ['read_*'] }), true);
33+
assert.strictEqual(isToolAllowed('write_file', { enabledTools: ['read_*'] }), false);
34+
assert.strictEqual(isToolAllowed('read_file', { enabledTools: ['*_file'] }), true);
35+
assert.strictEqual(isToolAllowed('read_dir', { enabledTools: ['*_file'] }), false);
36+
assert.strictEqual(isToolAllowed('anything_at_all', { enabledTools: ['*'] }), true);
37+
});
38+
39+
it('literal patterns require exact match (not substring)', () => {
40+
const policy = { enabledTools: ['read'] };
41+
assert.strictEqual(isToolAllowed('read', policy), true);
42+
assert.strictEqual(isToolAllowed('read_file', policy), false);
43+
});
44+
45+
it('regex metacharacters in patterns are treated literally', () => {
46+
const policy = { enabledTools: ['get.file', 'foo+bar', 'baz(qux)'] };
47+
assert.strictEqual(isToolAllowed('get.file', policy), true);
48+
assert.strictEqual(isToolAllowed('getXfile', policy), false, '. is not a regex wildcard');
49+
assert.strictEqual(isToolAllowed('foo+bar', policy), true);
50+
assert.strictEqual(isToolAllowed('foobar', policy), false, '+ is not a regex quantifier');
51+
assert.strictEqual(isToolAllowed('baz(qux)', policy), true);
52+
});
53+
54+
it('empty arrays behave like absent fields', () => {
55+
assert.strictEqual(isToolAllowed('anything', { enabledTools: [] }), true);
56+
assert.strictEqual(isToolAllowed('anything', { disabledTools: [] }), true);
57+
});
58+
});

0 commit comments

Comments
 (0)