package ar.com.companeros; import ar.com.companeros.tools.PrimitiveExecutor; import ar.com.companeros.tools.ToolExecution; import ar.com.companeros.tools.ToolManifest; import ar.com.companeros.tools.ToolPermission; import ar.com.companeros.tools.ToolRegistry; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import java.io.IOException; import java.nio.file.Path; import java.time.Instant; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestHelper; import net.minecraftforge.gametest.GameTestHolder; import net.minecraftforge.gametest.PrefixGameTestTemplate; /** Pruebas de permisos y ejecución DSL; no sustituyen las pruebas físicas del actor. */ @GameTestHolder(Companeros.ID) @PrefixGameTestTemplate(false) public final class ToolSafetyTests { private static final UUID AUTHOR = UUID.fromString("00000000-0000-0000-0000-000000000123"); private static final UUID ACTOR = UUID.fromString("00000000-0000-0000-0000-000000000456"); @GameTest(template = "empty") public void rejectsOperatingSystemAndUnexpectedArguments(GameTestHelper helper) { ToolRegistry registry = registry(); expectRejected(helper, () -> registry.registerCertified(manifest("shell_tool", "shell", new JsonObject(), ToolPermission.WORLD_READ, 20, 10)), "No admite primitivas de sistema operativo"); JsonObject args = new JsonObject(); args.addProperty("command", "stop"); expectRejected(helper, () -> registry.registerCertified(manifest("command_tool", "inspect", args, ToolPermission.WORLD_READ, 20, 10)), "Una primitiva no admite comandos escondidos como argumentos"); helper.succeed(); } @GameTest(template = "empty") public void proposalsStayPendingAndAuthorIsTrusted(GameTestHelper helper) { ToolRegistry registry = registry(); ToolManifest proposed = manifest("pending_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10); try { registry.propose(AUTHOR, proposed.toJson().toString()); } catch (IOException exception) { throw new IllegalStateException(exception); } helper.assertTrue(registry.pendingProposals().get(0).manifest().author().equals(AUTHOR), "El autor proviene del contexto confiable"); expectRejected(helper, () -> registry.start(proposed.id(), proposed.version(), context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), completedExecutor(new AtomicInteger())), "Una propuesta pendiente no se ejecuta"); JsonObject forged = proposed.toJson(); forged.addProperty("author", ACTOR.toString()); expectRejected(helper, () -> ToolManifest.parse(forged.toString(), AUTHOR), "El JSON no puede falsificar el autor"); helper.succeed(); } @GameTest(template = "empty") public void missingPermissionStartsNoPrimitive(GameTestHelper helper) { ToolRegistry registry = registry(); ToolManifest tool = manifest("permission_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10); registry.registerCertified(tool); AtomicInteger started = new AtomicInteger(); expectRejected(helper, () -> registry.start(tool.id(), 1, context(Set.of(), new JsonObject()), completedExecutor(started)), "Comprueba los permisos antes de tocar el mundo"); helper.assertTrue(started.get() == 0, "No inicia acciones sin permisos"); helper.succeed(); } @GameTest(template = "empty") public void runtimeResolvesTypedInputsAndCountsResultOnce(GameTestHelper helper) { ToolRegistry registry = registry(); JsonObject args = position(0, 0, 0); JsonObject reference = new JsonObject(); reference.addProperty("input", "destination_x"); args.add("x", reference); ToolManifest tool = manifest("input_test", "move_to", args, ToolPermission.MOVE, 20, 10); registry.registerCertified(tool); JsonObject inputs = new JsonObject(); inputs.addProperty("destination_x", 12.5); AtomicInteger calls = new AtomicInteger(); PrimitiveExecutor executor = (actor, invocation, primitive, resolved) -> { helper.assertTrue(actor.equals(ACTOR) && resolved.get("x").getAsDouble() == 12.5, "Conserva actor y tipo del argumento"); calls.incrementAndGet(); return completedTask(); }; ToolExecution execution = registry.start(tool.id(), 1, context(Set.of(ToolPermission.MOVE), inputs), executor); helper.assertTrue(execution.tick(0) == ToolExecution.Status.SUCCEEDED, "Ejecuta una herramienta certificada"); execution.tick(0); execution.tick(1); execution.cancel(); helper.assertTrue(calls.get() == 1 && registry.statistics(tool.id(), 1).successes() == 1, "No duplica acciones ni estadísticas"); helper.succeed(); } @GameTest(template = "empty") public void timeoutsCancelActiveAction(GameTestHelper helper) { ToolRegistry registry = registry(); ToolManifest tool = manifest("timeout_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 3); registry.registerCertified(tool); AtomicInteger cancellations = new AtomicInteger(); PrimitiveExecutor executor = (actor, invocation, primitive, args) -> new PrimitiveExecutor.PrimitiveTask() { public PrimitiveExecutor.Result tick(long tick) { return PrimitiveExecutor.Result.running(.25); } public void cancel() { cancellations.incrementAndGet(); } }; ToolExecution execution = registry.start(tool.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), executor); helper.assertTrue(execution.tick(0) == ToolExecution.Status.RUNNING, "Puede ejecutar una acción larga localmente"); helper.assertTrue(execution.tick(3) == ToolExecution.Status.FAILED, "El tiempo del paso está acotado"); execution.cancel(); execution.tick(4); helper.assertTrue(cancellations.get() == 1 && registry.statistics(tool.id(), 1).failures() == 1, "Cancela y registra un solo resultado"); helper.succeed(); } @GameTest(template = "empty") public void certifiedVersionsAreImmutableAndDependenciesPinned(GameTestHelper helper) { ToolRegistry registry = registry(); ToolManifest child = manifest("dependency_child", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10); registry.registerCertified(child); JsonObject changed = child.toJson(); changed.addProperty("description", "Otro contenido"); expectRejected(helper, () -> registry.registerCertified(ToolManifest.parse(changed.toString(), AUTHOR)), "La misma versión no cambia de contenido"); JsonObject parentJson = composition("dependency_parent", child, child.sha256(), 20, 10); ToolManifest parent = ToolManifest.parse(parentJson.toString(), AUTHOR); registry.registerCertified(parent); AtomicInteger calls = new AtomicInteger(); ToolExecution execution = registry.start(parent.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), completedExecutor(calls)); helper.assertTrue(execution.tick(0) == ToolExecution.Status.SUCCEEDED && calls.get() == 1, "Reutiliza una dependencia certificada"); expectRejected(helper, () -> registry.registerCertified(ToolManifest.parse(composition("wrong_hash", child, "0".repeat(64), 20, 10).toString(), AUTHOR)), "Un hash diferente impide ejecutar una dependencia"); helper.succeed(); } @GameTest(template = "empty") public void nestedCompositionHonorsCallTimeout(GameTestHelper helper) { ToolRegistry registry = registry(); ToolManifest child = manifest("nested_child", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 15); registry.registerCertified(child); ToolManifest parent = ToolManifest.parse(composition("nested_parent", child, child.sha256(), 30, 2).toString(), AUTHOR); registry.registerCertified(parent); ToolExecution execution = registry.start(parent.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), (actor, invocation, primitive, args) -> new PrimitiveExecutor.PrimitiveTask() { public PrimitiveExecutor.Result tick(long tick) { return PrimitiveExecutor.Result.running(0); } public void cancel() { } }); helper.assertTrue(execution.tick(0) == ToolExecution.Status.RUNNING && execution.tick(2) == ToolExecution.Status.FAILED, "La composición no permite evadir el límite de su llamada"); helper.succeed(); } @GameTest(template = "empty") public void cancellationStopsSequenceAndPersistsStatistics(GameTestHelper helper) { ToolRegistry registry = registry(); ToolManifest original = manifest("cancel_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10); JsonObject source = original.toJson(); source.getAsJsonArray("steps").add(source.getAsJsonArray("steps").get(0).deepCopy()); ToolManifest tool = ToolManifest.parse(source.toString(), AUTHOR); registry.registerCertified(tool); AtomicInteger calls = new AtomicInteger(); ToolExecution execution = registry.start(tool.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), completedExecutor(calls)); helper.assertTrue(execution.tick(0) == ToolExecution.Status.RUNNING && calls.get() == 1, "Inicia como máximo un paso por tick"); execution.cancel(); execution.tick(1); helper.assertTrue(calls.get() == 1 && registry.statistics(tool.id(), 1).cancellations() == 1, "No continúa luego de cancelar"); try { registry.saveStatistics(); } catch (IOException exception) { throw new IllegalStateException(exception); } helper.succeed(); } @GameTest(template = "empty") public void certificationRequiresMatchingTestEvidence(GameTestHelper helper) { ToolRegistry registry = registry(); ToolManifest tool = manifest("review_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10); try { registry.propose(AUTHOR, tool.toJson().toString()); } catch (IOException exception) { throw new IllegalStateException(exception); } expectRejected(helper, () -> registry.certify(tool.sha256(), ACTOR, new ToolRegistry.TestEvidence("0".repeat(64), "sandbox", true, Instant.now())), "No acepta evidencia de otro contenido"); expectRejected(helper, () -> registry.certify(tool.sha256(), ACTOR, new ToolRegistry.TestEvidence(tool.sha256(), "sandbox", false, Instant.now())), "No acepta una prueba fallida"); registry.certify(tool.sha256(), ACTOR, new ToolRegistry.TestEvidence(tool.sha256(), "sandbox", true, Instant.now())); helper.assertTrue(registry.pendingProposals().isEmpty() && registry.certifiedTools().size() == 1, "Solo la promoción explícita concede ejecución"); helper.succeed(); } @GameTest(template = "empty") public void fileLoadCannotGrantCertification(GameTestHelper helper) { Path directory = testDirectory(); ToolRegistry first = registry(directory); ToolManifest tool = manifest("reload_test", "inspect", new JsonObject(), ToolPermission.WORLD_READ, 20, 10); try { first.propose(AUTHOR, tool.toJson().toString()); ToolRegistry loaded = new ToolRegistry(directory); ToolRegistry.LoadReport report = loaded.loadProposals(); helper.assertTrue(report.loaded() == 1 && report.rejected().isEmpty(), "Recupera la propuesta persistente"); helper.assertTrue(loaded.certifiedTools().isEmpty() && loaded.pendingProposals().size() == 1, "Los archivos no conceden aprobación"); expectRejected(helper, () -> loaded.start(tool.id(), 1, context(Set.of(ToolPermission.WORLD_READ), new JsonObject()), completedExecutor(new AtomicInteger())), "El reinicio no habilita propuestas pendientes"); } catch (IOException exception) { throw new IllegalStateException(exception); } helper.succeed(); } @GameTest(template = "empty") public void internalChatAcceptsAllNamesAndCanonicalUuid(GameTestHelper helper) { ToolRegistry registry = registry(); JsonObject args = new JsonObject(); JsonObject recipientReference = new JsonObject(); recipientReference.addProperty("input", "recipient"); args.add("recipient", recipientReference); args.addProperty("text", "Encontré hierro"); ToolManifest tool = manifest("chat_recipients", "message_internal", args, ToolPermission.INTERNAL_CHAT, 20, 10); registry.registerCertified(tool); AtomicInteger calls = new AtomicInteger(); for (String recipient : new String[] { "all", "Helio", ACTOR.toString() }) { JsonObject inputs = new JsonObject(); inputs.addProperty("recipient", recipient); ToolExecution execution = registry.start(tool.id(), 1, context(Set.of(ToolPermission.INTERNAL_CHAT), inputs), (actor, invocation, primitive, resolved) -> { helper.assertTrue(resolved.get("recipient").getAsString().equals(recipient), "Conserva el destino como dato de comunicación"); calls.incrementAndGet(); return completedTask(); }); helper.assertTrue(execution.tick(0) == ToolExecution.Status.SUCCEEDED, "El catálogo acepta destinos soportados por el chat interno"); } helper.assertTrue(calls.get() == 3, "Los tres formatos de destino llegan al ejecutor"); helper.succeed(); } @GameTest(template = "empty") public void chatDestinationCannotInjectCommandsOrSelectSender(GameTestHelper helper) { ToolRegistry registry = registry(); JsonObject args = new JsonObject(); JsonObject reference = new JsonObject(); reference.addProperty("input", "recipient"); args.add("recipient", reference); args.addProperty("text", "Mensaje"); ToolManifest tool = manifest("chat_invalid_destination", "message_internal", args, ToolPermission.INTERNAL_CHAT, 20, 10); registry.registerCertified(tool); AtomicInteger calls = new AtomicInteger(); for (String recipient : new String[] { "/botmalo", "x".repeat(17), "../secrets" }) { JsonObject inputs = new JsonObject(); inputs.addProperty("recipient", recipient); expectRejected(helper, () -> registry.start(tool.id(), 1, context(Set.of(ToolPermission.INTERNAL_CHAT), inputs), completedExecutor(calls)), "El destino no puede convertirse en comando ni ruta"); } JsonObject forged = new JsonObject(); forged.addProperty("recipient", "all"); forged.addProperty("text", "Mensaje"); forged.addProperty("sender", AUTHOR.toString()); expectRejected(helper, () -> registry.registerCertified(manifest("chat_forged_sender", "message_internal", forged, ToolPermission.INTERNAL_CHAT, 20, 10)), "El remitente pertenece al contexto confiable y no a los argumentos"); JsonObject target = new JsonObject(); target.addProperty("target", "Helio"); expectRejected(helper, () -> registry.registerCertified(manifest("attack_name", "attack", target, ToolPermission.COMBAT, 20, 10)), "El ataque conserva su requisito de UUID"); helper.assertTrue(calls.get() == 0, "Los argumentos inválidos no inician primitivas"); helper.succeed(); } // Helpers: modelos y contadores solamente; no escriben bloques ni inventarios. private static ToolRegistry registry() { return registry(testDirectory()); } private static Path testDirectory() { return Path.of("tool-test-output", UUID.randomUUID().toString()); } private static ToolRegistry registry(Path path) { try { return new ToolRegistry(path); } catch (IOException exception) { throw new IllegalStateException(exception); } } private static ToolRegistry.ExecutionContext context(Set permissions, JsonObject inputs) { return new ToolRegistry.ExecutionContext(ACTOR, 0, permissions, inputs); } private static ToolManifest manifest(String id, String primitive, JsonObject args, ToolPermission permission, int maxTicks, int timeout) { JsonObject source = new JsonObject(); source.addProperty("schema", 1); source.addProperty("id", id); source.addProperty("version", 1); source.addProperty("description", "Prueba de herramienta acotada"); JsonArray permissions = new JsonArray(); permissions.add(permission.name()); source.add("permissions", permissions); source.add("dependencies", new JsonArray()); JsonObject limits = new JsonObject(); limits.addProperty("maxTicks", maxTicks); limits.addProperty("maxSteps", 16); source.add("limits", limits); JsonObject step = new JsonObject(); step.addProperty("primitive", primitive); step.add("args", args.deepCopy()); step.addProperty("timeoutTicks", timeout); JsonArray steps = new JsonArray(); steps.add(step); source.add("steps", steps); return ToolManifest.parse(source.toString(), AUTHOR); } private static JsonObject composition(String id, ToolManifest child, String hash, int maxTicks, int timeout) { JsonObject source = manifest(id, "inspect", new JsonObject(), ToolPermission.WORLD_READ, maxTicks, timeout).toJson(); JsonObject dependency = new JsonObject(); dependency.addProperty("id", child.id()); dependency.addProperty("version", child.version()); dependency.addProperty("sha256", hash); JsonArray dependencies = new JsonArray(); dependencies.add(dependency); source.add("dependencies", dependencies); JsonObject call = new JsonObject(); call.addProperty("tool", child.id()); call.addProperty("version", child.version()); call.addProperty("sha256", hash); call.add("args", new JsonObject()); call.addProperty("timeoutTicks", timeout); JsonArray steps = new JsonArray(); steps.add(call); source.add("steps", steps); return source; } private static JsonObject position(int x, int y, int z) { JsonObject args = new JsonObject(); args.addProperty("x", x); args.addProperty("y", y); args.addProperty("z", z); return args; } private static PrimitiveExecutor completedExecutor(AtomicInteger started) { return (actor, invocation, primitive, arguments) -> { started.incrementAndGet(); return completedTask(); }; } private static PrimitiveExecutor.PrimitiveTask completedTask() { return new PrimitiveExecutor.PrimitiveTask() { public PrimitiveExecutor.Result tick(long tick) { return PrimitiveExecutor.Result.success(); } public void cancel() { } }; } private static void expectRejected(GameTestHelper helper, Runnable action, String message) { boolean rejected = false; try { action.run(); } catch (IllegalArgumentException expected) { rejected = true; } helper.assertTrue(rejected, message); } }