Repository: btraceio/btrace (2026-07-13T10:04:06Z)

Issue:
[3.0] Add regression coverage for embedded extension permission enforcement
## Context

Embedded extension metadata now parses the permissions property, but this security invariant needs release-level regression coverage. Embedding an extension must not silently grant privileged capabilities.

## Scope

Add focused repository, bridge, and fat-agent tests comparing embedded and filesystem extension behavior.

Likely files:
- btrace-core/src/main/java/io/btrace/extension/impl/EmbeddedExtensionRepository.java
- btrace-core/src/main/java/io/btrace/extension/impl/ExtensionBridgeImpl.java
- Tests under btrace-core/src/test/java/io/btrace/extension/impl/
- A fat-agent integration fixture

## Acceptance criteria

- [ ] Embedded metadata preserves declared privileged permissions.
- [ ] The bridge denies a privileged embedded extension when allowPrivileged is false.
- [ ] Explicit policy allows it.
- [ ] Filesystem and embedded packaging make the same permission decision.
- [ ] Packaging fails if permission metadata cannot be transferred instead of defaulting to an empty set.


Discussion:

Patch:
diff --git a/btrace-core/src/main/java/io/btrace/extension/impl/EmbeddedExtensionRepository.java b/btrace-core/src/main/java/io/btrace/extension/impl/EmbeddedExtensionRepository.java
index 1e441c672..0bd1eb32f 100644
--- a/btrace-core/src/main/java/io/btrace/extension/impl/EmbeddedExtensionRepository.java
+++ b/btrace-core/src/main/java/io/btrace/extension/impl/EmbeddedExtensionRepository.java
@@ -212,6 +212,7 @@ private ExtensionDescriptorDTO parseEmbeddedExtension(String extensionId) {
       String btraceApiVersion = props.getProperty("btrace.api.version", "3.0+");
       String javaVersion = props.getProperty("java.version", "8+");
       String servicesStr = props.getProperty("services", "");
+      String requiredExtensionsStr = props.getProperty("requires.extensions", "");
       String configurator = props.getProperty("configurator");
 
       List<String> services =
@@ -246,6 +247,7 @@ private ExtensionDescriptorDTO parseEmbeddedExtension(String extensionId) {
           .btraceApiVersion(btraceApiVersion)
           .javaVersion(javaVersion)
           .services(services)
+          .requiredExtensions(ExtensionMetadata.parseList(requiredExtensionsStr))
           .repository(this)
           .requiredPermissions(requiredPermissions)
           .embedded(true)
diff --git a/btrace-core/src/main/java/io/btrace/extension/impl/ExtensionMetadata.java b/btrace-core/src/main/java/io/btrace/extension/impl/ExtensionMetadata.java
index 88eb074b6..64f479062 100644
--- a/btrace-core/src/main/java/io/btrace/extension/impl/ExtensionMetadata.java
+++ b/btrace-core/src/main/java/io/btrace/extension/impl/ExtensionMetadata.java
@@ -282,7 +282,7 @@ private static ExtensionDescriptorDTO inferMetadata(
     }
   }
 
-  private static List<String> parseList(String value) {
+  static List<String> parseList(String value) {
     if (value == null || value.trim().isEmpty()) {
       return new ArrayList<>();
     }
diff --git a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy
index 78bf3787d..fb34f3ecd 100644
--- a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy
+++ b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy
@@ -8,6 +8,7 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.bundling.Jar
 import org.gradle.api.file.DuplicatesStrategy
 import javax.lang.model.SourceVersion
+import java.util.jar.JarFile
 
 /**
  * Gradle plugin for building fat agent JARs with embedded extensions.
@@ -306,6 +307,8 @@ class BTraceFatAgentPlugin implements Plugin<Project> {
      * Stage a resolved extension to the staging directory.
      */
     private void stageExtension(Project project, File stagingDir, ResolvedExtension ext) {
+        def manifestMetadata = readApiManifestMetadata(ext)
+
         // Stage API classes as .class files (for bootstrap)
         if (ext.apiJar?.exists()) {
             project.copy {
@@ -335,13 +338,54 @@ class BTraceFatAgentPlugin implements Plugin<Project> {
         propsFile.text = """\
 id=${ext.id}
 version=${ext.version}
-name=${ext.metadata?.getProperty('name') ?: ext.id}
-description=${ext.metadata?.getProperty('description') ?: ''}
+name=${manifestMetadata.name}
+description=${manifestMetadata.description}
+btrace.api.version=${manifestMetadata.btraceApiVersion}
+java.version=${manifestMetadata.javaVersion}
+services=${manifestMetadata.services}
+requires.extensions=${manifestMetadata.requiresExtensions}
+permissions=${manifestMetadata.permissions}
 """
 
         project.logger.info("[fat-agent] Created extension.properties for ${ext.id}")
     }
 
+    /**
+     * Reads metadata from the API JAR that a filesystem repository would use. Permission metadata
+     * is mandatory: silently replacing a missing attribute with an empty set would let embedded
+     * packaging bypass the privileged-extension policy.
+     */
+    private Map<String, String> readApiManifestMetadata(ResolvedExtension ext) {
+        if (ext.apiJar == null || !ext.apiJar.isFile()) {
+            throw new GradleException(
+                "[fat-agent] Cannot embed extension '${ext.id}': API JAR is missing, so permission metadata cannot be transferred")
+        }
+
+        try (JarFile apiJar = new JarFile(ext.apiJar)) {
+            def attrs = apiJar.manifest?.mainAttributes
+            def permissions = attrs?.getValue('BTrace-Extension-Permissions')
+            if (permissions == null) {
+                throw new GradleException(
+                    "[fat-agent] Cannot embed extension '${ext.id}': ${ext.apiJar.name} is missing BTrace-Extension-Permissions; refusing to default permissions to an empty set")
+            }
+            return [
+                name: attrs.getValue('BTrace-Extension-Name') ?: ext.metadata?.getProperty('name') ?: ext.id,
+                description: attrs.getValue('BTrace-Extension-Description') ?: ext.metadata?.getProperty('description') ?: '',
+                btraceApiVersion: attrs.getValue('BTrace-API-Version') ?: '3.0+',
+                javaVersion: attrs.getValue('BTrace-Java-Version') ?: '8+',
+                services: attrs.getValue('BTrace-Extension-Services') ?: '',
+                requiresExtensions: attrs.getValue('BTrace-Extension-Requires') ?: '',
+                permissions: permissions
+            ]
+        } catch (GradleException e) {
+            throw e
+        } catch (Exception e) {
+            throw new GradleException(
+                "[fat-agent] Cannot read permission metadata for extension '${ext.id}' from ${ext.apiJar}: ${e.message}",
+                e)
+        }
+    }
+
     /**
      * Compile probe sources using BTrace compiler.
      */


Test patch:
diff --git a/btrace-core/src/test/java/io/btrace/extension/impl/EmbeddedExtensionPermissionTest.java b/btrace-core/src/test/java/io/btrace/extension/impl/EmbeddedExtensionPermissionTest.java
new file mode 100644
index 000000000..ed9968f93
--- /dev/null
+++ b/btrace-core/src/test/java/io/btrace/extension/impl/EmbeddedExtensionPermissionTest.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * 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
+ *
+ *     https://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 io.btrace.extension.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.btrace.core.extensions.Permission;
+import io.btrace.extension.ExtensionDescriptorDTO;
+import io.btrace.extension.ExtensionLoader;
+import io.btrace.extension.PermissionPolicy;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class EmbeddedExtensionPermissionTest {
+  private static final String EXTENSION_ID = "embedded-privileged-test";
+  private static final String SERVICE = "test.ext.Service";
+
+  @TempDir Path tempDir;
+
+  @BeforeEach
+  void resetPolicy() {
+    PermissionPolicy policy = PermissionPolicy.get();
+    policy.setAllowExtensionsCsv("");
+    policy.setDenyExtensionsCsv("");
+    policy.setAllowPrivileged(false);
+  }
+
+  @Test
+  void embeddedAndFilesystemRepositoriesPreserveTheSamePermissions() throws Exception {
+    ExtensionDescriptorDTO embedded = scanEmbeddedExtension();
+    ExtensionDescriptorDTO filesystem = scanFilesystemExtension();
+
+    assertTrue(embedded.isEmbedded());
+    assertFalse(filesystem.isEmbedded());
+    assertEquals(filesystem.getRequiredPermissions(), embedded.getRequiredPermissions());
+    assertTrue(embedded.getRequiredPermissions().has(Permission.NETWORK));
+
+    TestLoader embeddedLoader = new TestLoader(embedded);
+    TestLoader filesystemLoader = new TestLoader(filesystem);
+    assertNull(new ExtensionBridgeImpl(embeddedLoader).getExtensionClass(SERVICE));
+    assertNull(new ExtensionBridgeImpl(filesystemLoader).getExtensionClass(SERVICE));
+    assertFalse(embeddedLoader.loaded);
+    assertFalse(filesystemLoader.loaded);
+  }
+
+  @Test
+  void embeddedExtensionPreservesRequiredExtensions() throws Exception {
+    ExtensionDescriptorDTO embedded = scanEmbeddedExtension();
+
+    assertEquals(
+        Collections.singletonList("embedded-prerequisite"), embedded.getRequiredExtensions());
+  }
+
+  @Test
+  void bridgeDeniesPrivilegedEmbeddedExtensionByDefault() throws Exception {
+    ExtensionDescriptorDTO embedded = scanEmbeddedExtension();
+    TestLoader loader = new TestLoader(embedded);
+
+    Class<?> implementation = new ExtensionBridgeImpl(loader).getExtensionClass(SERVICE);
+
+    assertNull(implementation);
+    assertFalse(loader.loaded, "denied embedded implementation must not be loaded");
+  }
+
+  @Test
+  void explicitPolicyAllowsPrivilegedEmbeddedExtension() throws Exception {
+    ExtensionDescriptorDTO embedded = scanEmbeddedExtension();
+    PermissionPolicy.get().setAllowExtensionsCsv(EXTENSION_ID);
+    TestLoader loader = new TestLoader(embedded);
+
+    Class<?> implementation = new ExtensionBridgeImpl(loader).getExtensionClass(SERVICE);
+
+    assertTrue(loader.loaded);
+    assertEquals("test.ext.SpiImpl", implementation.getName());
+  }
+
+  private ExtensionDescriptorDTO scanEmbeddedExtension() throws Exception {
+    Path jar = tempDir.resolve("embedded-extension.jar");
+    Manifest manifest = manifest();
+    manifest.getMainAttributes().putValue("BTrace-Embedded-Extensions", EXTENSION_ID);
+    String properties =
+        "id="
+            + EXTENSION_ID
+            + "\nversion=1.0.0\nname=Embedded privileged test\nservices="
+            + SERVICE
+            + "\nrequires.extensions=embedded-prerequisite\npermissions=NETWORK\n";
+    writeJar(
+        jar,
+        manifest,
+        "META-INF/btrace-extensions/" + EXTENSION_ID + "/extension.properties",
+        properties);
+
+    try (URLClassLoader resourceLoader =
+        new URLClassLoader(new URL[] {jar.toUri().toURL()}, getClass().getClassLoader())) {
+      List<ExtensionDescriptorDTO> descriptors =
+          new EmbeddedExtensionRepository(resourceLoader).scan();
+      assertEquals(1, descriptors.size());
+      return descriptors.get(0);
+    }
+  }
+
+  private ExtensionDescriptorDTO scanFilesystemExtension() throws IOException {
+    Path extensionDir = tempDir.resolve("filesystem").resolve(EXTENSION_ID);
+    Files.createDirectories(extensionDir);
+    Manifest manifest = manifest();
+    Attributes attributes = manifest.getMainAttributes();
+    attributes.putValue("BTrace-Extension-Id", EXTENSION_ID);
+    attributes.putValue("BTrace-Extension-Version", "1.0.0");
+    attributes.putValue("BTrace-Extension-Name", "Filesystem privileged test");
+    attributes.putValue("BTrace-Extension-Services", SERVICE);
+    attributes.putValue("BTrace-Extension-Permissions", "NETWORK");
+    writeJar(extensionDir.resolve("permission-test-api.jar"), manifest, null, null);
+
+    List<ExtensionDescriptorDTO> descriptors =
+        new FileSystemExtensionRepository(tempDir.resolve("filesystem"), 0).scan();
+    assertEquals(1, descriptors.size());
+    return descriptors.get(0);
+  }
+
+  private static Manifest manifest() {
+    Manifest manifest = new Manifest();
+    manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
+    return manifest;
+  }
+
+  private static void writeJar(Path jar, Manifest manifest, String entryName, String contents)
+      throws IOException {
+    try (OutputStream output = Files.newOutputStream(jar);
+        JarOutputStream jarOutput = new JarOutputStream(output, manifest)) {
+      if (entryName != null) {
+        jarOutput.putNextEntry(new JarEntry(entryName));
+        jarOutput.write(contents.getBytes(StandardCharsets.ISO_8859_1));
+        jarOutput.closeEntry();
+      }
+    }
+  }
+
+  private static final class TestLoader extends ExtensionLoader {
+    private final ExtensionDescriptorDTO descriptor;
+    private boolean loaded;
+
+    private TestLoader(ExtensionDescriptorDTO descriptor) {
+      this.descriptor = descriptor;
+    }
+
+    @Override
+    public ExtensionDescriptorDTO findExtensionForService(String serviceClassName) {
+      return descriptor;
+    }
+
+    @Override
+    public List<ExtensionDescriptorDTO> discoverExtensions() {
+      return Collections.singletonList(descriptor);
+    }
+
+    @Override
+    public Collection<ExtensionDescriptorDTO> getAvailableExtensions() {
+      return Collections.singletonList(descriptor);
+    }
+
+    @Override
+    public boolean ensureApiOnBootstrap(ExtensionDescriptorDTO ignored) {
+      return true;
+    }
+
+    @Override
+    public boolean load(ExtensionDescriptorDTO descriptor) {
+      loaded = true;
+      descriptor.setClassLoader(EmbeddedExtensionPermissionTest.class.getClassLoader());
+      return true;
+    }
+  }
+}
diff --git a/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java b/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java
index 50fcaf831..b6f95ebab 100644
--- a/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java
+++ b/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java
@@ -26,11 +26,16 @@
 import java.io.FileWriter;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Properties;
 import java.util.concurrent.TimeUnit;
+import java.util.jar.Attributes;
 import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
 import org.gradle.testkit.runner.BuildResult;
 import org.gradle.testkit.runner.GradleRunner;
 import org.gradle.testkit.runner.TaskOutcome;
@@ -503,6 +508,79 @@ void stagingDirectoryCreated() throws IOException {
         assertTrue(Files.exists(stagingDir), "Staging directory should be created");
     }
 
+    @Test
+    @DisplayName("Embedded packaging transfers privileged permission metadata")
+    void embeddedPackagingTransfersPermissions() throws IOException {
+        Path extensionDir = writeExtensionFixture("NETWORK");
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "btraceFatAgent {\n"
+                        + "    embedExtensions { file('"
+                        + extensionDir.toString().replace("\\", "/")
+                        + "') }\n"
+                        + "}\n");
+
+        BuildResult result = createRunner().withArguments("stageExtensions").build();
+
+        assertEquals(TaskOutcome.SUCCESS, result.task(":stageExtensions").getOutcome());
+        Properties staged = new Properties();
+        try (InputStream input =
+                Files.newInputStream(
+                        projectDir.resolve(
+                                "build/fat-agent-staging/META-INF/btrace-extensions/privileged-test/extension.properties"))) {
+            staged.load(input);
+        }
+        assertEquals("NETWORK", staged.getProperty("permissions"));
+        assertEquals("test.ext.Service", staged.getProperty("services"));
+    }
+
+    @Test
+    @DisplayName("Embedded packaging preserves an explicitly empty permission set")
+    void embeddedPackagingPreservesEmptyPermissions() throws IOException {
+        Path extensionDir = writeExtensionFixture("");
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "btraceFatAgent {\n"
+                        + "    embedExtensions { file('"
+                        + extensionDir.toString().replace("\\", "/")
+                        + "') }\n"
+                        + "}\n");
+
+        BuildResult result = createRunner().withArguments("stageExtensions").build();
+
+        assertEquals(TaskOutcome.SUCCESS, result.task(":stageExtensions").getOutcome());
+        Properties staged = new Properties();
+        try (InputStream input =
+                Files.newInputStream(
+                        projectDir.resolve(
+                                "build/fat-agent-staging/META-INF/btrace-extensions/privileged-test/extension.properties"))) {
+            staged.load(input);
+        }
+        assertEquals("", staged.getProperty("permissions"));
+    }
+
+    @Test
+    @DisplayName("Embedded packaging fails closed when permission metadata is missing")
+    void embeddedPackagingRejectsMissingPermissions() throws IOException {
+        Path extensionDir = writeExtensionFixture(null);
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "btraceFatAgent {\n"
+                        + "    embedExtensions { file('"
+                        + extensionDir.toString().replace("\\", "/")
+                        + "') }\n"
+                        + "}\n");
+
+        BuildResult result = createRunner().withArguments("stageExtensions").buildAndFail();
+
+        assertTrue(result.getOutput().contains("missing BTrace-Extension-Permissions"));
+        assertTrue(
+                result.getOutput().contains("refusing to default permissions to an empty set"));
+    }
+
     @Test
     @DisplayName("Registry source fails clearly for unknown extension id")
     void registrySourceFailsForUnknownId() throws IOException {
@@ -556,6 +634,33 @@ private void writeProbeSelection(String probeName) throws IOException {
                         + "}\n");
     }
 
+    private Path writeExtensionFixture(String permissions) throws IOException {
+        Path extensionDir = projectDir.resolve("privileged-extension");
+        Files.createDirectories(extensionDir);
+        Files.writeString(
+                extensionDir.resolve("extension.properties"),
+                "id=privileged-test\nversion=1.0.0\n",
+                StandardCharsets.UTF_8);
+
+        Manifest manifest = new Manifest();
+        Attributes attributes = manifest.getMainAttributes();
+        attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0");
+        attributes.putValue("BTrace-Extension-Id", "privileged-test");
+        attributes.putValue("BTrace-Extension-Version", "1.0.0");
+        attributes.putValue("BTrace-Extension-Name", "Privileged test");
+        attributes.putValue("BTrace-Extension-Services", "test.ext.Service");
+        if (permissions != null) {
+            attributes.putValue("BTrace-Extension-Permissions", permissions);
+        }
+        try (OutputStream output =
+                        Files.newOutputStream(
+                                extensionDir.resolve("privileged-test-api.jar"));
+                JarOutputStream ignored = new JarOutputStream(output, manifest)) {
+            // The manifest is the only content needed by this packaging fixture.
+        }
+        return extensionDir;
+    }
+
     private void writeFile(File file, String content) throws IOException {
         try (FileWriter writer = new FileWriter(file)) {
             writer.write(content);