diff --git a/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/JavabuilderSecurityPolicy.java b/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/JavabuilderSecurityPolicy.java
new file mode 100644
index 00000000..55188cb3
--- /dev/null
+++ b/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/JavabuilderSecurityPolicy.java
@@ -0,0 +1,142 @@
+package org.code.javabuilder;
+
+import java.io.File;
+import java.io.FilePermission;
+import java.net.URL;
+import java.security.CodeSource;
+import java.security.Permission;
+import java.security.Policy;
+import java.security.ProtectionDomain;
+import java.security.cert.Certificate;
+import java.util.List;
+
+/**
+ * Security policy that confines student code at runtime while leaving all framework, JDK, and AWS
+ * SDK code fully trusted. Student code is identified by being loaded from a {@link
+ * UserClassLoader}. This covers both standard USER runs and the VALIDATOR-level validation run,
+ * which executes student code (the student's main method) within the same class loader.
+ *
+ *
For confined student code this policy:
+ *
+ *
+ * - allows read/write/delete only under the writable temp directory,
+ *
- allows read-only access to the app/runtime directories the JVM and student libraries need
+ * (so class loading, fonts, and bundled assets keep working),
+ *
- never allows execute
+ *
- denies all other filesystem access
+ *
+ *
+ * NOTE: {@link SecurityManager} / {@link Policy} are deprecated in Java 17 and removed in Java
+ * 21. This control is valid on the current java11 runtime only.
+ */
+public class JavabuilderSecurityPolicy extends Policy {
+ private static final String ALL_FILES = "<>";
+
+ // The only root confined student code may write to / delete within.
+ private final String writableRoot;
+ // Roots confined student code may read from (a superset of writableRoot). Everything the JVM
+ // needs to load classes, fonts, and library assets lives under these
+ private final String[] readableRoots;
+
+ public JavabuilderSecurityPolicy() {
+ this.writableRoot = canonicalize(System.getProperty("java.io.tmpdir"));
+ this.readableRoots =
+ new String[] {
+ this.writableRoot,
+ canonicalize(System.getProperty("java.home")), // JRE: class/resource loading
+ "/var/task", // deployed application code + dependency jars
+ "/var/lang", // managed runtime
+ "/opt" // Lambda layers: fonts, instrument samples, wrapper
+ };
+ // Warm up here, at construction, so it is guaranteed to run before the caller installs a
+ // SecurityManager.
+ warmUp();
+ }
+
+ @Override
+ public boolean implies(ProtectionDomain domain, Permission permission) {
+ if (!isConfinedStudentCode(domain)) {
+ // Framework / JDK / AWS SDK code: full trust.
+ return true;
+ }
+ if (permission instanceof FilePermission) {
+ return isAllowedFileAccess((FilePermission) permission);
+ }
+ return true;
+ }
+
+ /**
+ * Forces every class that #implies references to load. Called from the constructor so it always
+ * runs before a SecurityManager is installed.
+ *
+ * #implies runs on every permission check, including the File.exists() calls
+ * the class loader makes to locate a class. If a class #implies references
+ * is still unloaded when the first such check fires, loading it re-enters
+ * #implies before the load completes and throws ClassCircularityError. Exercising
+ * the policy here, while no SecurityManager is active, guarantees those classes are already
+ * loaded by the time checks begin.
+ */
+ private void warmUp() {
+ final ProtectionDomain studentDomain =
+ new ProtectionDomain(
+ new CodeSource(null, (Certificate[]) null),
+ null,
+ new UserClassLoader(
+ new URL[] {},
+ ClassLoader.getSystemClassLoader(),
+ List.of(),
+ RunPermissionLevel.USER),
+ null);
+ this.implies(studentDomain, new FilePermission("warmup", "read"));
+ this.implies(studentDomain, new FilePermission("warmup", "write"));
+ }
+
+ private boolean isConfinedStudentCode(ProtectionDomain domain) {
+ // Confine any code loaded by a UserClassLoader. This covers both standard USER runs and the
+ // VALIDATOR-level validation run: validation loads and invokes the student's code (via the
+ // student's main method) in the same class loader, so this ensures student code is confined
+ // there too, not just in RUN and user-test modes.
+ return domain != null && domain.getClassLoader() instanceof UserClassLoader;
+ }
+
+ private boolean isAllowedFileAccess(FilePermission permission) {
+ if (ALL_FILES.equals(permission.getName())) {
+ return false;
+ }
+ final String path = canonicalize(permission.getName());
+ if (path == null) {
+ return false;
+ }
+ final String actions = permission.getActions();
+ if (actions.contains("execute")) {
+ return false;
+ }
+ if (actions.contains("write") || actions.contains("delete")) {
+ return isUnder(path, this.writableRoot);
+ }
+ // read / readlink
+ for (String root : this.readableRoots) {
+ if (root != null && isUnder(path, root)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isUnder(String path, String root) {
+ return path.equals(root) || path.startsWith(root + File.separator);
+ }
+
+ private static String canonicalize(String path) {
+ if (path == null) {
+ return null;
+ }
+ try {
+ // getCanonicalPath() does not itself trigger a SecurityManager check, so this does not
+ // recurse; resolving symlinks prevents a /tmp/link -> /etc escape.
+ return new File(path).getCanonicalPath();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/LambdaRequestHandler.java b/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/LambdaRequestHandler.java
index b9c86a0a..c70f0360 100644
--- a/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/LambdaRequestHandler.java
+++ b/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/LambdaRequestHandler.java
@@ -20,6 +20,7 @@
import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
+import java.security.Policy;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
@@ -74,6 +75,13 @@ public LambdaRequestHandler() {
// This will only be called once in the initial creation of the lambda instance.
// Documentation: https://docs.aws.amazon.com/lambda/latest/dg/java-handler.html
CachedResources.create();
+
+ // Install the security policy once for the entire container. The policy scopes itself to code
+ // loaded by a UserClassLoader, so a fresh UserClassLoader per run means confinement is applied
+ // per run without any per-invocation setup.
+ final JavabuilderSecurityPolicy securityPolicy = new JavabuilderSecurityPolicy();
+ Policy.setPolicy(securityPolicy);
+ System.setSecurityManager(new SecurityManager());
COLD_BOOT_END = Clock.systemUTC().instant();
this.apiClient =
AmazonApiGatewayManagementApiClientBuilder.standard()
diff --git a/org-code-javabuilder/lib/src/test/java/org/code/javabuilder/JavabuilderSecurityPolicyTest.java b/org-code-javabuilder/lib/src/test/java/org/code/javabuilder/JavabuilderSecurityPolicyTest.java
new file mode 100644
index 00000000..825d8dd0
--- /dev/null
+++ b/org-code-javabuilder/lib/src/test/java/org/code/javabuilder/JavabuilderSecurityPolicyTest.java
@@ -0,0 +1,111 @@
+package org.code.javabuilder;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.FilePermission;
+import java.net.URL;
+import java.security.CodeSource;
+import java.security.ProtectionDomain;
+import java.security.cert.Certificate;
+import java.util.List;
+import java.util.PropertyPermission;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class JavabuilderSecurityPolicyTest {
+ private JavabuilderSecurityPolicy policy;
+ private ProtectionDomain userDomain;
+ private ProtectionDomain validatorDomain;
+ private ProtectionDomain frameworkDomain;
+
+ @BeforeEach
+ public void setUp() {
+ policy = new JavabuilderSecurityPolicy();
+ userDomain = domainFor(userClassLoader(RunPermissionLevel.USER));
+ validatorDomain = domainFor(userClassLoader(RunPermissionLevel.VALIDATOR));
+ frameworkDomain = domainFor(JavabuilderSecurityPolicyTest.class.getClassLoader());
+ }
+
+ @Test
+ public void studentCanReadAndWriteUnderTmp() {
+ final String tmpFile = System.getProperty("java.io.tmpdir") + "/student-output.txt";
+ assertTrue(policy.implies(userDomain, new FilePermission(tmpFile, "read")));
+ assertTrue(policy.implies(userDomain, new FilePermission(tmpFile, "write")));
+ assertTrue(policy.implies(userDomain, new FilePermission(tmpFile, "delete")));
+ }
+
+ @Test
+ public void studentCannotReadOutsideAllowedRoots() {
+ assertFalse(policy.implies(userDomain, new FilePermission("/etc/passwd", "read")));
+ assertFalse(policy.implies(userDomain, new FilePermission("/proc/self/environ", "read")));
+ assertFalse(policy.implies(userDomain, new FilePermission("<>", "read")));
+ }
+
+ @Test
+ public void studentCannotWriteOutsideTmp() {
+ // java.home is readable but not writable for student code.
+ final String runtimeFile = System.getProperty("java.home") + "/lib/evil.jar";
+ assertTrue(policy.implies(userDomain, new FilePermission(runtimeFile, "read")));
+ assertFalse(policy.implies(userDomain, new FilePermission(runtimeFile, "write")));
+ }
+
+ @Test
+ public void studentCanReadRuntimeDirectoriesForClassLoading() {
+ final String runtimeFile = System.getProperty("java.home") + "/lib/modules";
+ assertTrue(policy.implies(userDomain, new FilePermission(runtimeFile, "read")));
+ }
+
+ @Test
+ public void studentRetainsNonFilePermissions() {
+ // Filesystem access is the only thing confined; other capabilities are preserved. getenv is
+ // intentionally NOT denied because the AWS SDK resolves credentials via getenv while student
+ // code is on the call stack (e.g. flushing a println through the output adapter).
+ assertTrue(policy.implies(userDomain, new RuntimePermission("modifyThread")));
+ assertTrue(policy.implies(userDomain, new PropertyPermission("user.language", "read")));
+ assertTrue(policy.implies(userDomain, new RuntimePermission("getenv.AWS_SECRET_ACCESS_KEY")));
+ }
+
+ @Test
+ public void studentCannotExecuteEvenUnderTmp() {
+ final String tmpBinary = System.getProperty("java.io.tmpdir") + "/x";
+ assertFalse(policy.implies(userDomain, new FilePermission(tmpBinary, "execute")));
+ assertFalse(policy.implies(userDomain, new FilePermission(tmpBinary, "read,execute")));
+ // Execute is denied everywhere, including the otherwise-readable runtime roots.
+ final String runtimeBinary = System.getProperty("java.home") + "/bin/java";
+ assertFalse(policy.implies(userDomain, new FilePermission(runtimeBinary, "execute")));
+ assertFalse(policy.implies(validatorDomain, new FilePermission(tmpBinary, "execute")));
+ }
+
+ @Test
+ public void validatorRunIsAlsoConfined() {
+ // The validation run loads and executes student code in a VALIDATOR-level UserClassLoader, so
+ // it must be confined the same way as a USER run.
+ final String tmpFile = System.getProperty("java.io.tmpdir") + "/validation-output.txt";
+ assertTrue(policy.implies(validatorDomain, new FilePermission(tmpFile, "write")));
+ assertFalse(policy.implies(validatorDomain, new FilePermission("/etc/passwd", "read")));
+ }
+
+ @Test
+ public void constructorWarmUpDoesNotThrowAndPolicyStillEnforces() {
+ // Construction warms the policy up; it must not throw and must not weaken enforcement.
+ final JavabuilderSecurityPolicy freshPolicy = assertDoesNotThrow(JavabuilderSecurityPolicy::new);
+ assertFalse(freshPolicy.implies(userDomain, new FilePermission("/etc/passwd", "read")));
+ }
+
+ @Test
+ public void frameworkCodeIsFullyTrusted() {
+ assertTrue(policy.implies(frameworkDomain, new FilePermission("/etc/passwd", "read,write")));
+ assertTrue(
+ policy.implies(frameworkDomain, new RuntimePermission("getenv.AWS_SECRET_ACCESS_KEY")));
+ }
+
+ private UserClassLoader userClassLoader(RunPermissionLevel level) {
+ return new UserClassLoader(
+ new URL[] {}, JavabuilderSecurityPolicyTest.class.getClassLoader(), List.of(), level);
+ }
+
+ private ProtectionDomain domainFor(ClassLoader classLoader) {
+ return new ProtectionDomain(
+ new CodeSource(null, (Certificate[]) null), null, classLoader, null);
+ }
+}