Volver al índice
src/main/java/ar/com/companeros/ToolSafetyTests.java
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<ToolPermission> 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);
}
}