diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index a5e5545c3a647ed120e68dbee0c58b52d3b29439..8ef9946555774f8cd982851dbe036c38dd253a26 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -125,3 +125,9 @@ services:
     class: Drupal\automatic_updates\EventSubscriber\ConfigSubscriber
     tags:
       - { name: event_subscriber }
+  automatic_updates.validator.scaffold_file_permissions:
+    class: Drupal\automatic_updates\Validator\ScaffoldFilePermissionsValidator
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
diff --git a/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json b/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json
index dffe8ff002d4a3cb434d6548a41e4e5b829adad2..630abfad5d99c281bb3e726151dc8f09ee9a6a05 100644
--- a/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json
+++ b/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json
@@ -9,7 +9,12 @@
         },
         {
             "name": "drupal/core",
-            "version": "9.8.0"
+            "version": "9.8.0",
+            "extra": {
+                "drupal-scaffold": {
+                    "file-mapping": {}
+                }
+            }
         },
         {
             "name": "drupal/my_module",
diff --git a/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php b/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
index b4212ed7cc33db07f69cf480b8a84bd6e0031cce..8fd85a0a4536d049a52eedba652455f4b1883947 100644
--- a/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
+++ b/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
@@ -135,6 +135,11 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
    * @dataProvider providerSuccessfulUpdate
    */
   public function testSuccessfulUpdate(bool $maintenance_mode_on, string $project_name, string $installed_version, string $target_version): void {
+    // Disable the scaffold file permissions validator because it will try to
+    // read composer.json from the staging area, which won't exist because
+    // Package Manager is bypassed.
+    $this->disableValidators(['automatic_updates.validator.scaffold_file_permissions']);
+
     $this->updateProject = $project_name;
     $this->setReleaseMetadata(__DIR__ . '/../../../../tests/fixtures/release-history/drupal.9.8.2.xml');
     $this->setReleaseMetadata(__DIR__ . "/../../fixtures/release-history/$project_name.1.1.xml");
diff --git a/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml b/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..95dde1725d473db573d984d265982dc8484711cb
--- /dev/null
+++ b/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml
@@ -0,0 +1,2 @@
+# This file should be staged because it's scaffolded into place by Drupal core.
+services: {}
diff --git a/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php b/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d23e84006963818e10866380b9be09c6b92ba92
--- /dev/null
+++ b/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should be staged because it's scaffolded into place by Drupal core.
+ */
diff --git a/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json b/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
index ad9a32852dd505498c9c04d6fa5568b76c91993f..bc3f936e44c53595a691a438644b1b7990cfb65e 100644
--- a/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
+++ b/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
@@ -2,7 +2,15 @@
   "packages": [
     {
       "name": "drupal/core",
-      "version": "9.8.0"
+      "version": "9.8.0",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {
+            "[web-root]/sites/default/default.settings.php": "",
+            "[web-root]/sites/default/default.services.yml": ""
+          }
+        }
+      }
     }
   ]
 }
diff --git a/src/Validator/ScaffoldFilePermissionsValidator.php b/src/Validator/ScaffoldFilePermissionsValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..307ccb940cfbe6bc5d07197d09d6709ed30f4fde
--- /dev/null
+++ b/src/Validator/ScaffoldFilePermissionsValidator.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Drupal\automatic_updates\Validator;
+
+use Drupal\automatic_updates\Event\ReadinessCheckEvent;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\ComposerUtility;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\Validator\PreOperationStageValidatorInterface;
+
+/**
+ * Validates that scaffold files have appropriate permissions.
+ */
+class ScaffoldFilePermissionsValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a SiteDirectoryPermissionsValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    $paths = [];
+
+    // Figure out the absolute path of `sites/default`.
+    $site_dir = $this->pathLocator->getProjectRoot();
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $site_dir .= '/' . $web_root;
+    }
+    $site_dir .= '/sites/default';
+
+    $stage = $event->getStage();
+    $active_scaffold_files = $this->getDefaultSiteFilesFromScaffold($stage->getActiveComposer());
+
+    // If the active directory and staging area have different files scaffolded
+    // into `sites/default` (i.e., files were added, renamed, or deleted), the
+    // site directory itself must be writable for the changes to be applied.
+    if ($event instanceof PreApplyEvent) {
+      $staged_scaffold_files = $this->getDefaultSiteFilesFromScaffold($stage->getStageComposer());
+
+      if ($active_scaffold_files !== $staged_scaffold_files) {
+        $paths[] = $site_dir;
+      }
+    }
+    // The scaffolded files themselves must be writable, so that any changes to
+    // them in the staging area can be synced back to the active directory.
+    foreach ($active_scaffold_files as $scaffold_file) {
+      $paths[] = $site_dir . '/' . $scaffold_file;
+    }
+
+    // Flag messages about anything in $paths which exists, but isn't writable.
+    $non_writable_files = array_filter($paths, function (string $path): bool {
+      return file_exists($path) && !is_writable($path);
+    });
+    if ($non_writable_files) {
+      // Re-key the messages in order to prevent false negative comparisons in
+      // tests.
+      $non_writable_files = array_values($non_writable_files);
+      $event->addError($non_writable_files, $this->t('The following paths must be writable in order to update default site configuration files.'));
+    }
+  }
+
+  /**
+   * Returns the list of file names scaffolded into `sites/default`.
+   *
+   * @param \Drupal\package_manager\ComposerUtility $composer
+   *   A Composer utility helper for a directory.
+   *
+   * @return string[]
+   *   The names of files that are scaffolded into `sites/default`, stripped
+   *   of the preceding path. For example,
+   *   `[web-root]/sites/default/default.settings.php` will be
+   *   `default.settings.php`. Will be sorted alphabetically. If the target
+   *   directory doesn't have the `drupal/core` package installed, the returned
+   *   array will be empty.
+   */
+  protected function getDefaultSiteFilesFromScaffold(ComposerUtility $composer): array {
+    $installed = $composer->getInstalledPackages();
+
+    if (array_key_exists('drupal/core', $installed)) {
+      $extra = $installed['drupal/core']->getExtra();
+      // We expect Drupal core to provide a list of scaffold files.
+      $files = $extra['drupal-scaffold']['file-mapping'];
+    }
+    else {
+      $files = [];
+    }
+    $files = array_keys($files);
+    $files = preg_grep('/sites\/default\//', $files);
+    $files = array_map('basename', $files);
+    sort($files);
+
+    return $files;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => 'validateStagePreOperation',
+      PreCreateEvent::class => 'validateStagePreOperation',
+      PreApplyEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/tests/fixtures/fake-site/vendor/composer/installed.json b/tests/fixtures/fake-site/vendor/composer/installed.json
index c8ad1ba32e3c37d5c7d32c01759e4c940812fbb0..75bdd28cd5a2f88093e79285171c92a9293e50be 100644
--- a/tests/fixtures/fake-site/vendor/composer/installed.json
+++ b/tests/fixtures/fake-site/vendor/composer/installed.json
@@ -16,7 +16,12 @@
         },
         {
             "name": "drupal/core",
-            "version": "9.8.0"
+            "version": "9.8.0",
+            "extra": {
+                "drupal-scaffold": {
+                    "file-mapping": {}
+                }
+            }
         },
         {
             "name": "drupal/core-dev",
diff --git a/tests/fixtures/project_staged_validation/new_project_added/active.installed.json b/tests/fixtures/project_staged_validation/new_project_added/active.installed.json
index 56d81ccad175d109123acfc364f7240c77a897ab..1f6de85d0743b62ca29e259544fae6a9a76fcf38 100644
--- a/tests/fixtures/project_staged_validation/new_project_added/active.installed.json
+++ b/tests/fixtures/project_staged_validation/new_project_added/active.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/new_project_added/staged.installed.json b/tests/fixtures/project_staged_validation/new_project_added/staged.installed.json
index b0c0c6d63ef6dd498e15898fbdbabb92a9ee89c0..b0bdf7c1a4d42b4dafe1c012d8d1d9f6c001f8c6 100644
--- a/tests/fixtures/project_staged_validation/new_project_added/staged.installed.json
+++ b/tests/fixtures/project_staged_validation/new_project_added/staged.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/no_errors/active.installed.json b/tests/fixtures/project_staged_validation/no_errors/active.installed.json
index 65a8de94c62077e2e1d222508e1ba496bf21985a..6b93669c33273da234db2b07edcbfe077e032353 100644
--- a/tests/fixtures/project_staged_validation/no_errors/active.installed.json
+++ b/tests/fixtures/project_staged_validation/no_errors/active.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/no_errors/staged.installed.json b/tests/fixtures/project_staged_validation/no_errors/staged.installed.json
index f3955584890fdd369df5f31f3c053ed933f957b1..ee87bd5e9e0e835217bb7ca0d88faf93a539108a 100644
--- a/tests/fixtures/project_staged_validation/no_errors/staged.installed.json
+++ b/tests/fixtures/project_staged_validation/no_errors/staged.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/project_removed/active.installed.json b/tests/fixtures/project_staged_validation/project_removed/active.installed.json
index 4ec3e126c98e65c244581bef9342073c7c112bad..6e2ecb6b9f0a51323cbee638f09f0bea61291ed2 100644
--- a/tests/fixtures/project_staged_validation/project_removed/active.installed.json
+++ b/tests/fixtures/project_staged_validation/project_removed/active.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_theme",
diff --git a/tests/fixtures/project_staged_validation/project_removed/staged.installed.json b/tests/fixtures/project_staged_validation/project_removed/staged.installed.json
index 100c033c343aa2a5f9ee23433fe14f14249f3cd1..c44eb0104d91d63b28d8f5305429e3572ae7963c 100644
--- a/tests/fixtures/project_staged_validation/project_removed/staged.installed.json
+++ b/tests/fixtures/project_staged_validation/project_removed/staged.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module2",
diff --git a/tests/fixtures/project_staged_validation/version_changed/active.installed.json b/tests/fixtures/project_staged_validation/version_changed/active.installed.json
index e7bb050139058310bfaaa0e0966bfc3f82b2eff7..cb7c3ca5eff5096a08d64ce17d008d8ee4fb3ed8 100644
--- a/tests/fixtures/project_staged_validation/version_changed/active.installed.json
+++ b/tests/fixtures/project_staged_validation/version_changed/active.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/fixtures/project_staged_validation/version_changed/staged.installed.json b/tests/fixtures/project_staged_validation/version_changed/staged.installed.json
index 62a4b1dc724a18e77b05a36fec5a84b3366edf57..e457476acea77c727e783a19849526418f83865b 100644
--- a/tests/fixtures/project_staged_validation/version_changed/staged.installed.json
+++ b/tests/fixtures/project_staged_validation/version_changed/staged.installed.json
@@ -8,7 +8,12 @@
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/tests/src/Functional/ReadinessValidationTest.php b/tests/src/Functional/ReadinessValidationTest.php
index 96eab4ae9707c9cb6e711054da901b97b948dbb6..942d28f22be0b87c488879446a026900df4b5cd1 100644
--- a/tests/src/Functional/ReadinessValidationTest.php
+++ b/tests/src/Functional/ReadinessValidationTest.php
@@ -402,10 +402,13 @@ class ReadinessValidationTest extends AutomaticUpdatesFunctionalTestBase {
       'package_manager_test_fixture',
     ]);
     // Because all actual staging operations are bypassed by
-    // package_manager_bypass (enabled by the parent class), disable this
-    // validator because it will complain if there's no actual Composer data to
-    // inspect.
-    $this->disableValidators(['automatic_updates.staged_projects_validator']);
+    // package_manager_bypass (enabled by the parent class), disable these
+    // validators because they will complain if there's no actual Composer data
+    // to inspect.
+    $this->disableValidators([
+      'automatic_updates.staged_projects_validator',
+      'automatic_updates.validator.scaffold_file_permissions',
+    ]);
 
     // The error should be persistently visible, even after the checker stops
     // flagging it.
diff --git a/tests/src/Functional/UpdaterFormTest.php b/tests/src/Functional/UpdaterFormTest.php
index f73f37443f7df5f94ce98a94a609ca0da3585ac1..7bbb2254bc78d82a1903a394e893ad8cf634471d 100644
--- a/tests/src/Functional/UpdaterFormTest.php
+++ b/tests/src/Functional/UpdaterFormTest.php
@@ -43,9 +43,10 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
    */
   protected function setUp(): void {
     // In this test class, all actual staging operations are bypassed by
-    // package_manager_bypass, which means this validator will complain because
-    // there is no actual Composer data for it to inspect.
+    // package_manager_bypass, which means these validators will complain
+    // because there is no actual Composer data for them to inspect.
     $this->disableValidators[] = 'automatic_updates.staged_projects_validator';
+    $this->disableValidators[] = 'automatic_updates.validator.scaffold_file_permissions';
 
     parent::setUp();
 
diff --git a/tests/src/Kernel/CronUpdaterTest.php b/tests/src/Kernel/CronUpdaterTest.php
index 2e70847acd404fcc6805c5d0a3d0557bc56cb08d..93edcf19b7d28af88e5a94d89f55f2322d4414c3 100644
--- a/tests/src/Kernel/CronUpdaterTest.php
+++ b/tests/src/Kernel/CronUpdaterTest.php
@@ -57,6 +57,7 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase {
     // they attempt to compare the active and stage directories.
     $this->disableValidators[] = 'automatic_updates.validator.staged_database_updates';
     $this->disableValidators[] = 'automatic_updates.staged_projects_validator';
+    $this->disableValidators[] = 'automatic_updates.validator.scaffold_file_permissions';
     parent::setUp();
 
     $this->logger = new TestLogger();
diff --git a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
index 4094b1f2de00d6ab1cb8a351bcb0d525b3e26344..8976c67436b8c730c4778e7a1e44fa23acdec14b 100644
--- a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
+++ b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
@@ -238,11 +238,16 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase {
     // results should be stored.
     $this->assertValidationResultsEqual($results, $manager->getResults());
 
-    // Don't validate staged projects because actual staging operations are
-    // bypassed by package_manager_bypass, which will make this validator
-    // complain that there is no actual Composer data for it to inspect.
-    $validator = $this->container->get('automatic_updates.staged_projects_validator');
-    $this->container->get('event_dispatcher')->removeSubscriber($validator);
+    // Don't validate staged projects or scaffold file permissions because
+    // actual staging operations are bypassed by package_manager_bypass, which
+    // will make these validators complain that there is no actual Composer data
+    // for them to inspect.
+    $validators = array_map([$this->container, 'get'], [
+      'automatic_updates.staged_projects_validator',
+      'automatic_updates.validator.scaffold_file_permissions',
+    ]);
+    $event_dispatcher = $this->container->get('event_dispatcher');
+    array_walk($validators, [$event_dispatcher, 'removeSubscriber']);
 
     /** @var \Drupal\automatic_updates\Updater $updater */
     $updater = $this->container->get('automatic_updates.updater');
diff --git a/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d77ce05b3a96c0a51bec502d3f1cb0db2580586f
--- /dev/null
+++ b/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php
@@ -0,0 +1,346 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation;
+
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
+
+/**
+ * @covers \Drupal\automatic_updates\Validator\ScaffoldFilePermissionsValidator
+ *
+ * @group automatic_updates
+ */
+class ScaffoldFilePermissionsValidatorTest extends AutomaticUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['automatic_updates'];
+
+  /**
+   * The active directory of the virtual project.
+   *
+   * @var string
+   */
+  private $activeDir;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->createTestProject();
+    $this->activeDir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertValidationResultsEqual(array $expected_results, array $actual_results): void {
+    $map = function (string $path): string {
+      return $this->activeDir . '/' . $path;
+    };
+    foreach ($expected_results as $i => $result) {
+      // Prepend the active directory to every path listed in the error result,
+      // and add the expected summary.
+      $messages = array_map($map, $result->getMessages());
+      $expected_results[$i] = ValidationResult::createError($messages, t('The following paths must be writable in order to update default site configuration files.'));
+    }
+    parent::assertValidationResultsEqual($expected_results, $actual_results);
+  }
+
+  /**
+   * Write-protects a set of paths in the active directory.
+   *
+   * @param string[] $paths
+   *   The paths to write-protect, relative to the active directory.
+   */
+  private function writeProtect(array $paths): void {
+    foreach ($paths as $path) {
+      $path = $this->activeDir . '/' . $path;
+      chmod($path, 0400);
+      $this->assertFileIsNotWritable($path, "Failed to write-protect $path.");
+    }
+  }
+
+  /**
+   * Data provider for ::testPermissionsBeforeStart().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerPermissionsBeforeStart(): array {
+    return [
+      'write-protected scaffold file, writable site directory' => [
+        ['sites/default/default.settings.php'],
+        [
+          ValidationResult::createError(['sites/default/default.settings.php']),
+        ],
+      ],
+      // Whether the site directory is write-protected only matters during
+      // pre-apply, because it only presents a problem if scaffold files have
+      // been added or removed in the staging area. Which is a condition we can
+      // only detect during pre-apply.
+      'write-protected scaffold file and site directory' => [
+        [
+          'sites/default/default.settings.php',
+          'sites/default',
+        ],
+        [
+          ValidationResult::createError(['sites/default/default.settings.php']),
+        ],
+      ],
+      'write-protected site directory' => [
+        ['sites/default'],
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that scaffold file permissions are checked before an update begins.
+   *
+   * @param string[] $write_protected_paths
+   *   A list of paths, relative to the project root, which should be write
+   *   protected before staged changes are applied.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results, if any.
+   *
+   * @dataProvider providerPermissionsBeforeStart
+   */
+  public function testPermissionsBeforeStart(array $write_protected_paths, array $expected_results): void {
+    $this->writeProtect($write_protected_paths);
+    $this->assertCheckerResultsFromManager($expected_results, TRUE);
+
+    try {
+      $this->container->get('automatic_updates.updater')
+        ->begin(['drupal' => '9.8.1']);
+
+      // If no exception was thrown, ensure that we weren't expecting an error.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+  /**
+   * Data provider for ::testScaffoldFilesChanged().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerScaffoldFilesChanged(): array {
+    // The summary is always replaced by ::assertValidationResultsEqual(), so
+    // if there's more than one message in a result, just give it a mocked
+    // summary object to prevent an exception.
+    $summary = $this->prophesize('\Drupal\Core\StringTranslation\TranslatableMarkup')
+      ->reveal();
+
+    return [
+      // If no scaffold files are changed, it doesn't matter if the site
+      // directory is writable.
+      'no scaffold changes, site directory not writable' => [
+        ['sites/default'],
+        [],
+        [],
+        [],
+      ],
+      'no scaffold changes, site directory writable' => [
+        [],
+        [],
+        [],
+        [],
+      ],
+      // If scaffold files are added or deleted in the site directory, the site
+      // directory must be writable.
+      'new scaffold file added to non-writable site directory' => [
+        ['sites/default'],
+        [],
+        [
+          '[web-root]/sites/default/new.txt' => '',
+        ],
+        [
+          ValidationResult::createError(['sites/default']),
+        ],
+      ],
+      'new scaffold file added to writable site directory' => [
+        [],
+        [],
+        [
+          '[web-root]/sites/default/new.txt' => '',
+        ],
+        [],
+      ],
+      'writable scaffold file removed from non-writable site directory' => [
+        ['sites/default'],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default']),
+        ],
+      ],
+      'writable scaffold file removed from writable site directory' => [
+        [],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed from non-writable site directory' => [
+        [
+          // The file must be made write-protected before the site directory is,
+          // or the permissions change will fail.
+          'sites/default/deleted.txt',
+          'sites/default',
+        ],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default', 'sites/default/deleted.txt'], $summary),
+        ],
+      ],
+      'non-writable scaffold file removed from writable site directory' => [
+        ['sites/default/deleted.txt'],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default/deleted.txt']),
+        ],
+      ],
+      // If only scaffold files outside the site directory changed, the
+      // validator doesn't care if the site directory is writable.
+      'new scaffold file added outside non-writable site directory' => [
+        ['sites/default'],
+        [],
+        [
+          '[web-root]/foo.html' => '',
+        ],
+        [],
+      ],
+      'new scaffold file added outside writable site directory' => [
+        [],
+        [],
+        [
+          '[web-root]/foo.html' => '',
+        ],
+        [],
+      ],
+      'writable scaffold file removed outside non-writable site directory' => [
+        ['sites/default'],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'writable scaffold file removed outside writable site directory' => [
+        [],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed outside non-writable site directory' => [
+        [
+          'sites/default',
+          'foo.txt',
+        ],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed outside writable site directory' => [
+        ['foo.txt'],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests site directory permissions are checked before changes are applied.
+   *
+   * @param string[] $write_protected_paths
+   *   A list of paths, relative to the project root, which should be write
+   *   protected before staged changes are applied.
+   * @param string[] $active_scaffold_files
+   *   An array simulating the extra.drupal-scaffold.file-mapping section of the
+   *   active drupal/core package.
+   * @param string[] $staged_scaffold_files
+   *   An array simulating the extra.drupal-scaffold.file-mapping section of the
+   *   staged drupal/core package.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results, if any.
+   *
+   * @dataProvider providerScaffoldFilesChanged
+   */
+  public function testScaffoldFilesChanged(array $write_protected_paths, array $active_scaffold_files, array $staged_scaffold_files, array $expected_results): void {
+    // Create fake scaffold files so we can test scenarios in which a scaffold
+    // file that exists in the active directory is deleted in the staging area.
+    touch($this->activeDir . '/sites/default/deleted.txt');
+    touch($this->activeDir . '/foo.txt');
+
+    // Simulate updating Drupal core. This will copy the active directory into
+    // the (virtual) staging area.
+    // @see ::createTestProject()
+    // @see \Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager::copyFilesFromFixture()
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
+    // Rewrite the active and staged installed.json files, inserting the given
+    // lists of scaffold files.
+    $installed = [
+      'packages' => [
+        [
+          'name' => 'drupal/core',
+          'version' => \Drupal::VERSION,
+          'extra' => [
+            'drupal-scaffold' => [
+              'file_mapping' => [],
+            ],
+          ],
+        ],
+      ],
+    ];
+    // Since the list of scaffold files is in a deeply nested array, reference
+    // it for readability.
+    $scaffold_files = &$installed['packages'][0]['extra']['drupal-scaffold']['file-mapping'];
+
+    // Change the list of scaffold files in the active and stage directories.
+    $scaffold_files = $active_scaffold_files;
+    file_put_contents($this->activeDir . '/vendor/composer/installed.json', json_encode($installed));
+    $scaffold_files = $staged_scaffold_files;
+    file_put_contents($updater->getStageDirectory() . '/vendor/composer/installed.json', json_encode($installed));
+
+    $this->writeProtect($write_protected_paths);
+
+    try {
+      $updater->apply();
+
+      // If no exception was thrown, ensure that we weren't expecting an error.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
index f393369997faddf7efe188a8050bc10bb5229768..7268428389adc3d098fe0856c4327c43617269d6 100644
--- a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
+++ b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
@@ -62,14 +62,22 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
    * Tests that exceptions are turned into validation errors.
    */
   public function testEventConsumesExceptionResults(): void {
+    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher */
+    $event_dispatcher = $this->container->get('event_dispatcher');
+
     // Just before the staged changes are applied, delete the composer.json file
     // to trigger an error. This uses the highest possible priority to guarantee
     // it runs before any other subscribers.
     $listener = function (): void {
       unlink("$this->activeDir/composer.json");
     };
-    $this->container->get('event_dispatcher')
-      ->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
+    $event_dispatcher->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
+
+    // Disable the scaffold file permissions validator because it will try to
+    // read composer.json from the active directory, which won't exist thanks to
+    // the event listener we just added.
+    $validator = $this->container->get('automatic_updates.validator.scaffold_file_permissions');
+    $event_dispatcher->removeSubscriber($validator);
 
     $result = ValidationResult::createError([
       "Composer could not find the config file: $this->activeDir/composer.json\n",