[6.1.0] Flag for writable outputs (experimental) (#17617)

This feature is tied to an experimental flag `--experimental_writable_outputs`. When enabled, Bazel will set the permissions of all output files to 0755 instead of 0555.

RELNOTES: None.
PiperOrigin-RevId: 500786227
Change-Id: I59e15f3fec09c40a052a60b00da209547f10d7fc

Co-authored-by: Googler <cparsons@google.com>
Co-authored-by: kshyanashree <109167932+kshyanashree@users.noreply.github.com>
Co-authored-by: keertk <110264242+keertk@users.noreply.github.com>
diff --git a/src/main/java/com/google/devtools/build/lib/actions/ActionCacheChecker.java b/src/main/java/com/google/devtools/build/lib/actions/ActionCacheChecker.java
index 0612e51..a053d76 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/ActionCacheChecker.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/ActionCacheChecker.java
@@ -41,6 +41,7 @@
 import com.google.devtools.build.lib.events.EventKind;
 import com.google.devtools.build.lib.skyframe.TreeArtifactValue;
 import com.google.devtools.build.lib.skyframe.TreeArtifactValue.ArchivedRepresentation;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import java.io.FileNotFoundException;
@@ -291,9 +292,10 @@
     Map<String, String> usedClientEnv = computeUsedClientEnv(action, clientEnv);
     Map<String, String> usedExecProperties =
         computeUsedExecProperties(action, remoteDefaultPlatformProperties);
-    // Combining the Client environment with the Remote Default Execution Properties, because
-    // the Miss Reason is not used currently by Bazel, therefore there is no need to distinguish
-    // between these two cases. This also saves memory used for the Action Cache.
+    // Combining the Client environment with the Remote Default Execution Properties and Output
+    // Permissions, because the Miss Reason is not used currently by Bazel, therefore there is no
+    // need to distinguish between these property types. This also saves memory used for the Action
+    // Cache.
     Map<String, String> usedEnvironment = new HashMap<>();
     usedEnvironment.putAll(usedClientEnv);
     usedEnvironment.putAll(usedExecProperties);
@@ -419,6 +421,7 @@
       Action action,
       List<Artifact> resolvedCacheArtifacts,
       Map<String, String> clientEnv,
+      OutputPermissions outputPermissions,
       EventHandler handler,
       MetadataHandler metadataHandler,
       ArtifactExpander artifactExpander,
@@ -481,6 +484,7 @@
         artifactExpander,
         actionInputs,
         clientEnv,
+        outputPermissions,
         remoteDefaultPlatformProperties,
         cachedOutputMetadata)) {
       if (entry != null) {
@@ -510,6 +514,7 @@
       ArtifactExpander artifactExpander,
       NestedSet<Artifact> actionInputs,
       Map<String, String> clientEnv,
+      OutputPermissions outputPermissions,
       Map<String, String> remoteDefaultPlatformProperties,
       @Nullable CachedOutputMetadata cachedOutputMetadata)
       throws InterruptedException {
@@ -542,7 +547,7 @@
     }
     Map<String, String> usedEnvironment =
         computeUsedEnv(action, clientEnv, remoteDefaultPlatformProperties);
-    if (!entry.usedSameClientEnv(usedEnvironment)) {
+    if (!entry.sameActionProperties(usedEnvironment, outputPermissions)) {
       reportClientEnv(handler, action, usedEnvironment);
       actionCache.accountMiss(MissReason.DIFFERENT_ENVIRONMENT);
       return true;
@@ -580,6 +585,7 @@
       MetadataHandler metadataHandler,
       ArtifactExpander artifactExpander,
       Map<String, String> clientEnv,
+      OutputPermissions outputPermissions,
       Map<String, String> remoteDefaultPlatformProperties)
       throws IOException, InterruptedException {
     checkState(cacheConfig.enabled(), "cache unexpectedly disabled, action: %s", action);
@@ -595,7 +601,8 @@
         new ActionCache.Entry(
             action.getKey(actionKeyContext, artifactExpander),
             usedEnvironment,
-            action.discoversInputs());
+            action.discoversInputs(),
+            outputPermissions);
     for (Artifact output : action.getOutputs()) {
       // Remove old records from the cache if they used different key.
       String execPath = output.getExecPathString();
@@ -746,7 +753,7 @@
       // Compute the aggregated middleman digest.
       // Since we never validate action key for middlemen, we should not store
       // it in the cache entry and just use empty string instead.
-      entry = new ActionCache.Entry("", ImmutableMap.of(), false);
+      entry = new ActionCache.Entry("", ImmutableMap.of(), false, OutputPermissions.READONLY);
       for (Artifact input : action.getInputs().toList()) {
         entry.addInputFile(
             input.getExecPath(), getMetadataMaybe(metadataHandler, input), /*saveExecPath=*/ true);
@@ -768,6 +775,7 @@
       Action action,
       List<Artifact> resolvedCacheArtifacts,
       Map<String, String> clientEnv,
+      OutputPermissions outputPermissions,
       EventHandler handler,
       MetadataHandler metadataHandler,
       ArtifactExpander artifactExpander,
@@ -781,6 +789,7 @@
         action,
         resolvedCacheArtifacts,
         clientEnv,
+        outputPermissions,
         handler,
         metadataHandler,
         artifactExpander,
diff --git a/src/main/java/com/google/devtools/build/lib/actions/cache/ActionCache.java b/src/main/java/com/google/devtools/build/lib/actions/cache/ActionCache.java
index 6204e6f..8f91a18 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/cache/ActionCache.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/cache/ActionCache.java
@@ -33,6 +33,7 @@
 import com.google.devtools.build.lib.skyframe.TreeArtifactValue;
 import com.google.devtools.build.lib.util.Fingerprint;
 import com.google.devtools.build.lib.vfs.DigestUtils;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import java.io.IOException;
 import java.io.PrintStream;
@@ -84,9 +85,11 @@
    * will continue to return same result regardless of internal data transformations).
    */
   final class Entry {
+    private static final byte[] EMPTY_CLIENT_ENV_DIGEST = new byte[0];
+
     /** Unique instance to represent a corrupted cache entry. */
     public static final ActionCache.Entry CORRUPTED =
-        new ActionCache.Entry(null, ImmutableMap.of(), false);
+        new ActionCache.Entry(null, ImmutableMap.of(), false, OutputPermissions.READONLY);
 
     private final String actionKey;
     @Nullable
@@ -95,7 +98,7 @@
     // If null, digest is non-null and the entry is immutable.
     private Map<String, FileArtifactValue> mdMap;
     private byte[] digest;
-    private final byte[] usedClientEnvDigest;
+    private final byte[] actionPropertiesDigest;
     private final Map<String, RemoteFileArtifactValue> outputFileMetadata;
     private final Map<String, SerializableTreeArtifactValue> outputTreeMetadata;
 
@@ -160,9 +163,13 @@
       public abstract Optional<PathFragment> materializationExecPath();
     }
 
-    public Entry(String key, Map<String, String> usedClientEnv, boolean discoversInputs) {
+    public Entry(
+        String key,
+        Map<String, String> usedClientEnv,
+        boolean discoversInputs,
+        OutputPermissions outputPermissions) {
       actionKey = key;
-      this.usedClientEnvDigest = digestClientEnv(usedClientEnv);
+      this.actionPropertiesDigest = digestActionProperties(usedClientEnv, outputPermissions);
       files = discoversInputs ? new ArrayList<>() : null;
       mdMap = new HashMap<>();
       outputFileMetadata = new HashMap<>();
@@ -171,13 +178,13 @@
 
     public Entry(
         String key,
-        byte[] usedClientEnvDigest,
+        byte[] actionPropertiesDigest,
         @Nullable List<String> files,
         byte[] digest,
         Map<String, RemoteFileArtifactValue> outputFileMetadata,
         Map<String, SerializableTreeArtifactValue> outputTreeMetadata) {
       actionKey = key;
-      this.usedClientEnvDigest = usedClientEnvDigest;
+      this.actionPropertiesDigest = actionPropertiesDigest;
       this.files = files;
       this.digest = digest;
       mdMap = null;
@@ -185,10 +192,9 @@
       this.outputTreeMetadata = outputTreeMetadata;
     }
 
-    private static final byte[] EMPTY_CLIENT_ENV_DIGEST = new byte[0];
-
     /**
-     * Computes an order-independent digest of a map of environment variables.
+     * Computes an order-independent digest of action properties. This includes a map of client
+     * environment variables and the non-default permissions for output artifacts of the action.
      *
      * <p>Note that as discussed in https://github.com/bazelbuild/bazel/issues/15660, using {@link
      * DigestUtils#xor} to achieve order-independence is questionable in case it is possible that
@@ -196,7 +202,8 @@
      * (due to lossy conversion from UTF-16 to UTF-8). We could instead use a sorted map, however
      * changing the digest function would cause action cache misses across bazel versions.
      */
-    private static byte[] digestClientEnv(Map<String, String> clientEnv) {
+    private static byte[] digestActionProperties(
+        Map<String, String> clientEnv, OutputPermissions outputPermissions) {
       byte[] result = EMPTY_CLIENT_ENV_DIGEST;
       Fingerprint fp = new Fingerprint();
       for (Map.Entry<String, String> entry : clientEnv.entrySet()) {
@@ -204,6 +211,13 @@
         fp.addString(entry.getValue());
         result = DigestUtils.xor(result, fp.digestAndReset());
       }
+      // Add the permissions mode to the digest if it differs from the default.
+      // This is a bit of a hack to save memory on entries which have the default permissions mode
+      // and no client env.
+      if (outputPermissions != OutputPermissions.READONLY) {
+        fp.addInt(outputPermissions.getPermissionsMode());
+        result = DigestUtils.xor(result, fp.digestAndReset());
+      }
       return result;
     }
 
@@ -288,14 +302,16 @@
       return actionKey;
     }
 
-    /** @return the effectively used client environment */
-    public byte[] getUsedClientEnvDigest() {
-      return usedClientEnvDigest;
+    /** Returns the effectively used client environment. */
+    public byte[] getActionPropertiesDigest() {
+      return actionPropertiesDigest;
     }
 
-    /** Determines whether this entry used the same client environment as the one given. */
-    public boolean usedSameClientEnv(Map<String, String> clientEnv) {
-      return Arrays.equals(digestClientEnv(clientEnv), usedClientEnvDigest);
+    /** Determines whether this entry has the same action properties as the one given. */
+    public boolean sameActionProperties(
+        Map<String, String> clientEnv, OutputPermissions outputPermissions) {
+      return Arrays.equals(
+          digestActionProperties(clientEnv, outputPermissions), actionPropertiesDigest);
     }
 
     /**
@@ -343,7 +359,7 @@
       builder.append("      actionKey = ").append(actionKey).append("\n");
       builder
           .append("      usedClientEnvKey = ")
-          .append(formatDigest(usedClientEnvDigest))
+          .append(formatDigest(actionPropertiesDigest))
           .append("\n");
       builder.append("      digestKey = ");
       if (digest == null) {
diff --git a/src/main/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCache.java b/src/main/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCache.java
index b2d44b8..937b353 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCache.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCache.java
@@ -571,7 +571,7 @@
       VarInt.putVarInt(indexer.getOrCreateIndex(file), sink);
     }
 
-    MetadataDigestUtils.write(entry.getUsedClientEnvDigest(), sink);
+    MetadataDigestUtils.write(entry.getActionPropertiesDigest(), sink);
 
     VarInt.putVarInt(entry.getOutputFiles().size(), sink);
     for (Map.Entry<String, RemoteFileArtifactValue> file : entry.getOutputFiles().entrySet()) {
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/config/CoreOptions.java b/src/main/java/com/google/devtools/build/lib/analysis/config/CoreOptions.java
index d028ad8..e683417 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/config/CoreOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/config/CoreOptions.java
@@ -170,6 +170,17 @@
               + "disabled.")
   public boolean strictFilesets;
 
+  // This option is only used during execution. However, it is a required input to the analysis
+  // phase, as otherwise flipping this flag would not invalidate already-executed actions.
+  @Option(
+      name = "experimental_writable_outputs",
+      defaultValue = "false",
+      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+      effectTags = {OptionEffectTag.BAZEL_INTERNAL_CONFIGURATION},
+      metadataTags = {OptionMetadataTag.EXPERIMENTAL},
+      help = "If true, the file permissions of action outputs are set to 0755 instead of 0555")
+  public boolean experimentalWritableOutputs;
+
   @Option(
       name = "incompatible_strict_conflict_checks",
       oldName = "experimental_strict_conflict_checks",
@@ -970,6 +981,7 @@
     host.includeRequiredConfigFragmentsProvider = includeRequiredConfigFragmentsProvider;
     host.debugSelectsAlwaysSucceed = debugSelectsAlwaysSucceed;
     host.checkTestonlyForOutputFiles = checkTestonlyForOutputFiles;
+    host.experimentalWritableOutputs = experimentalWritableOutputs;
     host.strictConflictChecks = strictConflictChecks;
 
     // === Runfiles ===
diff --git a/src/main/java/com/google/devtools/build/lib/remote/AbstractActionInputPrefetcher.java b/src/main/java/com/google/devtools/build/lib/remote/AbstractActionInputPrefetcher.java
index bcdf604..3c32568 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/AbstractActionInputPrefetcher.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/AbstractActionInputPrefetcher.java
@@ -48,6 +48,7 @@
 import com.google.devtools.build.lib.remote.util.RxUtils.TransferResult;
 import com.google.devtools.build.lib.remote.util.TempPathGenerator;
 import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import io.reactivex.rxjava3.core.Completable;
@@ -74,6 +75,7 @@
   private final Reporter reporter;
   private final AsyncTaskCache.NoResult<Path> downloadCache = AsyncTaskCache.NoResult.create();
   private final TempPathGenerator tempPathGenerator;
+  private final OutputPermissions outputPermissions;
   protected final Set<Artifact> outputsAreInputs = Sets.newConcurrentHashSet();
 
   protected final Path execRoot;
@@ -123,11 +125,13 @@
       Reporter reporter,
       Path execRoot,
       TempPathGenerator tempPathGenerator,
-      ImmutableList<Pattern> patternsToDownload) {
+      ImmutableList<Pattern> patternsToDownload,
+      OutputPermissions outputPermissions) {
     this.reporter = reporter;
     this.execRoot = execRoot;
     this.tempPathGenerator = tempPathGenerator;
     this.patternsToDownload = patternsToDownload;
+    this.outputPermissions = outputPermissions;
   }
 
   private boolean shouldDownloadFile(Path path, FileArtifactValue metadata) {
@@ -352,12 +356,12 @@
                   }
 
                   for (Path dir : dirs) {
-                    // Change permission of all directories of a tree artifact to 0555 (files are
+                    // Change permission of all directories of a tree artifact (files are
                     // changed inside {@code finalizeDownload}) in order to match the behaviour when
                     // the tree artifact is generated locally. In that case, permission of all files
-                    // and directories inside a tree artifact is changed to 0555 within {@code
+                    // and directories inside a tree artifact is changed within {@code
                     // checkOutputs()}.
-                    dir.chmod(0555);
+                    dir.chmod(outputPermissions.getPermissionsMode());
                   }
 
                   completed.set(true);
@@ -537,9 +541,9 @@
       parentDir.setWritable(true);
     }
 
-    // The permission of output file is changed to 0555 after action execution. We manually change
+    // The permission of output file is changed after action execution. We manually change
     // the permission here for the downloaded file to keep this behaviour consistent.
-    tmpPath.chmod(0555);
+    tmpPath.chmod(outputPermissions.getPermissionsMode());
     FileSystemUtils.moveFile(tmpPath, path);
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/remote/BUILD b/src/main/java/com/google/devtools/build/lib/remote/BUILD
index c925b61..43b5ed1 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/remote/BUILD
@@ -61,6 +61,7 @@
         "//src/main/java/com/google/devtools/build/lib/analysis:analysis_cluster",
         "//src/main/java/com/google/devtools/build/lib/analysis:blaze_directories",
         "//src/main/java/com/google/devtools/build/lib/analysis:config/build_options",
+        "//src/main/java/com/google/devtools/build/lib/analysis:config/core_options",
         "//src/main/java/com/google/devtools/build/lib/analysis:configured_target",
         "//src/main/java/com/google/devtools/build/lib/analysis/platform:platform_utils",
         "//src/main/java/com/google/devtools/build/lib/authandtls",
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcher.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcher.java
index bcda637..fb6f55c 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcher.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcher.java
@@ -38,6 +38,7 @@
 import com.google.devtools.build.lib.server.FailureDetails;
 import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
 import com.google.devtools.build.lib.server.FailureDetails.Spawn.Code;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import io.reactivex.rxjava3.core.Completable;
@@ -65,8 +66,9 @@
       Path execRoot,
       TempPathGenerator tempPathGenerator,
       ImmutableList<Pattern> patternsToDownload,
+      OutputPermissions outputPermissions,
       boolean useNewExitCodeForLostInputs) {
-    super(reporter, execRoot, tempPathGenerator, patternsToDownload);
+    super(reporter, execRoot, tempPathGenerator, patternsToDownload, outputPermissions);
     this.buildRequestId = Preconditions.checkNotNull(buildRequestId);
     this.commandId = Preconditions.checkNotNull(commandId);
     this.remoteCache = Preconditions.checkNotNull(remoteCache);
diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
index 6f1bfbe..979df0f 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteModule.java
@@ -41,6 +41,7 @@
 import com.google.devtools.build.lib.analysis.BlazeDirectories;
 import com.google.devtools.build.lib.analysis.ConfiguredTarget;
 import com.google.devtools.build.lib.analysis.config.BuildOptions;
+import com.google.devtools.build.lib.analysis.config.CoreOptions;
 import com.google.devtools.build.lib.analysis.configuredtargets.RuleConfiguredTarget;
 import com.google.devtools.build.lib.authandtls.AuthAndTLSOptions;
 import com.google.devtools.build.lib.authandtls.CallCredentialsProvider;
@@ -94,6 +95,7 @@
 import com.google.devtools.build.lib.util.io.AsynchronousFileOutputStream;
 import com.google.devtools.build.lib.vfs.DigestHashFunction;
 import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.OutputService;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.common.options.OptionsBase;
@@ -908,6 +910,11 @@
     RemoteOptions remoteOptions =
         Preconditions.checkNotNull(
             env.getOptions().getOptions(RemoteOptions.class), "RemoteOptions");
+    CoreOptions coreOptions = env.getOptions().getOptions(CoreOptions.class);
+    OutputPermissions outputPermissions =
+        coreOptions.experimentalWritableOutputs
+            ? OutputPermissions.WRITABLE
+            : OutputPermissions.READONLY;
     RemoteOutputsMode remoteOutputsMode = remoteOptions.remoteOutputsMode;
 
     if (!remoteOutputsMode.downloadAllOutputs() && actionContextProvider.getRemoteCache() != null) {
@@ -921,6 +928,7 @@
               env.getExecRoot(),
               tempPathGenerator,
               patternsToDownload,
+              outputPermissions,
               remoteOptions.useNewExitCodeForLostInputs);
       env.getEventBus().register(actionInputFetcher);
       builder.setActionInputPrefetcher(actionInputFetcher);
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/ActionExecutionFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/ActionExecutionFunction.java
index 4a116c0..874262d 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/ActionExecutionFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/ActionExecutionFunction.java
@@ -742,6 +742,7 @@
             state.inputArtifactData,
             action.discoversInputs(),
             skyframeActionExecutor.useArchivedTreeArtifacts(action),
+            skyframeActionExecutor.getOutputPermissions(),
             action.getOutputs(),
             skyframeActionExecutor.getXattrProvider(),
             tsgm.get(),
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/ActionMetadataHandler.java b/src/main/java/com/google/devtools/build/lib/skyframe/ActionMetadataHandler.java
index a9385f9..af848e3 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/ActionMetadataHandler.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/ActionMetadataHandler.java
@@ -47,6 +47,7 @@
 import com.google.devtools.build.lib.vfs.FileStatus;
 import com.google.devtools.build.lib.vfs.FileStatusWithDigest;
 import com.google.devtools.build.lib.vfs.FileStatusWithDigestAdapter;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.build.lib.vfs.RootedPath;
@@ -93,6 +94,7 @@
       ActionInputMap inputArtifactData,
       boolean forInputDiscovery,
       boolean archivedTreeArtifactsEnabled,
+      OutputPermissions outputPermissions,
       ImmutableSet<Artifact> outputs,
       XattrProvider xattrProvider,
       TimestampGranularityMonitor tsgm,
@@ -104,6 +106,7 @@
         inputArtifactData,
         forInputDiscovery,
         archivedTreeArtifactsEnabled,
+        outputPermissions,
         outputs,
         xattrProvider,
         tsgm,
@@ -117,6 +120,7 @@
   private final ActionInputMap inputArtifactData;
   private final boolean forInputDiscovery;
   private final boolean archivedTreeArtifactsEnabled;
+  private final OutputPermissions outputPermissions;
   private final ImmutableMap<PathFragment, FileArtifactValue> filesetMapping;
 
   private final Set<Artifact> omittedOutputs = Sets.newConcurrentHashSet();
@@ -135,6 +139,7 @@
       ActionInputMap inputArtifactData,
       boolean forInputDiscovery,
       boolean archivedTreeArtifactsEnabled,
+      OutputPermissions outputPermissions,
       ImmutableSet<Artifact> outputs,
       XattrProvider xattrProvider,
       TimestampGranularityMonitor tsgm,
@@ -146,6 +151,7 @@
     this.inputArtifactData = checkNotNull(inputArtifactData);
     this.forInputDiscovery = forInputDiscovery;
     this.archivedTreeArtifactsEnabled = archivedTreeArtifactsEnabled;
+    this.outputPermissions = outputPermissions;
     this.outputs = checkNotNull(outputs);
     this.xattrProvider = xattrProvider;
     this.tsgm = checkNotNull(tsgm);
@@ -170,8 +176,9 @@
   ActionMetadataHandler transformAfterInputDiscovery(OutputStore store) {
     return new ActionMetadataHandler(
         inputArtifactData,
-        /*forInputDiscovery=*/ false,
+        /* forInputDiscovery= */ false,
         archivedTreeArtifactsEnabled,
+        outputPermissions,
         outputs,
         xattrProvider,
         tsgm,
@@ -287,7 +294,7 @@
     // If necessary, we first call chmod the output file. The FileArtifactValue may use a
     // FileContentsProxy, which is based on ctime (affected by chmod).
     if (executionMode.get()) {
-      setPathReadOnlyAndExecutableIfFile(artifactPathResolver.toPath(artifact));
+      setPathPermissionsIfFile(artifactPathResolver.toPath(artifact));
     }
 
     value = constructFileArtifactValueFromFilesystem(artifact);
@@ -330,13 +337,13 @@
     // initialized, so this should hold unless the action itself has deleted the root.
     if (!treeDir.isDirectory(Symlinks.FOLLOW)) {
       if (chmod) {
-        setPathReadOnlyAndExecutableIfFile(treeDir);
+        setPathPermissionsIfFile(treeDir);
       }
       return TreeArtifactValue.MISSING_TREE_ARTIFACT;
     }
 
     if (chmod) {
-      setPathReadOnlyAndExecutable(treeDir);
+      setPathPermissions(treeDir);
     }
 
     TreeArtifactValue.Builder tree = TreeArtifactValue.newBuilder(parent);
@@ -345,7 +352,7 @@
         treeDir,
         (parentRelativePath, type) -> {
           if (chmod && type != Dirent.Type.SYMLINK) {
-            setPathReadOnlyAndExecutable(treeDir.getRelative(parentRelativePath));
+            setPathPermissions(treeDir.getRelative(parentRelativePath));
           }
           if (type == Dirent.Type.DIRECTORY) {
             return; // The final TreeArtifactValue does not contain child directories.
@@ -711,13 +718,13 @@
         fileStateValue.getDigest(), fileStateValue.getContentsProxy(), stat.getSize());
   }
 
-  private static void setPathReadOnlyAndExecutableIfFile(Path path) throws IOException {
+  private void setPathPermissionsIfFile(Path path) throws IOException {
     if (path.isFile(Symlinks.NOFOLLOW)) {
-      setPathReadOnlyAndExecutable(path);
+      setPathPermissions(path);
     }
   }
 
-  private static void setPathReadOnlyAndExecutable(Path path) throws IOException {
-    path.chmod(0555);
+  private void setPathPermissions(Path path) throws IOException {
+    path.chmod(outputPermissions.getPermissionsMode());
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeActionExecutor.java b/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeActionExecutor.java
index 342d977..11a3d1d 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeActionExecutor.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeActionExecutor.java
@@ -99,6 +99,7 @@
 import com.google.devtools.build.lib.util.io.FileOutErr;
 import com.google.devtools.build.lib.vfs.FileSystem;
 import com.google.devtools.build.lib.vfs.FileSystem.NotASymlinkException;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.OutputService;
 import com.google.devtools.build.lib.vfs.OutputService.ActionFileSystemType;
 import com.google.devtools.build.lib.vfs.Path;
@@ -328,11 +329,19 @@
     return options.getOptions(BuildEventProtocolOptions.class).publishTargetSummary;
   }
 
+  OutputPermissions getOutputPermissions() {
+    return options.getOptions(CoreOptions.class).experimentalWritableOutputs
+        ? OutputPermissions.WRITABLE
+        : OutputPermissions.READONLY;
+  }
+
   XattrProvider getXattrProvider() {
     return syscallCache;
   }
 
-  /** REQUIRES: {@link #actionFileSystemType()} to be not {@code DISABLED}. */
+  /**
+   * REQUIRES: {@link #actionFileSystemType()} to be not {@code DISABLED}.
+   */
   FileSystem createActionFileSystem(
       String relativeOutputPath,
       ActionInputMap inputArtifactData,
@@ -624,6 +633,7 @@
               action,
               resolvedCacheArtifacts,
               clientEnv,
+              getOutputPermissions(),
               handler,
               metadataHandler,
               artifactExpander,
@@ -682,6 +692,7 @@
                   action,
                   resolvedCacheArtifacts,
                   clientEnv,
+                  getOutputPermissions(),
                   handler,
                   metadataHandler,
                   artifactExpander,
@@ -727,7 +738,13 @@
 
     try {
       actionCacheChecker.updateActionCache(
-          action, token, metadataHandler, artifactExpander, clientEnv, remoteDefaultProperties);
+          action,
+          token,
+          metadataHandler,
+          artifactExpander,
+          clientEnv,
+          getOutputPermissions(),
+          remoteDefaultProperties);
     } catch (IOException e) {
       // Skyframe has already done all the filesystem access needed for outputs and swallows
       // IOExceptions for inputs. So an IOException is impossible here.
diff --git a/src/main/java/com/google/devtools/build/lib/vfs/OutputPermissions.java b/src/main/java/com/google/devtools/build/lib/vfs/OutputPermissions.java
new file mode 100644
index 0000000..0bc688d
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/vfs/OutputPermissions.java
@@ -0,0 +1,30 @@
+// Copyright 2022 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.vfs;
+
+/** File permissions of output file(s). */
+public enum OutputPermissions {
+  READONLY(0555),
+  WRITABLE(0755);
+
+  private final int permissionsMode;
+
+  private OutputPermissions(int permissionsMode) {
+    this.permissionsMode = permissionsMode;
+  }
+
+  public int getPermissionsMode() {
+    return permissionsMode;
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/actions/ActionCacheCheckerTest.java b/src/test/java/com/google/devtools/build/lib/actions/ActionCacheCheckerTest.java
index b854342..ee8f3d7 100644
--- a/src/test/java/com/google/devtools/build/lib/actions/ActionCacheCheckerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/actions/ActionCacheCheckerTest.java
@@ -57,6 +57,7 @@
 import com.google.devtools.build.lib.vfs.Dirent;
 import com.google.devtools.build.lib.vfs.FileSystem;
 import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.build.lib.vfs.Root;
@@ -179,13 +180,14 @@
     Token token =
         cacheChecker.getTokenIfNeedToExecute(
             action,
-            /*resolvedCacheArtifacts=*/ null,
+            /* resolvedCacheArtifacts= */ null,
             clientEnv,
-            /*handler=*/ null,
+            OutputPermissions.READONLY,
+            /* handler= */ null,
             metadataHandler,
-            /*artifactExpander=*/ null,
+            /* artifactExpander= */ null,
             platform,
-            /*isRemoteCacheEnabled=*/ true);
+            /* loadCachedOutputMetadata= */ true);
     if (token != null) {
       // Real action execution would happen here.
       ActionExecutionContext context = mock(ActionExecutionContext.class);
@@ -193,7 +195,13 @@
       action.execute(context);
 
       cacheChecker.updateActionCache(
-          action, token, metadataHandler, /*artifactExpander=*/ null, clientEnv, platform);
+          action,
+          token,
+          metadataHandler,
+          /* artifactExpander= */ null,
+          clientEnv,
+          OutputPermissions.READONLY,
+          platform);
     }
   }
 
@@ -437,13 +445,14 @@
     assertThat(
             cacheChecker.getTokenIfNeedToExecute(
                 action,
-                /*resolvedCacheArtifacts=*/ null,
-                /*clientEnv=*/ ImmutableMap.of(),
-                /*handler=*/ null,
+                /* resolvedCacheArtifacts= */ null,
+                /* clientEnv= */ ImmutableMap.of(),
+                OutputPermissions.READONLY,
+                /* handler= */ null,
                 new FakeMetadataHandler(),
-                /*artifactExpander=*/ null,
-                /*remoteDefaultPlatformProperties=*/ ImmutableMap.of(),
-                /*isRemoteCacheEnabled=*/ true))
+                /* artifactExpander= */ null,
+                /* remoteDefaultPlatformProperties= */ ImmutableMap.of(),
+                /* loadCachedOutputMetadata= */ true))
         .isNotNull();
   }
 
@@ -563,13 +572,14 @@
     Token token =
         cacheChecker.getTokenIfNeedToExecute(
             action,
-            /*resolvedCacheArtifacts=*/ null,
-            /*clientEnv=*/ ImmutableMap.of(),
-            /*handler=*/ null,
+            /* resolvedCacheArtifacts= */ null,
+            /* clientEnv= */ ImmutableMap.of(),
+            OutputPermissions.READONLY,
+            /* handler= */ null,
             metadataHandler,
-            /*artifactExpander=*/ null,
-            /*remoteDefaultPlatformProperties=*/ ImmutableMap.of(),
-            /*isRemoteCacheEnabled=*/ true);
+            /* artifactExpander= */ null,
+            /* remoteDefaultPlatformProperties= */ ImmutableMap.of(),
+            /* loadCachedOutputMetadata= */ true);
 
     assertThat(output.getPath().exists()).isFalse();
     assertThat(token).isNull();
@@ -592,13 +602,14 @@
     Token token =
         cacheChecker.getTokenIfNeedToExecute(
             action,
-            /*resolvedCacheArtifacts=*/ null,
-            /*clientEnv=*/ ImmutableMap.of(),
-            /*handler=*/ null,
+            /* resolvedCacheArtifacts= */ null,
+            /* clientEnv= */ ImmutableMap.of(),
+            OutputPermissions.READONLY,
+            /* handler= */ null,
             metadataHandler,
-            /*artifactExpander=*/ null,
-            /*remoteDefaultPlatformProperties=*/ ImmutableMap.of(),
-            /*isRemoteCacheEnabled=*/ false);
+            /* artifactExpander= */ null,
+            /* remoteDefaultPlatformProperties= */ ImmutableMap.of(),
+            /* loadCachedOutputMetadata= */ false);
 
     assertThat(output.getPath().exists()).isFalse();
     assertThat(token).isNotNull();
@@ -766,13 +777,14 @@
     Token token =
         cacheChecker.getTokenIfNeedToExecute(
             action,
-            /*resolvedCacheArtifacts=*/ null,
-            /*clientEnv=*/ ImmutableMap.of(),
-            /*handler=*/ null,
+            /* resolvedCacheArtifacts= */ null,
+            /* clientEnv= */ ImmutableMap.of(),
+            OutputPermissions.READONLY,
+            /* handler= */ null,
             metadataHandler,
-            /*artifactExpander=*/ null,
-            /*remoteDefaultPlatformProperties=*/ ImmutableMap.of(),
-            /*isRemoteCacheEnabled=*/ true);
+            /* artifactExpander= */ null,
+            /* remoteDefaultPlatformProperties= */ ImmutableMap.of(),
+            /* loadCachedOutputMetadata= */ true);
 
     assertThat(token).isNull();
     assertThat(output.getPath().exists()).isFalse();
@@ -864,13 +876,14 @@
     Token token =
         cacheChecker.getTokenIfNeedToExecute(
             action,
-            /*resolvedCacheArtifacts=*/ null,
-            /*clientEnv=*/ ImmutableMap.of(),
-            /*handler=*/ null,
+            /* resolvedCacheArtifacts= */ null,
+            /* clientEnv= */ ImmutableMap.of(),
+            OutputPermissions.READONLY,
+            /* handler= */ null,
             metadataHandler,
-            /*artifactExpander=*/ null,
-            /*remoteDefaultPlatformProperties=*/ ImmutableMap.of(),
-            /*isRemoteCacheEnabled=*/ true);
+            /* artifactExpander= */ null,
+            /* remoteDefaultPlatformProperties= */ ImmutableMap.of(),
+            /* loadCachedOutputMetadata= */ true);
 
     TreeArtifactValue expectedMetadata =
         createTreeMetadata(
@@ -1025,13 +1038,14 @@
     Token token =
         cacheChecker.getTokenIfNeedToExecute(
             action,
-            /*resolvedCacheArtifacts=*/ null,
-            /*clientEnv=*/ ImmutableMap.of(),
-            /*handler=*/ null,
+            /* resolvedCacheArtifacts= */ null,
+            /* clientEnv= */ ImmutableMap.of(),
+            OutputPermissions.READONLY,
+            /* handler= */ null,
             metadataHandler,
-            /*artifactExpander=*/ null,
-            /*remoteDefaultPlatformProperties=*/ ImmutableMap.of(),
-            /*isRemoteCacheEnabled=*/ true);
+            /* artifactExpander= */ null,
+            /* remoteDefaultPlatformProperties= */ ImmutableMap.of(),
+            /* loadCachedOutputMetadata= */ true);
 
     assertThat(token).isNull();
     assertStatistics(1, new MissDetailsBuilder().set(MissReason.NOT_CACHED, 1).build());
diff --git a/src/test/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCacheTest.java b/src/test/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCacheTest.java
index b893c2a..f8f1773 100644
--- a/src/test/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCacheTest.java
+++ b/src/test/java/com/google/devtools/build/lib/actions/cache/CompactPersistentActionCacheTest.java
@@ -30,6 +30,7 @@
 import com.google.devtools.build.lib.testutil.ManualClock;
 import com.google.devtools.build.lib.testutil.Scratch;
 import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import java.io.IOException;
@@ -160,7 +161,8 @@
   @SuppressWarnings("ReturnValueIgnored")
   @Test
   public void testEntryToStringIsIdempotent() {
-    ActionCache.Entry entry = new ActionCache.Entry("actionKey", ImmutableMap.of(), false);
+    ActionCache.Entry entry =
+        new ActionCache.Entry("actionKey", ImmutableMap.of(), false, OutputPermissions.READONLY);
     entry.toString();
     entry.addInputFile(
         PathFragment.create("foo/bar"), FileArtifactValue.createForDirectoryWithMtime(1234));
@@ -239,7 +241,8 @@
   @Test
   public void putAndGet_savesRemoteFileMetadata() {
     String key = "key";
-    ActionCache.Entry entry = new ActionCache.Entry(key, ImmutableMap.of(), false);
+    ActionCache.Entry entry =
+        new ActionCache.Entry(key, ImmutableMap.of(), false, OutputPermissions.READONLY);
     Artifact artifact = ActionsTestUtil.DUMMY_ARTIFACT;
     RemoteFileArtifactValue metadata = createRemoteMetadata(artifact, "content");
     entry.addOutputFile(artifact, metadata, /*saveFileMetadata=*/ true);
@@ -253,7 +256,8 @@
   @Test
   public void putAndGet_savesRemoteFileMetadata_withmaterializationExecPath() {
     String key = "key";
-    ActionCache.Entry entry = new ActionCache.Entry(key, ImmutableMap.of(), false);
+    ActionCache.Entry entry =
+        new ActionCache.Entry(key, ImmutableMap.of(), false, OutputPermissions.READONLY);
     Artifact artifact = ActionsTestUtil.DUMMY_ARTIFACT;
     RemoteFileArtifactValue metadata =
         createRemoteMetadata(artifact, "content", PathFragment.create("/execroot/some/path"));
@@ -268,7 +272,8 @@
   @Test
   public void putAndGet_ignoresLocalFileMetadata() throws IOException {
     String key = "key";
-    ActionCache.Entry entry = new ActionCache.Entry(key, ImmutableMap.of(), false);
+    ActionCache.Entry entry =
+        new ActionCache.Entry(key, ImmutableMap.of(), false, OutputPermissions.READONLY);
     Artifact artifact = ActionsTestUtil.DUMMY_ARTIFACT;
     FileArtifactValue metadata = createLocalMetadata(artifact, "content");
     entry.addOutputFile(artifact, metadata, /*saveFileMetadata=*/ true);
@@ -282,7 +287,8 @@
   @Test
   public void putAndGet_treeMetadata_onlySavesRemoteFileMetadata() throws IOException {
     String key = "key";
-    ActionCache.Entry entry = new ActionCache.Entry(key, ImmutableMap.of(), false);
+    ActionCache.Entry entry =
+        new ActionCache.Entry(key, ImmutableMap.of(), false, OutputPermissions.READONLY);
     SpecialArtifact artifact =
         ActionsTestUtil.createTreeArtifactWithGeneratingAction(
             artifactRoot, PathFragment.create("bin/dummy"));
@@ -323,7 +329,8 @@
   @Test
   public void putAndGet_treeMetadata_savesRemoteArchivedArtifact() {
     String key = "key";
-    ActionCache.Entry entry = new ActionCache.Entry(key, ImmutableMap.of(), false);
+    ActionCache.Entry entry =
+        new ActionCache.Entry(key, ImmutableMap.of(), false, OutputPermissions.READONLY);
     SpecialArtifact artifact =
         ActionsTestUtil.createTreeArtifactWithGeneratingAction(
             artifactRoot, PathFragment.create("bin/dummy"));
@@ -349,7 +356,8 @@
   @Test
   public void putAndGet_treeMetadata_ignoresLocalArchivedArtifact() throws IOException {
     String key = "key";
-    ActionCache.Entry entry = new ActionCache.Entry(key, ImmutableMap.of(), false);
+    ActionCache.Entry entry =
+        new ActionCache.Entry(key, ImmutableMap.of(), false, OutputPermissions.READONLY);
     SpecialArtifact artifact =
         ActionsTestUtil.createTreeArtifactWithGeneratingAction(
             artifactRoot, PathFragment.create("bin/dummy"));
@@ -373,7 +381,8 @@
   public void putAndGet_treeMetadata_savesMaterializationExecPath() {
     String key = "key";
     PathFragment materializationExecPath = PathFragment.create("/execroot/some/path");
-    ActionCache.Entry entry = new ActionCache.Entry(key, ImmutableMap.of(), false);
+    ActionCache.Entry entry =
+        new ActionCache.Entry(key, ImmutableMap.of(), false, OutputPermissions.READONLY);
     SpecialArtifact artifact =
         ActionsTestUtil.createTreeArtifactWithGeneratingAction(
             artifactRoot, PathFragment.create("bin/dummy"));
@@ -424,7 +433,8 @@
 
   private void putKey(String key, ActionCache ac, boolean discoversInputs) {
     ActionCache.Entry entry =
-        new ActionCache.Entry(key, ImmutableMap.of("k", "v"), discoversInputs);
+        new ActionCache.Entry(
+            key, ImmutableMap.of("k", "v"), discoversInputs, OutputPermissions.READONLY);
     entry.getFileDigest();
     ac.put(key, entry);
   }
diff --git a/src/test/java/com/google/devtools/build/lib/exec/util/BUILD b/src/test/java/com/google/devtools/build/lib/exec/util/BUILD
index 1190919..afed03e 100644
--- a/src/test/java/com/google/devtools/build/lib/exec/util/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/exec/util/BUILD
@@ -30,6 +30,7 @@
         "//src/main/java/com/google/devtools/build/lib/analysis:actions/template_expansion_action",
         "//src/main/java/com/google/devtools/build/lib/analysis:analysis_cluster",
         "//src/main/java/com/google/devtools/build/lib/analysis:blaze_directories",
+        "//src/main/java/com/google/devtools/build/lib/analysis:config/core_options",
         "//src/main/java/com/google/devtools/build/lib/analysis/platform",
         "//src/main/java/com/google/devtools/build/lib/bugreport",
         "//src/main/java/com/google/devtools/build/lib/buildeventstream/proto:build_event_stream_java_proto",
diff --git a/src/test/java/com/google/devtools/build/lib/exec/util/TestExecutorBuilder.java b/src/test/java/com/google/devtools/build/lib/exec/util/TestExecutorBuilder.java
index c58c403..be7cb4c 100644
--- a/src/test/java/com/google/devtools/build/lib/exec/util/TestExecutorBuilder.java
+++ b/src/test/java/com/google/devtools/build/lib/exec/util/TestExecutorBuilder.java
@@ -22,6 +22,7 @@
 import com.google.devtools.build.lib.analysis.actions.LocalTemplateExpansionStrategy;
 import com.google.devtools.build.lib.analysis.actions.SymlinkTreeActionContext;
 import com.google.devtools.build.lib.analysis.actions.TemplateExpansionContext;
+import com.google.devtools.build.lib.analysis.config.CoreOptions;
 import com.google.devtools.build.lib.bugreport.BugReporter;
 import com.google.devtools.build.lib.clock.BlazeClock;
 import com.google.devtools.build.lib.events.Reporter;
@@ -49,7 +50,7 @@
  */
 public class TestExecutorBuilder {
   public static final ImmutableList<Class<? extends OptionsBase>> DEFAULT_OPTIONS =
-      ImmutableList.of(ExecutionOptions.class, CommonCommandOptions.class);
+      ImmutableList.of(ExecutionOptions.class, CommonCommandOptions.class, CoreOptions.class);
   private final FileSystem fileSystem;
   private final Path execRoot;
   private Reporter reporter = new Reporter(new EventBus());
diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcherTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcherTest.java
index e82fcfe..fca68db 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcherTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteActionInputFetcherTest.java
@@ -35,6 +35,7 @@
 import com.google.devtools.build.lib.remote.util.InMemoryCacheClient;
 import com.google.devtools.build.lib.remote.util.StaticMetadataProvider;
 import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.SyscallCache;
 import com.google.devtools.common.options.Options;
@@ -74,6 +75,7 @@
         execRoot,
         tempPathGenerator,
         ImmutableList.of(),
+        OutputPermissions.READONLY,
         /* useNewExitCodeForLostInputs= */ false);
   }
 
@@ -91,6 +93,7 @@
             execRoot,
             tempPathGenerator,
             ImmutableList.of(),
+            OutputPermissions.READONLY,
             /* useNewExitCodeForLostInputs= */ false);
     VirtualActionInput a = ActionsTestUtil.createVirtualActionInput("file1", "hello world");
 
@@ -119,6 +122,7 @@
             execRoot,
             tempPathGenerator,
             ImmutableList.of(),
+            OutputPermissions.READONLY,
             /* useNewExitCodeForLostInputs= */ false);
 
     // act
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/ActionMetadataHandlerTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/ActionMetadataHandlerTest.java
index 8d58e33..ebc04c3 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/ActionMetadataHandlerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/ActionMetadataHandlerTest.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
+import com.google.common.collect.Maps;
 import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.ActionInputHelper;
 import com.google.devtools.build.lib.actions.ActionInputMap;
@@ -42,6 +42,7 @@
 import com.google.devtools.build.lib.util.io.TimestampGranularityMonitor;
 import com.google.devtools.build.lib.vfs.DigestHashFunction;
 import com.google.devtools.build.lib.vfs.DigestUtils;
+import com.google.devtools.build.lib.vfs.OutputPermissions;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.build.lib.vfs.Root;
@@ -49,7 +50,7 @@
 import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
 import java.io.FileNotFoundException;
 import java.io.IOException;
-import java.util.Set;
+import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -59,18 +60,19 @@
 @RunWith(JUnit4.class)
 public final class ActionMetadataHandlerTest {
 
-  private final Set<Path> chmodCalls = Sets.newConcurrentHashSet();
+  private final Map<Path, Integer> chmodCalls = Maps.newConcurrentMap();
 
   private final Scratch scratch =
       new Scratch(
           new InMemoryFileSystem(DigestHashFunction.SHA256) {
             @Override
-            public void chmod(PathFragment path, int mode) throws IOException {
-              assertThat(mode).isEqualTo(0555); // Read only and executable.
-              if (!chmodCalls.add(getPath(path))) {
+            public void chmod(PathFragment pathFragment, int mode) throws IOException {
+              Path path = getPath(pathFragment);
+              if (chmodCalls.containsKey(path)) {
                 fail("chmod called on " + path + " twice");
               }
-              super.chmod(path, mode);
+              chmodCalls.put(path, mode);
+              super.chmod(pathFragment, mode);
             }
           });
 
@@ -95,14 +97,15 @@
     return ActionMetadataHandler.create(
         inputMap,
         forInputDiscovery,
-        /*archivedTreeArtifactsEnabled=*/ false,
+        /* archivedTreeArtifactsEnabled= */ false,
+        OutputPermissions.READONLY,
         outputs,
         SyscallCache.NO_CACHE,
         tsgm,
         ArtifactPathResolver.IDENTITY,
         execRoot.asFragment(),
         derivedPathPrefix,
-        /*expandedFilesets=*/ ImmutableMap.of());
+        /* expandedFilesets= */ ImmutableMap.of());
   }
 
   @Test
@@ -293,7 +296,7 @@
 
     // The handler doesn't have any info. It'll stat the file and discover that it's 10 bytes long.
     assertThat(handler.getMetadata(artifact).getSize()).isEqualTo(10);
-    assertThat(chmodCalls).containsExactly(outputPath);
+    assertThat(chmodCalls).containsExactly(outputPath, 0555);
 
     // Inject a remote file of size 42.
     handler.injectFile(
@@ -304,7 +307,7 @@
     handler.resetOutputs(ImmutableList.of(artifact));
     chmodCalls.clear(); // Permit a second chmod call for the artifact.
     assertThat(handler.getMetadata(artifact).getSize()).isEqualTo(10);
-    assertThat(chmodCalls).containsExactly(outputPath);
+    assertThat(chmodCalls).containsExactly(outputPath, 0555);
   }
 
   @Test
@@ -438,9 +441,10 @@
     ActionMetadataHandler handler =
         ActionMetadataHandler.create(
             new ActionInputMap(0),
-            /*forInputDiscovery=*/ false,
-            /*archivedTreeArtifactsEnabled=*/ false,
-            /*outputs=*/ ImmutableSet.of(),
+            /* forInputDiscovery= */ false,
+            /* archivedTreeArtifactsEnabled= */ false,
+            OutputPermissions.READONLY,
+            /* outputs= */ ImmutableSet.of(),
             SyscallCache.NO_CACHE,
             tsgm,
             ArtifactPathResolver.IDENTITY,
@@ -538,7 +542,38 @@
     assertThat(metadata.getDigest()).isEqualTo(outputPath.getDigest());
     assertThat(handler.getOutputStore().getAllArtifactData()).containsExactly(output, metadata);
     assertThat(handler.getOutputStore().getAllTreeArtifactData()).isEmpty();
-    assertThat(chmodCalls).containsExactly(outputPath);
+    assertThat(chmodCalls).containsExactly(outputPath, 0555);
+  }
+
+  @Test
+  public void outputArtifactNotPreviouslyInjectedInExecutionMode_writablePermissions()
+      throws Exception {
+    Artifact output =
+        ActionsTestUtil.createArtifactWithRootRelativePath(
+            outputRoot, PathFragment.create("dir/file.out"));
+    Path outputPath = scratch.file(output.getPath().getPathString(), "contents");
+    ActionMetadataHandler handler =
+        ActionMetadataHandler.create(
+            new ActionInputMap(0),
+            /* forInputDiscovery= */ false,
+            /* archivedTreeArtifactsEnabled= */ false,
+            OutputPermissions.WRITABLE,
+            /* outputs= */ ImmutableSet.of(output),
+            SyscallCache.NO_CACHE,
+            tsgm,
+            ArtifactPathResolver.IDENTITY,
+            execRoot.asFragment(),
+            derivedPathPrefix,
+            /* expandedFilesets= */ ImmutableMap.of());
+    handler.prepareForActionExecution();
+
+    FileArtifactValue metadata = handler.getMetadata(output);
+
+    assertThat(metadata.getDigest()).isEqualTo(outputPath.getDigest());
+    assertThat(handler.getOutputStore().getAllArtifactData()).containsExactly(output, metadata);
+    assertThat(handler.getOutputStore().getAllTreeArtifactData()).isEmpty();
+    // Permissions preserved in handler, so chmod calls should be empty.
+    assertThat(chmodCalls).containsExactly(outputPath, 0755);
   }
 
   @Test
@@ -569,7 +604,14 @@
     assertThat(handler.getOutputStore().getAllArtifactData()).isEmpty();
     assertThat(chmodCalls)
         .containsExactly(
-            treeArtifact.getPath(), child1Path, child2Path, child2Path.getParentDirectory());
+            treeArtifact.getPath(),
+            0555,
+            child1Path,
+            0555,
+            child2Path,
+            0555,
+            child2Path.getParentDirectory(),
+            0555);
   }
 
   @Test
diff --git a/src/test/shell/bazel/BUILD b/src/test/shell/bazel/BUILD
index 5d0af9d..6fe78ba 100644
--- a/src/test/shell/bazel/BUILD
+++ b/src/test/shell/bazel/BUILD
@@ -598,6 +598,15 @@
 )
 
 sh_test(
+    name = "bazel_permissions_test",
+    size = "small",
+    srcs = ["bazel_permissions_test.sh"],
+    data = [":test-deps"],
+    tags = ["no_windows"],
+    deps = ["@bazel_tools//tools/bash/runfiles"],
+)
+
+sh_test(
     name = "bazel_execute_testlog",
     srcs = ["bazel_execute_testlog.sh"],
     data = [":test-deps"],
diff --git a/src/test/shell/bazel/bazel_permissions_test.sh b/src/test/shell/bazel/bazel_permissions_test.sh
new file mode 100755
index 0000000..43979b8
--- /dev/null
+++ b/src/test/shell/bazel/bazel_permissions_test.sh
@@ -0,0 +1,146 @@
+#!/bin/bash
+#
+# Copyright 2022 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -euo pipefail
+
+# --- begin runfiles.bash initialization ---
+if [[ ! -d "${RUNFILES_DIR:-/dev/null}" && ! -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
+    if [[ -f "$0.runfiles_manifest" ]]; then
+      export RUNFILES_MANIFEST_FILE="$0.runfiles_manifest"
+    elif [[ -f "$0.runfiles/MANIFEST" ]]; then
+      export RUNFILES_MANIFEST_FILE="$0.runfiles/MANIFEST"
+    elif [[ -f "$0.runfiles/bazel_tools/tools/bash/runfiles/runfiles.bash" ]]; then
+      export RUNFILES_DIR="$0.runfiles"
+    fi
+fi
+if [[ -f "${RUNFILES_DIR:-/dev/null}/bazel_tools/tools/bash/runfiles/runfiles.bash" ]]; then
+  source "${RUNFILES_DIR}/bazel_tools/tools/bash/runfiles/runfiles.bash"
+elif [[ -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
+  source "$(grep -m1 "^bazel_tools/tools/bash/runfiles/runfiles.bash " \
+            "$RUNFILES_MANIFEST_FILE" | cut -d ' ' -f 2-)"
+else
+  echo >&2 "ERROR: cannot find @bazel_tools//tools/bash/runfiles:runfiles.bash"
+  exit 1
+fi
+# --- end runfiles.bash initialization ---
+
+source "$(rlocation "io_bazel/src/test/shell/integration_test_setup.sh")" \
+  || { echo "integration_test_setup.sh not found!" >&2; exit 1; }
+
+function test_output_readonly() {
+  # Test that permission of output files are 0555 if --experimental_writable_outputs is not set.
+  mkdir -p a
+  cat > a/BUILD <<EOF
+genrule(
+  name = "foo",
+  srcs = [],
+  outs = ["foo"],
+  cmd = "echo 'foo' > \$@",
+)
+EOF
+
+  bazel build \
+      //a:foo >& $TEST_log || fail "Failed to build"
+
+  ls -l bazel-bin/a/foo >& $TEST_log
+  expect_log "-r-xr-xr-x"
+
+  bazel clean >& $TEST_log || fail "Failed to clean"
+
+  # Verify that changing the value of --experimental_writable_outputs results
+  # in an update of the output permissions (invalidation of the action cache, etc)
+  bazel build \
+      --experimental_writable_outputs \
+      //a:foo >& $TEST_log || fail "Failed to build"
+
+  ls -l bazel-bin/a/foo >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  bazel clean >& $TEST_log || fail "Failed to clean"
+}
+
+function test_output_writable() {
+  # Test that permission of output files are 0755 if --experimental_writable_outputs is set.
+  mkdir -p a
+  cat > a/BUILD <<EOF
+genrule(
+  name = "foo",
+  srcs = [],
+  outs = ["foo"],
+  cmd = "echo 'foo' > \$@",
+)
+EOF
+
+  bazel build \
+      --experimental_writable_outputs \
+      //a:foo >& $TEST_log || fail "Failed to build"
+
+  ls -l bazel-bin/a/foo >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  bazel clean >& $TEST_log || fail "Failed to clean"
+
+  # Verify that changing the value of --experimental_writable_outputs results
+  # in an update of the output permissions (invalidation of the action cache, etc)
+  bazel build \
+      //a:foo >& $TEST_log || fail "Failed to build"
+
+  ls -l bazel-bin/a/foo >& $TEST_log
+  expect_log "-r-xr-xr-x"
+
+  bazel clean >& $TEST_log || fail "Failed to clean"
+}
+
+function test_create_tree_artifact_outputs_permissions() {
+  mkdir -p pkg
+  cat > pkg/def.bzl <<'EOF'
+def _r(ctx):
+    d = ctx.actions.declare_directory("%s_dir" % ctx.label.name)
+    ctx.actions.run_shell(
+        outputs = [d],
+        command = "cd %s && touch x && touch y" % d.path,
+    )
+    return [DefaultInfo(files = depset([d]))]
+
+r = rule(implementation = _r)
+EOF
+
+cat > pkg/BUILD <<'EOF'
+load(":def.bzl", "r")
+
+r(name = "a")
+EOF
+
+  bazel build pkg:a &>$TEST_log || fail "expected build to succeed"
+
+  ls -l bazel-bin/pkg >& $TEST_log
+  expect_log "dr-xr-xr-x"
+  ls -l bazel-bin/pkg/a_dir/x >& $TEST_log
+  expect_log "-r-xr-xr-x"
+  ls -l bazel-bin/pkg/a_dir/y >& $TEST_log
+  expect_log "-r-xr-xr-x"
+
+  bazel build pkg:a --experimental_writable_outputs &>$TEST_log || fail "expected build to succeed"
+
+  ls -l bazel-bin/pkg >& $TEST_log
+  expect_log "drwxr-xr-x"
+  ls -l bazel-bin/pkg/a_dir/x >& $TEST_log
+  expect_log "-rwxr-xr-x"
+  ls -l bazel-bin/pkg/a_dir/y >& $TEST_log
+  expect_log "-rwxr-xr-x"
+}
+
+run_suite "bazel file permissions tests"
diff --git a/src/test/shell/bazel/remote/build_without_the_bytes_test.sh b/src/test/shell/bazel/remote/build_without_the_bytes_test.sh
index ff03785..ef92bbb 100755
--- a/src/test/shell/bazel/remote/build_without_the_bytes_test.sh
+++ b/src/test/shell/bazel/remote/build_without_the_bytes_test.sh
@@ -951,6 +951,97 @@
   expect_log "-r-xr-xr-x"
 }
 
+function test_prefetcher_change_permission_writable_outputs() {
+  # test that prefetcher change permission for downloaded files and directories to 0755 if
+  # --experimental_writable_outputs is set.
+  touch WORKSPACE
+
+  cat > rules.bzl <<'EOF'
+def _gen_output_dir_impl(ctx):
+    output_dir = ctx.actions.declare_directory(ctx.attr.outdir)
+    ctx.actions.run_shell(
+        outputs = [output_dir],
+        inputs = [],
+        command = """
+          mkdir -p $1/sub
+          echo "Shuffle, duffle, muzzle, muff" > $1/sub/bar
+        """,
+        arguments = [output_dir.path],
+    )
+    return DefaultInfo(files = depset(direct = [output_dir]))
+
+gen_output_dir = rule(
+    implementation = _gen_output_dir_impl,
+    attrs = {
+        "outdir": attr.string(mandatory = True),
+    },
+)
+EOF
+
+  cat > BUILD <<'EOF'
+load("rules.bzl", "gen_output_dir")
+
+genrule(
+  name = "input-file",
+  srcs = [],
+  outs = ["file"],
+  cmd = "echo 'input' > $@",
+)
+
+gen_output_dir(
+  name = "input-tree",
+  outdir = "tree",
+)
+
+genrule(
+  name = "test",
+  srcs = [":input-file", ":input-tree"],
+  outs = ["out"],
+  cmd = "touch $@",
+  tags = ["no-remote"],
+)
+EOF
+
+  bazel build \
+      --remote_executor=grpc://localhost:${worker_port} \
+      --remote_download_minimal \
+      --experimental_writable_outputs \
+      //:test >& $TEST_log \
+      || fail "Failed to build"
+
+  ls -l bazel-bin/file >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  ls -ld bazel-bin/tree >& $TEST_log
+  expect_log "drwxr-xr-x"
+
+  ls -ld bazel-bin/tree/sub >& $TEST_log
+  expect_log "drwxr-xr-x"
+
+  ls -l bazel-bin/tree/sub/bar >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  # Rebuild without the flag and verify that permissions in the
+  # outputs have changed. (Verifies that outputs aren't cached)
+  bazel build \
+    --remote_executor=grpc://localhost:${worker_port} \
+    --remote_download_minimal \
+    //:test >& $TEST_log \
+    || fail "Failed to build"
+
+  ls -l bazel-bin/file >& $TEST_log
+  expect_log "-r-xr-xr-x"
+
+  ls -ld bazel-bin/tree >& $TEST_log
+  expect_log "dr-xr-xr-x"
+
+  ls -ld bazel-bin/tree/sub >& $TEST_log
+  expect_log "dr-xr-xr-x"
+
+  ls -l bazel-bin/tree/sub/bar >& $TEST_log
+  expect_log "-r-xr-xr-x"
+}
+
 function test_remote_cache_intermediate_outputs_toplevel() {
   # test that remote cache is hit when intermediate output is not executable in remote download toplevel mode
   touch WORKSPACE
@@ -1192,6 +1283,77 @@
   expect_log "-r-xr-xr-x"
 }
 
+function test_output_file_permission() {
+  # Test that permission of output files are always 0755 if --experimental_writable_outputs is set.
+
+  mkdir -p a
+  cat > a/BUILD <<EOF
+genrule(
+  name = "foo",
+  srcs = [],
+  outs = ["foo"],
+  cmd = "echo 'foo' > \$@",
+)
+
+genrule(
+  name = "bar",
+  srcs = [":foo"],
+  outs = ["bar"],
+  cmd = "ls -lL \$(SRCS) > \$@",
+  tags = ["no-remote"],
+)
+EOF
+
+  # no remote execution
+  bazel build \
+      --experimental_writable_outputs \
+      //a:bar >& $TEST_log || fail "Failed to build"
+
+  ls -l bazel-bin/a/bar >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  ls -l bazel-bin/a/foo >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  cat bazel-bin/a/bar >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  bazel clean >& $TEST_log || fail "Failed to clean"
+
+  # normal remote execution
+  bazel build \
+      --remote_executor=grpc://localhost:${worker_port} \
+      --experimental_writable_outputs \
+      //a:bar >& $TEST_log || fail "Failed to build"
+
+  ls -l bazel-bin/a/bar >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  ls -l bazel-bin/a/foo >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  cat bazel-bin/a/bar >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  bazel clean >& $TEST_log || fail "Failed to clean"
+
+  # build without bytes
+  bazel build \
+      --remote_executor=grpc://localhost:${worker_port} \
+      --remote_download_minimal \
+      --experimental_writable_outputs \
+      //a:bar >& $TEST_log || fail "Failed to build"
+
+  ls -l bazel-bin/a/bar >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  ls -l bazel-bin/a/foo >& $TEST_log
+  expect_log "-rwxr-xr-x"
+
+  cat bazel-bin/a/bar >& $TEST_log
+  expect_log "-rwxr-xr-x"
+}
+
 function test_download_toplevel_when_turn_remote_cache_off() {
   # Test that BwtB doesn't cause build failure if remote cache is disabled in a following build.
   # See https://github.com/bazelbuild/bazel/issues/13882.