Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5a69e36
Implement account policy gate for AI features
joshspicer Apr 20, 2026
2df0499
Refactor account policy gate logic to focus on approved organizations…
joshspicer Apr 20, 2026
3020b07
Add Account Policy Gate service and integrate with existing policy se…
joshspicer Apr 20, 2026
34d5ae0
Add account policy gate information to PolicyDiagnosticsAction
joshspicer Apr 20, 2026
a0e35b9
Fix CI: layer violation, ESLint, i18n entry, policyData export
joshspicer Apr 20, 2026
d5aa2a8
Add account policy settings for approved organizations and AI feature…
joshspicer Apr 20, 2026
44f1ffc
Switch ChatApprovedAccountOrganizations to type:'array'
joshspicer Apr 21, 2026
1893a38
Don't restrict policies during policyNotResolved boot window
joshspicer Apr 21, 2026
f319f3d
Add Contact Administrator and Learn More links to gate notification
joshspicer Apr 21, 2026
8b5874f
Show approved organizations in gate notification
joshspicer Apr 21, 2026
a6f0592
Move 'contact your administrator' from button to message text
joshspicer Apr 21, 2026
fa5b09b
Fix: check org membership before policyData resolution
joshspicer Apr 21, 2026
5a6191a
Directly set chatSetupHidden context key when gate is restricted
joshspicer Apr 21, 2026
a8e6299
Use IChatEntitlementService.setForceHidden to hide chat when gate res…
joshspicer Apr 21, 2026
4f347c7
Fix setForceHidden fallback when no ChatEntitlementContext
joshspicer Apr 21, 2026
f10da06
Add trace logging to AccountPolicyGateContribution
joshspicer Apr 21, 2026
4b4d6ce
Gate chat view on accountPolicyGateActive context key
joshspicer Apr 21, 2026
34a4e2b
Re-show notification on account swap, include account name and org list
joshspicer Apr 21, 2026
1714190
Generalize sessions blocked overlay for account policy gate
joshspicer Apr 21, 2026
c242401
Fix notification formatting: use inline comma-separated org list
joshspicer Apr 21, 2026
138d9df
Fix sessions overlay: remove workbench notification, handle gate nati…
joshspicer Apr 21, 2026
3ae9af2
Defer notification until account service has settled
joshspicer Apr 21, 2026
b931915
Fix gate stuck on noAccount: re-evaluate after account init barrier
joshspicer Apr 21, 2026
07b097b
Add 'Sign into an approved GitHub account' to notification messages
joshspicer Apr 22, 2026
7a785e4
Regenerate policyData.jsonc to match array type for ChatApprovedAccou…
joshspicer Apr 22, 2026
83a6f0e
Remove ChatDisableAIFeatures policy registration
joshspicer Apr 22, 2026
3f6cb7e
Address code review: fix duplicate IPC, remove unused import
joshspicer Apr 22, 2026
f3659c5
Remove JSDoc from currentDefaultAccount interface addition
joshspicer Apr 22, 2026
339ee47
Revert sessions overlay will revisit approachchanges
joshspicer Apr 22, 2026
7668724
Restore sessions overlay with loading state for transient restrictions
joshspicer Apr 23, 2026
2b24e73
Don't show overlay for noAccount/ let welcome screen handle sign-inwr…
joshspicer Apr 23, 2026
f6f1c2b
Remove 'Open VS Code' button from account policy gate overlay
joshspicer Apr 23, 2026
cf5e68f
Fix: don't show 'Agents Disabled' when gate is forcing restrictedValue
joshspicer Apr 23, 2026
0a09aa1
Defer all stable gate-blocked states to welcome screen
joshspicer Apr 23, 2026
3919612
Show AccountPolicyGate overlay for orgNotApproved only
joshspicer Apr 23, 2026
3acd8c5
Fix boot-race test to match managed policy reader pattern
joshspicer Apr 23, 2026
5e5e796
Address PR review feedback
joshspicer Apr 23, 2026
c153525
Polish ChatApprovedAccountOrganizations policy description
joshspicer Apr 23, 2026
bd6d42b
Trim self-explanatory comments
joshspicer Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/skills/add-policy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,7 @@ See `chat.tools.global.autoApprove` and `chat.useHooks` in `src/vs/workbench/con
## Examples

Search the codebase for `policy:` to find all the examples of different policy configurations.

## Learnings

* Never hand-edit `build/lib/policies/policyData.jsonc` (its header explicitly forbids it). If `npm run export-policy-data` is failing, fix the script — don't patch the JSON. Common cause: running it in the wrong working directory (e.g. main repo instead of a worktree), which silently exports the wrong source tree.
4 changes: 4 additions & 0 deletions build/lib/i18n.resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@
"name": "vs/workbench/services/language",
"project": "vscode-workbench"
},
{
"name": "vs/workbench/services/policies",
"project": "vscode-workbench"
},
{
"name": "vs/workbench/services/progress",
"project": "vscode-workbench"
Expand Down
15 changes: 15 additions & 0 deletions build/lib/policies/policyData.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@
"default": "",
"included": false
},
{
"key": "chat.approvedAccountOrganizations",
"name": "ChatApprovedAccountOrganizations",
"category": "InteractiveSession",
"minimumVersion": "1.118",
"localization": {
"description": {
"key": "chat.approvedAccountOrganizations.policy.description",
"value": "Setting this policy to a non-empty list activates the Approved Account gate: all AI features are disabled until the user signs into a GitHub account whose organizations intersect this list AND the account-side policy data has resolved. Comparison is case-insensitive. Use '*' as a wildcard to accept any signed-in GitHub or GHE account (use this for GHE deployments where the organization list is not surfaced)."
}
},
"type": "array",
"default": [],
"included": false
},
{
"key": "extensions.allowed",
"name": "AllowedExtensions",
Expand Down
12 changes: 12 additions & 0 deletions src/vs/base/common/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,16 @@ export interface IPolicy {
* If `undefined`, the feature's setting is not locked and can be overridden by other means.
*/
readonly value?: (policyData: IPolicyData) => string | number | boolean | undefined;

/**
* The most-restrictive value that should be applied when the user is subject to the
* "Require Approved Account" gate but the gate is not yet satisfied (i.e. no approved
* GitHub account is signed in or the account-side policy data has not yet resolved).
*
* If omitted, the gate falls back to a type-driven safe default
* (`false` for boolean, `0` for number, `''` for string).
*
* Only consulted while the gate is active and unsatisfied; ignored otherwise.
*/
readonly restrictedValue?: string | number | boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel<T>(
onDidChangeDefaultAccount: Event.None,
onDidChangePolicyData: Event.None,
policyData: null,
currentDefaultAccount: null,
copilotTokenInfo: null,
onDidChangeCopilotTokenInfo: Event.None,
getDefaultAccount: async () => null,
Expand Down
1 change: 1 addition & 0 deletions src/vs/editor/standalone/browser/standaloneServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,7 @@ class StandaloneDefaultAccountService implements IDefaultAccountService {
readonly onDidChangeDefaultAccount: Event<IDefaultAccount | null> = Event.None;
readonly onDidChangePolicyData: Event<IPolicyData | null> = Event.None;
readonly policyData: IPolicyData | null = null;
readonly currentDefaultAccount: IDefaultAccount | null = null;
readonly copilotTokenInfo = null;
readonly onDidChangeCopilotTokenInfo: Event<null> = Event.None;

Expand Down
3 changes: 2 additions & 1 deletion src/vs/platform/configuration/common/configurations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,12 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat
this.logService.warn(`Policy ${config.policy.name} has unsupported type ${config.type}`);
continue;
}
const { value } = config.policy;
const { value, restrictedValue } = config.policy;
keys.push(key);
policyDefinitions[config.policy.name] = {
type: config.type === 'number' ? 'number' : config.type === 'boolean' ? 'boolean' : 'string',
value,
restrictedValue,
};
}
}
Expand Down
1 change: 1 addition & 0 deletions src/vs/platform/defaultAccount/common/defaultAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface IDefaultAccountService {
readonly onDidChangeDefaultAccount: Event<IDefaultAccount | null>;
readonly onDidChangePolicyData: Event<IPolicyData | null>;
readonly policyData: IPolicyData | null;
readonly currentDefaultAccount: IDefaultAccount | null;
readonly copilotTokenInfo: ICopilotTokenInfo | null;
readonly onDidChangeCopilotTokenInfo: Event<ICopilotTokenInfo | null>;
getDefaultAccount(): Promise<IDefaultAccount | null>;
Expand Down
17 changes: 17 additions & 0 deletions src/vs/platform/policy/common/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,25 @@ export type PolicyValue = string | number | boolean;
export type PolicyDefinition = {
type: 'string' | 'number' | 'boolean';
value?: (policyData: IPolicyData) => string | number | boolean | undefined;
restrictedValue?: PolicyValue;
};

/**
* Returns the value to apply for `definition` when the account-policy gate is active
* but not satisfied. Uses `definition.restrictedValue` when specified, otherwise falls
* back to a type-driven safe default.
*/
export function getRestrictedPolicyValue(definition: PolicyDefinition): PolicyValue {
if (definition.restrictedValue !== undefined) {
return definition.restrictedValue;
}
switch (definition.type) {
case 'boolean': return false;
case 'number': return 0;
case 'string': return '';
}
}

export const IPolicyService = createDecorator<IPolicyService>('policy');

export interface IPolicyService {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,58 @@
text-decoration: underline;
}

/* Progress bar for transient loading state */
.sessions-policy-blocked-card .sessions-policy-blocked-progress-bar {
width: 100%;
height: 3px;
background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
border-radius: 2px;
overflow: hidden;
margin-top: 16px;
}

.sessions-policy-blocked-card .sessions-policy-blocked-progress-bar-fill {
width: 30%;
height: 100%;
background: var(--vscode-progressBar-background, #0078d4);
border-radius: 2px;
animation: sessions-policy-blocked-progress 2s ease-in-out infinite;
}

@keyframes sessions-policy-blocked-progress {
0% { transform: translateX(0%); }
50% { transform: translateX(233%); }
100% { transform: translateX(0%); }
}

/* Approved organizations list */
.sessions-policy-blocked-card .sessions-policy-blocked-orgs {
text-align: center;
}

.sessions-policy-blocked-card .sessions-policy-blocked-orgs-label {
margin: 0 0 4px 0;
font-size: 12px;
font-weight: 600;
color: var(--vscode-foreground);
}

.sessions-policy-blocked-card .sessions-policy-blocked-orgs ul {
margin: 0;
padding: 0;
list-style: none;
}

.sessions-policy-blocked-card .sessions-policy-blocked-orgs li {
font-size: 12px;
color: var(--vscode-descriptionForeground);
line-height: 1.6;
}

.sessions-policy-blocked-card .sessions-policy-blocked-footer {
font-size: 12px;
}

.sessions-policy-blocked-card .monaco-button {
margin-top: 4px;
width: auto;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase
import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';
import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js';
import { SessionsPolicyBlockedOverlay } from './sessionsPolicyBlocked.js';
import { ISessionsBlockedOverlayOptions, SessionsBlockedReason, SessionsPolicyBlockedOverlay } from './sessionsPolicyBlocked.js';
import { AccountPolicyGateState, AccountPolicyGateUnsatisfiedReason, IAccountPolicyGateService } from '../../../../workbench/services/policies/common/accountPolicyService.js';

export class SessionsPolicyBlockedContribution extends Disposable implements IWorkbenchContribution {

static readonly ID = 'workbench.contrib.sessionsPolicyBlocked';

private readonly overlayRef = this._register(new MutableDisposable());
private currentReason: SessionsBlockedReason | undefined;

constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAccountPolicyGateService private readonly gateService: IAccountPolicyGateService,
@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,
) {
super();

Expand All @@ -31,21 +36,64 @@ export class SessionsPolicyBlockedContribution extends Disposable implements IWo
this.update();
}
}));

this._register(this.gateService.onDidChangeGateInfo(() => this.update()));
}

private update(): void {
const enabled = this.configurationService.getValue<boolean>(ChatConfiguration.AgentEnabled);

if (enabled === false) {
if (!this.overlayRef.value) {
this.overlayRef.value = this.instantiationService.createInstance(
SessionsPolicyBlockedOverlay,
this.layoutService.mainContainer,
);
const gateInfo = this.gateService.gateInfo;

// The gate forces chat.agent.enabled = false via restrictedValue when stably
// Restricted. Suppress AgentDisabled in that case so users see the gate-specific
// overlay (or the welcome screen for noAccount/wrongProvider) instead.
const gateForcesAgentDisabled = gateInfo.state === AccountPolicyGateState.Restricted
&& gateInfo.reason !== AccountPolicyGateUnsatisfiedReason.PolicyNotResolved;

const agentEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.AgentEnabled);
if (agentEnabled === false && !gateForcesAgentDisabled) {
this.showOverlay({ reason: SessionsBlockedReason.AgentDisabled });
return;
}

if (gateInfo.state === AccountPolicyGateState.Restricted) {
// Defer to the sessions welcome/walkthrough so the user signs in via the standard flow.
if (gateInfo.reason === AccountPolicyGateUnsatisfiedReason.NoAccount
|| gateInfo.reason === AccountPolicyGateUnsatisfiedReason.WrongProvider) {
this.overlayRef.clear();
this.currentReason = undefined;
return;
}

if (gateInfo.reason === AccountPolicyGateUnsatisfiedReason.PolicyNotResolved) {
this.showOverlay({ reason: SessionsBlockedReason.Loading });
} else {
const accountName = this.defaultAccountService.currentDefaultAccount?.accountName;
this.showOverlay({
reason: SessionsBlockedReason.AccountPolicyGate,
approvedOrganizations: gateInfo.approvedOrganizations,
accountName,
});
}
} else {
this.overlayRef.clear();
return;
}

this.overlayRef.clear();
this.currentReason = undefined;
}

private showOverlay(options: ISessionsBlockedOverlayOptions): void {
// AccountPolicyGate may need re-render when the account name changes.
if (this.currentReason === options.reason && options.reason !== SessionsBlockedReason.AccountPolicyGate) {
return;
}
this.overlayRef.clear();
this.currentReason = options.reason;

this.overlayRef.value = this.instantiationService.createInstance(
SessionsPolicyBlockedOverlay,
this.layoutService.mainContainer,
options,
);
}
}

Expand Down
Loading
Loading