Skip to content

Commit 1812c1d

Browse files
committed
feat(mcp-server): add permission forecasting and approval bundling to parse_mode
- Define permission class taxonomy (read-only, repo-write, network, destructive, external) - Create approval bundle grouping for related actions (Ship, Run checks, Install, Review) - Analyze mode + prompt signals to predict permission needs before execution - Integrate permissionForecast field into ParseModeResult response - Add unit tests for forecast generation and integration tests for parse_mode Closes #1377
1 parent c9d79f4 commit 1812c1d

5 files changed

Lines changed: 372 additions & 0 deletions

File tree

apps/mcp-server/src/keyword/keyword.service.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2932,4 +2932,35 @@ ${'Even more content.\n'.repeat(150)}`;
29322932
expect(result.requiredSkillsEnforced).toBe(true);
29332933
});
29342934
});
2935+
2936+
describe('permissionForecast integration', () => {
2937+
it('includes permissionForecast in PLAN mode response', async () => {
2938+
const result = await service.parseMode('PLAN design auth feature');
2939+
expect(result.permissionForecast).toBeDefined();
2940+
expect(result.permissionForecast!.permissionClasses).toContain('read-only');
2941+
expect(result.permissionForecast!.permissionSummary).toContain('PLAN');
2942+
});
2943+
2944+
it('includes permissionForecast in ACT mode response', async () => {
2945+
const result = await service.parseMode('ACT implement login');
2946+
expect(result.permissionForecast).toBeDefined();
2947+
expect(result.permissionForecast!.permissionClasses).toContain('repo-write');
2948+
});
2949+
2950+
it('includes ship bundle when prompt mentions shipping', async () => {
2951+
const result = await service.parseMode('ACT ship the changes');
2952+
expect(result.permissionForecast).toBeDefined();
2953+
const ship = result.permissionForecast!.approvalBundles.find(
2954+
(b: { name: string }) => b.name === 'Ship changes',
2955+
);
2956+
expect(ship).toBeDefined();
2957+
expect(result.permissionForecast!.permissionClasses).toContain('external');
2958+
});
2959+
2960+
it('provides a human-readable summary', async () => {
2961+
const result = await service.parseMode('EVAL review PR');
2962+
expect(typeof result.permissionForecast!.permissionSummary).toBe('string');
2963+
expect(result.permissionForecast!.permissionSummary.length).toBeGreaterThan(0);
2964+
});
2965+
});
29352966
});

apps/mcp-server/src/keyword/keyword.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { filterRulesByMode } from './rule-filter';
3131
import { truncateSkillContent } from '../skill/skill-content.utils';
3232
import { createAgentSummary } from '../agent/agent-summary.utils';
3333
import { truncateRuleContent } from '../rules/rules-content.utils';
34+
import { generatePermissionForecast } from './permission-forecast';
3435
import { getDefaultModeConfig } from '../shared/keyword-core';
3536
import { isTaskmaestroAvailable } from './taskmaestro-detector';
3637
import { type ClientType } from '../shared/client-type';
@@ -539,6 +540,9 @@ export class KeywordService {
539540
// 11. Auto-include release checklist in EVAL mode on version changes (#1085)
540541
this.addReleaseChecklistIfNeeded(result, mode, originalPrompt);
541542

543+
// 12. Permission forecasting — predict permission needs and bundle related actions (#1377)
544+
result.permissionForecast = generatePermissionForecast(mode, originalPrompt);
545+
542546
return result;
543547
}
544548

apps/mcp-server/src/keyword/keyword.types.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,47 @@ export const KEYWORDS = ['PLAN', 'ACT', 'EVAL', 'AUTO'] as const;
77

88
export type Mode = (typeof KEYWORDS)[number];
99

10+
// ─── Permission forecasting types (#1377) ─────────────────────────
11+
12+
/**
13+
* Classification of permission levels required for execution actions.
14+
* Ordered roughly from least to most impactful.
15+
*/
16+
export type PermissionClass =
17+
| 'read-only' // file reads, grep, git log
18+
| 'repo-write' // file edits, git commit, git push
19+
| 'network' // API calls, package install
20+
| 'destructive' // file deletion, git reset, branch delete
21+
| 'external'; // GitHub API (PR create, issue comment)
22+
23+
/**
24+
* A group of related actions that share a logical intent and permission
25+
* class. By surfacing bundles *before* execution, users can anticipate
26+
* why approval prompts appear later.
27+
*/
28+
export interface ApprovalBundle {
29+
/** Short human-readable label, e.g. "Ship changes" */
30+
name: string;
31+
/** Concrete actions in this bundle */
32+
actions: string[];
33+
/** Dominant permission class */
34+
permissionClass: PermissionClass;
35+
/** Why this bundle is needed */
36+
reason: string;
37+
}
38+
39+
/**
40+
* Permission forecast attached to a parse_mode response.
41+
*/
42+
export interface PermissionForecast {
43+
/** Expected permission classes for this mode + task */
44+
permissionClasses: PermissionClass[];
45+
/** Grouped related actions */
46+
approvalBundles: ApprovalBundle[];
47+
/** One-line human-readable summary */
48+
permissionSummary: string;
49+
}
50+
1051
/** Mode Agent names in priority order */
1152
export const MODE_AGENTS = ['plan-mode', 'act-mode', 'eval-mode', 'auto-mode'] as const;
1253

@@ -546,6 +587,13 @@ export interface ParseModeResult {
546587
* Reflects environment, config, or default gating from TeamsCapabilityService (#1311).
547588
*/
548589
teamsCapability?: TeamsCapabilityStatus;
590+
/**
591+
* @apiProperty External API - do not rename.
592+
* Forecasts the permission classes and approval bundles expected during
593+
* execution, so users can anticipate upcoming approval prompts.
594+
* Present in all modes.
595+
*/
596+
permissionForecast?: PermissionForecast;
549597
}
550598

551599
/**
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generatePermissionForecast } from './permission-forecast';
3+
import type { PermissionClass } from './keyword.types';
4+
5+
describe('generatePermissionForecast', () => {
6+
// ── Mode base classes ──────────────────────────────────────────
7+
8+
describe('mode base permission classes', () => {
9+
it('PLAN mode → read-only only', () => {
10+
const forecast = generatePermissionForecast('PLAN', 'design auth feature');
11+
expect(forecast.permissionClasses).toEqual(['read-only']);
12+
});
13+
14+
it('ACT mode → read-only + repo-write', () => {
15+
const forecast = generatePermissionForecast('ACT', 'implement login');
16+
expect(forecast.permissionClasses).toContain('read-only');
17+
expect(forecast.permissionClasses).toContain('repo-write');
18+
});
19+
20+
it('EVAL mode → read-only only (no prompt signals)', () => {
21+
const forecast = generatePermissionForecast('EVAL', 'check code quality');
22+
expect(forecast.permissionClasses).toEqual(['read-only']);
23+
});
24+
25+
it('AUTO mode → read-only + repo-write + external', () => {
26+
const forecast = generatePermissionForecast('AUTO', 'add login feature');
27+
expect(forecast.permissionClasses).toContain('read-only');
28+
expect(forecast.permissionClasses).toContain('repo-write');
29+
expect(forecast.permissionClasses).toContain('external');
30+
});
31+
});
32+
33+
// ── Prompt-signal enrichment ───────────────────────────────────
34+
35+
describe('prompt-signal enrichment', () => {
36+
it('ship-related prompt adds repo-write + external and Ship bundle', () => {
37+
const forecast = generatePermissionForecast('ACT', 'ship the changes to GitHub');
38+
expect(forecast.permissionClasses).toContain('repo-write');
39+
expect(forecast.permissionClasses).toContain('external');
40+
const ship = forecast.approvalBundles.find(b => b.name === 'Ship changes');
41+
expect(ship).toBeDefined();
42+
expect(ship!.actions).toContain('git push');
43+
expect(ship!.actions).toContain('gh pr create');
44+
});
45+
46+
it('test-related prompt adds Run checks bundle', () => {
47+
const forecast = generatePermissionForecast('ACT', 'run tests and lint');
48+
const checks = forecast.approvalBundles.find(b => b.name === 'Run checks');
49+
expect(checks).toBeDefined();
50+
expect(checks!.actions).toContain('yarn test');
51+
});
52+
53+
it('install-related prompt adds network class and Install bundle', () => {
54+
const forecast = generatePermissionForecast('ACT', 'install lodash package');
55+
expect(forecast.permissionClasses).toContain('network');
56+
const install = forecast.approvalBundles.find(b => b.name === 'Install dependencies');
57+
expect(install).toBeDefined();
58+
});
59+
60+
it('delete-related prompt adds destructive class', () => {
61+
const forecast = generatePermissionForecast('ACT', 'delete the old migration files');
62+
expect(forecast.permissionClasses).toContain('destructive');
63+
});
64+
65+
it('review prompt in EVAL mode adds external class and Review bundle', () => {
66+
const forecast = generatePermissionForecast('EVAL', 'review PR 1234');
67+
expect(forecast.permissionClasses).toContain('external');
68+
const review = forecast.approvalBundles.find(b => b.name === 'Review PR');
69+
expect(review).toBeDefined();
70+
expect(review!.actions).toContain('gh pr review');
71+
});
72+
73+
it('review prompt in ACT mode does NOT add Review bundle', () => {
74+
const forecast = generatePermissionForecast('ACT', 'review the code');
75+
const review = forecast.approvalBundles.find(b => b.name === 'Review PR');
76+
expect(review).toBeUndefined();
77+
});
78+
});
79+
80+
// ── Implicit bundles ───────────────────────────────────────────
81+
82+
describe('implicit bundles', () => {
83+
it('ACT mode with no signal patterns → Code changes bundle', () => {
84+
const forecast = generatePermissionForecast('ACT', 'implement user dashboard');
85+
const codeChanges = forecast.approvalBundles.find(b => b.name === 'Code changes');
86+
expect(codeChanges).toBeDefined();
87+
expect(codeChanges!.permissionClass).toBe('repo-write');
88+
});
89+
90+
it('ACT mode with explicit ship signal → no Code changes bundle (has Ship instead)', () => {
91+
const forecast = generatePermissionForecast('ACT', 'ship the feature');
92+
const codeChanges = forecast.approvalBundles.find(b => b.name === 'Code changes');
93+
expect(codeChanges).toBeUndefined();
94+
});
95+
});
96+
97+
// ── Permission summary ─────────────────────────────────────────
98+
99+
describe('permissionSummary', () => {
100+
it('is a human-readable string', () => {
101+
const forecast = generatePermissionForecast('PLAN', 'design API');
102+
expect(typeof forecast.permissionSummary).toBe('string');
103+
expect(forecast.permissionSummary.length).toBeGreaterThan(0);
104+
});
105+
106+
it('includes mode name', () => {
107+
const forecast = generatePermissionForecast('PLAN', 'design API');
108+
expect(forecast.permissionSummary).toContain('PLAN');
109+
});
110+
111+
it('includes highest permission class', () => {
112+
const forecast = generatePermissionForecast('ACT', 'ship to GitHub');
113+
expect(forecast.permissionSummary).toContain('external');
114+
});
115+
116+
it('includes bundle names when bundles present', () => {
117+
const forecast = generatePermissionForecast('ACT', 'ship and run tests');
118+
expect(forecast.permissionSummary).toContain('Ship changes');
119+
expect(forecast.permissionSummary).toContain('Run checks');
120+
});
121+
122+
it('no bundle names when no bundles', () => {
123+
const forecast = generatePermissionForecast('PLAN', 'design API');
124+
expect(forecast.permissionSummary).not.toContain('—');
125+
});
126+
});
127+
128+
// ── Permission class ordering ──────────────────────────────────
129+
130+
describe('class ordering', () => {
131+
it('returns classes in canonical order: read-only < repo-write < network < destructive < external', () => {
132+
const forecast = generatePermissionForecast(
133+
'ACT',
134+
'delete files, install packages, and ship',
135+
);
136+
const expected: PermissionClass[] = [
137+
'read-only',
138+
'repo-write',
139+
'network',
140+
'destructive',
141+
'external',
142+
];
143+
expect(forecast.permissionClasses).toEqual(expected);
144+
});
145+
});
146+
});

0 commit comments

Comments
 (0)