feat(extensions): implement cryptographic integrity verification for extension updates (#21772)

This commit is contained in:
Emily Hedlund
2026-03-16 15:01:52 -04:00
committed by GitHub
parent d43ec6c8f3
commit 05fda0cf01
18 changed files with 1271 additions and 103 deletions
+40 -12
View File
@@ -41,6 +41,9 @@ import {
loadSkillsFromDir,
loadAgentsFromDirectory,
homedir,
ExtensionIntegrityManager,
type IExtensionIntegrity,
type IntegrityDataStatus,
type ExtensionEvents,
type MCPServerConfig,
type ExtensionInstallMetadata,
@@ -89,6 +92,7 @@ interface ExtensionManagerParams {
workspaceDir: string;
eventEmitter?: EventEmitter<ExtensionEvents>;
clientVersion?: string;
integrityManager?: IExtensionIntegrity;
}
/**
@@ -98,6 +102,7 @@ interface ExtensionManagerParams {
*/
export class ExtensionManager extends ExtensionLoader {
private extensionEnablementManager: ExtensionEnablementManager;
private integrityManager: IExtensionIntegrity;
private settings: MergedSettings;
private requestConsent: (consent: string) => Promise<boolean>;
private requestSetting:
@@ -127,12 +132,28 @@ export class ExtensionManager extends ExtensionLoader {
});
this.requestConsent = options.requestConsent;
this.requestSetting = options.requestSetting ?? undefined;
this.integrityManager =
options.integrityManager ?? new ExtensionIntegrityManager();
}
getEnablementManager(): ExtensionEnablementManager {
return this.extensionEnablementManager;
}
async verifyExtensionIntegrity(
extensionName: string,
metadata: ExtensionInstallMetadata | undefined,
): Promise<IntegrityDataStatus> {
return this.integrityManager.verify(extensionName, metadata);
}
async storeExtensionIntegrity(
extensionName: string,
metadata: ExtensionInstallMetadata,
): Promise<void> {
return this.integrityManager.store(extensionName, metadata);
}
setRequestConsent(
requestConsent: (consent: string) => Promise<boolean>,
): void {
@@ -159,10 +180,7 @@ export class ExtensionManager extends ExtensionLoader {
previousExtensionConfig?: ExtensionConfig,
requestConsentOverride?: (consent: string) => Promise<boolean>,
): Promise<GeminiCLIExtension> {
if (
this.settings.security?.allowedExtensions &&
this.settings.security?.allowedExtensions.length > 0
) {
if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) {
const extensionAllowed = this.settings.security?.allowedExtensions.some(
(pattern) => {
try {
@@ -421,6 +439,12 @@ Would you like to attempt to install via "git clone" instead?`,
);
await fs.promises.writeFile(metadataPath, metadataString);
// Establish trust at point of installation
await this.storeExtensionIntegrity(
newExtensionConfig.name,
installMetadata,
);
// TODO: Gracefully handle this call failing, we should back up the old
// extension prior to overwriting it and then restore and restart it.
extension = await this.loadExtension(destinationPath);
@@ -693,10 +717,7 @@ Would you like to attempt to install via "git clone" instead?`,
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (
this.settings.security?.allowedExtensions &&
this.settings.security?.allowedExtensions.length > 0
) {
if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) {
if (!installMetadata?.source) {
throw new Error(
`Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`,
@@ -961,11 +982,18 @@ Would you like to attempt to install via "git clone" instead?`,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
const extName = path.basename(extensionDir);
debugLogger.warn(
`Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`,
);
try {
await fs.promises.rm(extensionDir, { recursive: true, force: true });
} catch (rmError) {
debugLogger.error(
`Failed to remove broken extension directory ${extensionDir}:`,
rmError,
);
}
return null;
}
}