Compare commits

...

5 Commits

Author SHA1 Message Date
Mahima Shanware 0d62cdb1cc fix(prompts): resolve build and test errors in prompts.test.ts
- Fixes ESLint `no-explicit-any` errors by importing and using `AnyDeclarativeTool` from `tools.ts` instead of the unexported type in `tool-registry.ts`.
- Removes unused `nonReadOnlyMcpTool` local variable.
- Updates mock setups to correctly use `getActiveTools` for Plan Mode tool assertions, ensuring both the build and test suites pass.
2026-02-24 00:43:58 +00:00
Mahima Shanware 5cb555dd05 chore: update package-lock.json 2026-02-24 00:08:59 +00:00
Mahima Shanware 2c7a62049e fix(prompts): dynamically list active tools in Plan Mode system prompt
- Updates `PromptProvider` to retrieve and alphabetically sort the active tools from `ToolRegistry` for the Plan Mode prompt list.
- Removes hardcoded descriptions for `write_file` and `replace` from `snippets.ts` and `snippets.legacy.ts` as they are now dynamically included.
- Modifies verbiage in Plan Mode instructions to reflect that tools may not be exclusively "read-only" when workspace policies apply overrides.
- Updates snapshot assertions and prompt test mocks to verify the correct dynamic and sorted injection of tool descriptions.
2026-02-24 00:07:42 +00:00
Mahima Shanware 3e5a250b6f feat(tools): delegate Plan Mode tool filtering to PolicyEngine
- Removes hardcoded `PLAN_MODE_TOOLS` from `ToolRegistry` and delegates permission checks to the `PolicyEngine` using the new `isToolPotentiallyAllowed` method.
- Preserves Plan Mode bypass logic for read-only MCP tools while ensuring they respect explicit/wildcard policy blocks.
- Makes `getActiveTools` public with TSDoc documentation for use in prompt generation.
- Adds regression tests to `tool-registry.test.ts` to verify policy-driven tool availability.
2026-02-24 00:02:43 +00:00
Mahima Shanware 90cbd0cd5b feat(policy): add isToolPotentiallyAllowed for pre-execution evaluation
- Introduces `isToolPotentiallyAllowed` to the `PolicyEngine` to evaluate if a tool could be permitted under current modes and rules, without executing it.
- Correctly handles global rules, specific tool rules, wildcards, and conditional (argsPattern) rules.
- Adds comprehensive unit tests for the new method.
2026-02-24 00:02:02 +00:00
11 changed files with 317 additions and 90 deletions
+27 -1
View File
@@ -2129,6 +2129,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -2271,6 +2272,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2451,6 +2453,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2500,6 +2503,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2874,6 +2878,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2907,6 +2912,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2961,6 +2967,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4124,6 +4131,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4398,6 +4406,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5323,6 +5332,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7863,6 +7873,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8383,6 +8394,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9679,6 +9691,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9979,6 +9992,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -13667,6 +13681,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13677,6 +13692,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15730,6 +15746,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15953,7 +15970,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -15961,6 +15979,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16121,6 +16140,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16328,6 +16348,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16441,6 +16462,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16453,6 +16475,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17084,6 +17107,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17419,6 +17443,7 @@
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"systeminformation": "^5.25.11",
"tree-sitter-bash": "^0.25.0",
"undici": "^7.10.0",
@@ -17619,6 +17644,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -42,11 +42,9 @@ For example:
You are operating in **Plan Mode** - a structured planning workflow for designing implementation strategies before execution.
## Available Tools
The following read-only tools are available in Plan Mode:
The following tools are available in Plan Mode:
<tool>\`glob\`</tool>
<tool>\`grep_search\`</tool>
- \`write_file\` - Save plans to the plans directory (see Plan Storage below)
- \`replace\` - Update plans in the plans directory
## Plan Storage
- Save your plans as Markdown (.md) files ONLY within: \`/tmp/plans/\`
@@ -174,11 +172,9 @@ For example:
You are operating in **Plan Mode** - a structured planning workflow for designing implementation strategies before execution.
## Available Tools
The following read-only tools are available in Plan Mode:
The following tools are available in Plan Mode:
<tool>\`glob\`</tool>
<tool>\`grep_search\`</tool>
- \`write_file\` - Save plans to the plans directory (see Plan Storage below)
- \`replace\` - Update plans in the plans directory
## Plan Storage
- Save your plans as Markdown (.md) files ONLY within: \`/tmp/plans/\`
@@ -424,11 +420,9 @@ For example:
You are operating in **Plan Mode** - a structured planning workflow for designing implementation strategies before execution.
## Available Tools
The following read-only tools are available in Plan Mode:
The following tools are available in Plan Mode:
<tool>\`glob\`</tool>
<tool>\`grep_search\`</tool>
- \`write_file\` - Save plans to the plans directory (see Plan Storage below)
- \`replace\` - Update plans in the plans directory
## Plan Storage
- Save your plans as Markdown (.md) files ONLY within: \`/tmp/project-temp/plans/\`
+14 -20
View File
@@ -25,6 +25,7 @@ import {
} from '../config/models.js';
import { ApprovalMode } from '../policy/types.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import type { AnyDeclarativeTool } from '../tools/tools.js';
import type { CallableTool } from '@google/genai';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -85,6 +86,12 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue(['grep_search', 'glob']),
getAllTools: vi.fn().mockReturnValue([]),
getActiveTools: vi
.fn()
.mockReturnValue([
{ name: 'grep_search' } as unknown as AnyDeclarativeTool,
{ name: 'glob' } as unknown as AnyDeclarativeTool,
]),
}),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
storage: {
@@ -452,24 +459,11 @@ describe('Core System Prompt (prompts.ts)', () => {
true, // isReadOnly
);
const nonReadOnlyMcpTool = new DiscoveredMCPTool(
{} as CallableTool,
'nonreadonly-server',
'non_read_static_value',
'A non-read-only tool',
{},
{} as MessageBus,
false,
false,
);
// Note: non-read-only MCP tools are implicitly filtered out by the logic
// because they are not potentially allowed in Plan Mode by default.
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue([
vi.mocked(mockConfig.getToolRegistry().getActiveTools).mockReturnValue([
readOnlyMcpTool,
nonReadOnlyMcpTool,
]);
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([
readOnlyMcpTool.name,
nonReadOnlyMcpTool.name,
]);
const prompt = getCoreSystemPrompt(mockConfig);
@@ -483,10 +477,10 @@ describe('Core System Prompt (prompts.ts)', () => {
it('should only list available tools in PLAN mode', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
// Only enable a subset of tools, including ask_user
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([
'glob',
'read_file',
'ask_user',
vi.mocked(mockConfig.getToolRegistry().getActiveTools).mockReturnValue([
{ name: 'glob' } as unknown as AnyDeclarativeTool,
{ name: 'read_file' } as unknown as AnyDeclarativeTool,
{ name: 'ask_user' } as unknown as AnyDeclarativeTool,
]);
const prompt = getCoreSystemPrompt(mockConfig);
@@ -2458,4 +2458,137 @@ describe('PolicyEngine', () => {
expect(checkers[0].priority).toBe(2.5);
});
});
describe('isToolPotentiallyAllowed', () => {
it('should return true if tool is explicitly allowed', () => {
const engine = new PolicyEngine({
rules: [
{ toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 100 },
],
});
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(true);
});
it('should return false if tool is explicitly denied', () => {
const engine = new PolicyEngine({
rules: [
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 100 },
],
});
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(false);
});
it('should return true if tool is allowed by wildcard', () => {
const engine = new PolicyEngine({
rules: [
{
toolName: 'server__*',
decision: PolicyDecision.ALLOW,
priority: 100,
},
],
});
expect(engine.isToolPotentiallyAllowed('tool1', 'server')).toBe(true);
});
it('should respect priority: higher priority deny wins', () => {
const engine = new PolicyEngine({
rules: [
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 200 },
{ toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 100 },
],
});
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(false);
});
it('should respect priority: higher priority allow wins', () => {
const engine = new PolicyEngine({
rules: [
{ toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 200 },
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 100 },
],
});
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(true);
});
it('should return true for conditional DENY (argsPattern) if lower priority is ALLOW', () => {
const engine = new PolicyEngine({
rules: [
{
toolName: 'tool1',
decision: PolicyDecision.DENY,
priority: 200,
argsPattern: /something/,
},
{ toolName: 'tool1', decision: PolicyDecision.ALLOW, priority: 100 },
],
});
// Since it's only conditionally denied, it is "potentially" allowed
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(true);
});
it('should respect global rules', () => {
const engine = new PolicyEngine({
rules: [
{ decision: PolicyDecision.DENY, priority: 50 }, // Global deny
],
});
expect(engine.isToolPotentiallyAllowed('any_tool')).toBe(false);
});
it('should ignore global rules when ignoreGlobalRules is true', () => {
const engine = new PolicyEngine({
rules: [
{ decision: PolicyDecision.DENY, priority: 100 }, // Global deny
],
});
// Should fallback to defaultDecision (ASK_USER by default)
expect(engine.isToolPotentiallyAllowed('any_tool', undefined, true)).toBe(
true,
);
});
it('should still respect specific tool DENY when ignoreGlobalRules is true', () => {
const engine = new PolicyEngine({
rules: [
{ decision: PolicyDecision.DENY, priority: 100 }, // Global deny
{ toolName: 'tool1', decision: PolicyDecision.DENY, priority: 150 }, // Specific deny
],
});
expect(engine.isToolPotentiallyAllowed('tool1', undefined, true)).toBe(
false,
);
});
it('should respect modes', () => {
const engine = new PolicyEngine({
rules: [
{
toolName: 'tool1',
decision: PolicyDecision.ALLOW,
priority: 100,
modes: [ApprovalMode.PLAN],
},
],
});
expect(engine.isToolPotentiallyAllowed('tool1')).toBe(true); // Default mode is usually DEFAULT which might be allowed if defaultDecision is ASK_USER
const engineDeny = new PolicyEngine({
rules: [
{
toolName: 'tool1',
decision: PolicyDecision.ALLOW,
priority: 100,
modes: [ApprovalMode.PLAN],
},
{ decision: PolicyDecision.DENY, priority: 50 },
],
});
engineDeny.setApprovalMode(ApprovalMode.DEFAULT);
expect(engineDeny.isToolPotentiallyAllowed('tool1')).toBe(false);
engineDeny.setApprovalMode(ApprovalMode.PLAN);
expect(engineDeny.isToolPotentiallyAllowed('tool1')).toBe(true);
});
});
});
+67
View File
@@ -629,6 +629,73 @@ export class PolicyEngine {
return excludedTools;
}
/**
* Checks if a tool could potentially be allowed (or ASK_USER) under the current approval mode.
* This is used to filter out tools that are unconditionally denied before generating the prompt schema.
*/
isToolPotentiallyAllowed(
toolName: string,
serverName?: string,
ignoreGlobalRules = false,
): boolean {
const aliases = getToolAliases(toolName);
const namesToTry = [...aliases];
if (serverName) {
for (const alias of aliases) {
namesToTry.push(`${serverName}__${alias}`);
}
}
let globalVerdict: PolicyDecision | undefined;
for (const rule of this.rules) {
// Check if rule applies to current approval mode
if (rule.modes && rule.modes.length > 0) {
if (!rule.modes.includes(this.approvalMode)) {
continue;
}
}
const ruleToolName = rule.toolName;
let isToolMatch = false;
if (!ruleToolName) {
if (ignoreGlobalRules) {
continue;
}
isToolMatch = true; // Global rule matches all tools
if (globalVerdict === undefined) {
globalVerdict = rule.decision;
}
} else if (isWildcardPattern(ruleToolName)) {
isToolMatch = namesToTry.some((name) =>
matchesWildcard(ruleToolName, name),
);
} else {
isToolMatch = namesToTry.includes(ruleToolName);
}
if (isToolMatch) {
if (rule.decision !== PolicyDecision.DENY) {
// Found a rule that might allow it (could be conditional on args)
return true;
} else if (!rule.argsPattern) {
// Unconditional DENY that applies to this tool (either specific or global)
return false;
}
// Conditional DENY (depends on args). Keep checking lower priority rules
// because if the args DON'T match this DENY, a lower priority rule might ALLOW it.
}
}
// No specific rule allowed it or unconditionally denied it.
if (globalVerdict !== undefined && !ignoreGlobalRules) {
return globalVerdict !== PolicyDecision.DENY;
}
return this.defaultDecision !== PolicyDecision.DENY;
}
private applyNonInteractiveMode(decision: PolicyDecision): PolicyDecision {
// In non-interactive mode, ASK_USER becomes DENY
if (this.nonInteractive && decision === PolicyDecision.ASK_USER) {
+10 -19
View File
@@ -22,7 +22,6 @@ import {
import { CodebaseInvestigatorAgent } from '../agents/codebase-investigator.js';
import { isGitRepository } from '../utils/gitUtils.js';
import {
PLAN_MODE_TOOLS,
WRITE_TODOS_TOOL_NAME,
READ_FILE_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
@@ -67,25 +66,17 @@ export class PromptProvider {
const contextFilenames = getAllGeminiMdFilenames();
// --- Context Gathering ---
let planModeToolsList = PLAN_MODE_TOOLS.filter((t) =>
enabledToolNames.has(t),
)
.map((t) => ` <tool>\`${t}\`</tool>`)
.join('\n');
// Add read-only MCP tools to the list
let planModeToolsList = '';
if (isPlanMode) {
const allTools = config.getToolRegistry().getAllTools();
const readOnlyMcpTools = allTools.filter(
(t): t is DiscoveredMCPTool =>
t instanceof DiscoveredMCPTool && !!t.isReadOnly,
);
if (readOnlyMcpTools.length > 0) {
const mcpToolsList = readOnlyMcpTools
.map((t) => ` <tool>\`${t.name}\` (${t.serverName})</tool>`)
.join('\n');
planModeToolsList += `\n${mcpToolsList}`;
}
const activeTools = config.getToolRegistry().getActiveTools();
activeTools.sort((a, b) => a.name.localeCompare(b.name));
planModeToolsList = activeTools
.map((t) => {
const serverSuffix =
t instanceof DiscoveredMCPTool ? ` (${t.serverName})` : '';
return ` <tool>\`${t.name}\`${serverSuffix}</tool>`;
})
.join('\n');
}
let basePrompt: string;
+1 -3
View File
@@ -398,10 +398,8 @@ export function renderPlanningWorkflow(
You are operating in **Plan Mode** - a structured planning workflow for designing implementation strategies before execution.
## Available Tools
The following read-only tools are available in Plan Mode:
The following tools are available in Plan Mode:
${options.planModeToolsList}
- \`${WRITE_FILE_TOOL_NAME}\` - Save plans to the plans directory (see Plan Storage below)
- \`${EDIT_TOOL_NAME}\` - Update plans in the plans directory
## Plan Storage
- Save your plans as Markdown (.md) files ONLY within: \`${options.plansDir}/\`
+1 -3
View File
@@ -452,11 +452,9 @@ export function renderPlanningWorkflow(
You are operating in **Plan Mode**. Your goal is to produce a detailed implementation plan in \`${options.plansDir}/\` and get user approval before editing source code.
## Available Tools
The following read-only tools are available in Plan Mode:
The following tools are available in Plan Mode:
<available_tools>
${options.planModeToolsList}
<tool>${formatToolName(WRITE_FILE_TOOL_NAME)} - Save plans to the plans directory</tool>
<tool>${formatToolName(EDIT_TOOL_NAME)} - Update plans in the plans directory</tool>
</available_tools>
## Rules
-16
View File
@@ -112,22 +112,6 @@ export const ALL_BUILTIN_TOOL_NAMES = [
EXIT_PLAN_MODE_TOOL_NAME,
] as const;
/**
* Read-only tools available in Plan Mode.
* This list is used to dynamically generate the Plan Mode prompt,
* filtered by what tools are actually enabled in the current configuration.
*/
export const PLAN_MODE_TOOLS = [
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
READ_FILE_TOOL_NAME,
LS_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
] as const;
/**
* Validates if a tool name is syntactically valid.
* Checks against built-in tools, discovered tools, and MCP naming conventions.
@@ -379,6 +379,46 @@ describe('ToolRegistry', () => {
});
});
describe('getActiveTools', () => {
it('should include tools in Plan Mode when allowed by policy', () => {
// Setup Plan Mode
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.PLAN);
const mockTool = new MockTool({ name: 'mock-tool' });
toolRegistry.registerTool(mockTool);
// Mock PolicyEngine to explicitly allow this tool
const policyEngine = config.getPolicyEngine();
vi.spyOn(policyEngine, 'isToolPotentiallyAllowed').mockImplementation(
(toolName) => toolName === 'mock-tool',
);
const activeTools = toolRegistry.getActiveTools();
const activeToolNames = activeTools.map((t) => t.name);
expect(activeToolNames).toContain('mock-tool');
});
it('should exclude tools in Plan Mode when denied by policy', () => {
// Setup Plan Mode
vi.spyOn(config, 'getApprovalMode').mockReturnValue(ApprovalMode.PLAN);
const mockTool = new MockTool({ name: 'mock-tool' });
toolRegistry.registerTool(mockTool);
// Mock PolicyEngine to explicitly deny this tool
const policyEngine = config.getPolicyEngine();
vi.spyOn(policyEngine, 'isToolPotentiallyAllowed').mockImplementation(
(toolName) => toolName !== 'mock-tool',
);
const activeTools = toolRegistry.getActiveTools();
const activeToolNames = activeTools.map((t) => t.name);
expect(activeToolNames).not.toContain('mock-tool');
});
});
describe('getAllToolNames', () => {
it('should return all registered tool names', () => {
// Register tools with displayNames in non-alphabetical order
+21 -19
View File
@@ -26,7 +26,6 @@ import {
DISCOVERED_TOOL_PREFIX,
TOOL_LEGACY_ALIASES,
getToolAliases,
PLAN_MODE_TOOLS,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
} from './tool-names.js';
@@ -442,9 +441,12 @@ export class ToolRegistry {
}
/**
* Retrieves the list of currently active tools.
* This is filtered by the current policies and approval modes (e.g. Plan Mode).
* It is public so that PromptProvider can dynamically construct accurate tool lists.
* @returns All the tools that are not excluded.
*/
private getActiveTools(): AnyDeclarativeTool[] {
getActiveTools(): AnyDeclarativeTool[] {
const excludedTools =
this.expandExcludeToolsWithAliases(this.config.getExcludeTools()) ??
new Set([]);
@@ -490,28 +492,28 @@ export class ToolRegistry {
this.expandExcludeToolsWithAliases(this.config.getExcludeTools()) ??
new Set([]);
// Filter tools in Plan Mode to only allow approved read-only tools.
let serverName: string | undefined;
const isMcpTool = tool instanceof DiscoveredMCPTool;
if (isMcpTool) {
serverName = tool.getFullyQualifiedPrefix().slice(0, -2);
}
const isPlanMode =
typeof this.config.getApprovalMode === 'function' &&
this.config.getApprovalMode() === ApprovalMode.PLAN;
if (isPlanMode) {
const allowedToolNames = new Set<string>(PLAN_MODE_TOOLS);
// We allow write_file and replace for writing plans specifically.
allowedToolNames.add(WRITE_FILE_TOOL_NAME);
allowedToolNames.add(EDIT_TOOL_NAME);
// Discovered MCP tools are allowed if they are read-only.
if (
tool instanceof DiscoveredMCPTool &&
tool.isReadOnly &&
!allowedToolNames.has(tool.name)
) {
allowedToolNames.add(tool.name);
}
const isReadOnlyMcp = isMcpTool && tool.isReadOnly;
if (!allowedToolNames.has(tool.name)) {
return false;
}
// Read-only MCP tools bypass the Plan Mode global deny policy,
// but still respect specific tool or wildcard rules.
const ignoreGlobalRules = isPlanMode && isReadOnlyMcp;
if (
!this.config
.getPolicyEngine()
.isToolPotentiallyAllowed(tool.name, serverName, ignoreGlobalRules)
) {
return false;
}
const normalizedClassName = tool.constructor.name.replace(/^_+/, '');