Skip to content

Commit 241afeb

Browse files
joshspicerCopilot
andcommitted
fix(agent-host): use canonical managed shell rule
Emit the runtime's kind-only Shell rule for terminal managed policy, validate the supported managed permission grammar in policy diagnostics, and distinguish client injection from the provider account/device baseline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f1dcbec commit 241afeb

5 files changed

Lines changed: 112 additions & 12 deletions

File tree

src/vs/platform/agentHost/common/agentHostSchema.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,12 @@ export interface IManagedPermissions {
319319

320320
/**
321321
* The runtime permission-rule string emitted when managed
322-
* `chat.tools.terminal.enableAutoApprove` is `false`. `Shell(*)` is the exact
323-
* all-shell boundary confirmed by the runtime managed-permission parser.
322+
* `chat.tools.terminal.enableAutoApprove` is `false`. The kind-only `Shell`
323+
* rule matches all shell commands in the runtime managed-permission parser.
324324
* Centralized as a single constant so the grammar lives in one place. Generic
325325
* `Tool(...)` rules are NOT supported by the runtime and must never be emitted.
326326
*/
327-
export const MANAGED_PERMISSION_TERMINAL_ASK_RULE = 'Shell(*)';
327+
export const MANAGED_PERMISSION_TERMINAL_ASK_RULE = 'Shell';
328328

329329
/**
330330
* The enterprise-policy inputs — read exclusively from
@@ -345,7 +345,7 @@ export interface IManagedPermissionPolicyInputs {
345345
* runtime-supported permission rules are emitted:
346346
*
347347
* - managed `chat.tools.global.autoApprove === false` → `disableBypassPermissionsMode: "disable"`;
348-
* - managed `chat.tools.terminal.enableAutoApprove === false` → the all-shell `ask` rule `Shell(*)`.
348+
* - managed `chat.tools.terminal.enableAutoApprove === false` → the all-shell `ask` rule `Shell`.
349349
*
350350
* Per-tool eligibility (`chat.tools.eligibleForAutoApproval`) is intentionally
351351
* NOT mapped: the runtime rejects generic `Tool(...)` rules, so there is no
@@ -388,6 +388,66 @@ export function normalizeManagedPermissions(permissions: IManagedPermissions | u
388388
return permissions && Object.keys(permissions).length > 0 ? permissions : undefined;
389389
}
390390

391+
const managedPermissionRuleFamilies = new Set(['bash', 'shell', 'powershell', 'read', 'edit', 'write', 'domain']);
392+
const managedPermissionShellRuleFamilies = new Set(['bash', 'shell', 'powershell']);
393+
394+
/**
395+
* Return parser-compatible validation issues for managed permission rules.
396+
* Provider runtimes remain authoritative for family-specific path and domain patterns.
397+
*/
398+
export function validateManagedPermissionRules(permissions: IManagedPermissions | undefined): readonly string[] {
399+
if (!permissions) {
400+
return [];
401+
}
402+
403+
const issues: string[] = [];
404+
for (const list of ['deny', 'ask', 'allow'] as const) {
405+
for (const [index, rule] of (permissions[list] ?? []).entries()) {
406+
const issue = validateManagedPermissionRule(rule);
407+
if (issue) {
408+
issues.push(`${list}.${index}: ${issue}`);
409+
}
410+
}
411+
}
412+
return issues;
413+
}
414+
415+
function validateManagedPermissionRule(rule: string): string | undefined {
416+
const openParenthesis = rule.indexOf('(');
417+
let family = rule;
418+
let argument: string | undefined;
419+
if (openParenthesis !== -1) {
420+
if (!rule.endsWith(')')) {
421+
return `Invalid rule format: ${rule}`;
422+
}
423+
family = rule.slice(0, openParenthesis);
424+
argument = rule.slice(openParenthesis + 1, -1);
425+
if (!argument || argument.includes(')')) {
426+
return `Invalid rule format: ${rule}`;
427+
}
428+
}
429+
if (!family || ![...family].every(character => /[a-zA-Z0-9_./@-]/.test(character))) {
430+
return `Invalid rule format: ${rule}`;
431+
}
432+
433+
const normalizedFamily = family.toLowerCase();
434+
if (!managedPermissionRuleFamilies.has(normalizedFamily)) {
435+
return `Unsupported managed permission rule family '${family}'; expected Bash, Shell, PowerShell, Read, Edit, Write, or Domain`;
436+
}
437+
if (!argument || !managedPermissionShellRuleFamilies.has(normalizedFamily)) {
438+
return undefined;
439+
}
440+
if (argument.endsWith(' *')) {
441+
return argument.slice(0, -2).trimEnd()
442+
? undefined
443+
: 'Invalid managed shell permission rule: wildcard requires a command prefix';
444+
}
445+
if (argument.includes('*') && !argument.endsWith(':*')) {
446+
return `Unsupported managed shell wildcard pattern '${argument}'; use '<command> *' or the canonical '<command>:*' suffix`;
447+
}
448+
return undefined;
449+
}
450+
391451
const managedPermissionsProperty = schemaProperty<IManagedPermissions>({
392452
type: 'object',
393453
title: localize('agentHost.config.managedPermissions.title', "Managed Permissions"),

src/vs/platform/agentHost/test/common/agentHostSchema.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import assert from 'assert';
77
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
88
import type { IConfigurationValue } from '../../../configuration/common/configuration.js';
9-
import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, normalizeManagedPermissions, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IManagedPermissions, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js';
9+
import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, normalizeManagedPermissions, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, validateManagedPermissionRules, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IManagedPermissions, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js';
1010
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
1111
import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js';
1212

@@ -452,7 +452,7 @@ suite('agentHostSchema', () => {
452452
assert.deepStrictEqual(deriveManagedPermissions({
453453
globalAutoApprove: undefined,
454454
terminalAutoApproveEnabled: false,
455-
}), { ask: ['Shell(*)'] } satisfies IManagedPermissions);
455+
}), { ask: ['Shell'] } satisfies IManagedPermissions);
456456
});
457457

458458
test('derived value validates against the managed-permissions root schema', () => {
@@ -461,7 +461,21 @@ suite('agentHostSchema', () => {
461461
terminalAutoApproveEnabled: false,
462462
});
463463
assert.ok(permissions);
464-
assert.strictEqual(platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, permissions), true);
464+
assert.deepStrictEqual({
465+
schema: platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, permissions),
466+
rules: validateManagedPermissionRules(permissions),
467+
}, {
468+
schema: true,
469+
rules: [],
470+
});
471+
});
472+
473+
test('reports runtime-incompatible managed shell wildcards', () => {
474+
assert.deepStrictEqual(validateManagedPermissionRules({
475+
ask: ['Shell(*)', 'Shell(git *)', 'PowerShell(Get-Item:*)'],
476+
}), [
477+
`ask.0: Unsupported managed shell wildcard pattern '*'; use '<command> *' or the canonical '<command>:*' suffix`,
478+
]);
465479
});
466480

467481
test('normalizes the root-config clear sentinel to no policy', () => {

src/vs/platform/agentHost/test/node/agentService.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -932,7 +932,7 @@ suite('AgentService (node dispatcher)', () => {
932932
await readStarted.p;
933933
svc.dispatchAction(ROOT_STATE_URI, {
934934
type: ActionType.RootConfigChanged,
935-
config: { [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell(*)'] } },
935+
config: { [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell'] } },
936936
}, clientId, 2);
937937
svc.removeClientManagedPermissions(clientId);
938938
const queueDrained = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientId === clientId && envelope.origin.clientSeq === 3));
@@ -1052,7 +1052,7 @@ suite('AgentService (node dispatcher)', () => {
10521052
type: ActionType.RootConfigChanged,
10531053
config: {
10541054
customizations: [customization],
1055-
[AgentHostManagedPermissionsConfigKey]: { ask: ['Shell(*)'] },
1055+
[AgentHostManagedPermissionsConfigKey]: { ask: ['Shell'] },
10561056
},
10571057
}, 'test-client', 1);
10581058

src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ suite('CopilotSessionLauncher shared session config', () => {
463463
return session;
464464
},
465465
};
466-
const permissions = { disableBypassPermissionsMode: 'disable', ask: ['Shell(*)'] };
466+
const permissions = { disableBypassPermissionsMode: 'disable', ask: ['Shell'] };
467467
const launcher = createTestLauncherWithRootValues({ [AgentHostManagedPermissionsConfigKey]: permissions });
468468
const basePlan = {
469469
client,

src/vs/workbench/browser/actions/developerActions.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import * as json from '../../../base/common/json.js';
5555
import { getParseErrorMessage } from '../../../base/common/jsonErrorMessages.js';
5656
import { IAgentHostService } from '../../../platform/agentHost/common/agentService.js';
5757
import { IAgentHostEnablementService } from '../../../platform/agentHost/common/agentHostEnablementService.js';
58+
import { deriveManagedPermissions, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, validateManagedPermissionRules } from '../../../platform/agentHost/common/agentHostSchema.js';
5859

5960
class InspectContextKeysAction extends Action2 {
6061

@@ -864,6 +865,7 @@ class PolicyDiagnosticsAction extends Action2 {
864865
}
865866

866867
content += '## Managed Settings\n\n';
868+
content += '*This section covers GitHub Copilot managed-settings delivery channels. Traditional VS Code policies from a configuration profile are reported under Policy-Controlled Settings and may synthesize the Agent Host client injection shown below even when no Copilot managed-settings channel is active.*\n\n';
867869
try {
868870
const policyData = defaultAccountService.policyData;
869871
const serverManagedSettings = policyData?.managedSettings ?? {};
@@ -981,11 +983,35 @@ class PolicyDiagnosticsAction extends Action2 {
981983
content += '*No managed-settings keys are supplied by any channel.*\n\n';
982984
}
983985

984-
content += '### Agent Runtime Resolution\n\n';
985-
content += '*Resolved independently by each provider through its own SDK/runtime. This may include runtime-owned keys that VS Code does not declare as configuration policies.*\n\n';
986+
content += '### Agent Host Client Injection\n\n';
987+
content += '*Synthesized by VS Code from effective managed policy values and forwarded to supporting Agent Host providers as session-local managed permissions.*\n\n';
988+
const agentHostManagedPermissions = deriveManagedPermissions({
989+
globalAutoApprove: configurationService.inspect<boolean>(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue,
990+
terminalAutoApproveEnabled: configurationService.inspect<boolean>(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue,
991+
});
992+
content += '**Synthesized managed permissions**\n\n';
993+
content += jsonBlock(agentHostManagedPermissions ?? {});
994+
content += `**Expected session runtime provenance**: ${agentHostManagedPermissions ? '`client` when no account/device policy contributes; `mixed` otherwise' : 'the account/device baseline shown below'}\n\n`;
995+
const agentHostManagedPermissionIssues = validateManagedPermissionRules(agentHostManagedPermissions);
996+
content += `**Rule validation issues (${agentHostManagedPermissionIssues.length})**\n\n`;
997+
if (agentHostManagedPermissionIssues.length > 0) {
998+
for (const issue of agentHostManagedPermissionIssues) {
999+
content += `- ${issue}\n`;
1000+
parseErrors.push({ stage: 'agentHost: client permissions', message: issue });
1001+
}
1002+
content += '\n';
1003+
} else {
1004+
content += '*None.*\n\n';
1005+
}
1006+
1007+
content += '### Agent Runtime Account and Device Baseline\n\n';
1008+
content += '*Queried from each provider when this report is generated. The SDK query covers account/server and device policy, but intentionally excludes the session-local Agent Host client injection above and may use the provider runtime\'s own policy cache. Therefore `source: none` here does not mean that synthesized client permissions are inactive; a created session reports `client` or `mixed` provenance after applying them.*\n\n';
9861009
if (!agentHostEnablementService.enabled.get()) {
9871010
content += '*Agent Host is disabled; runtime managed-settings diagnostics were not queried.*\n\n';
9881011
} else {
1012+
content += PROPERTY_VALUE_TABLE_HEADER;
1013+
content += `| Queried | ${new Date().toISOString()} |\n`;
1014+
content += '| Force refresh | Not supported by the provider runtime API |\n\n';
9891015
try {
9901016
const runtimeDiagnostics = await agentHostService.getManagedSettingsDiagnostics();
9911017
if (runtimeDiagnostics.length === 0) {

0 commit comments

Comments
 (0)