feat(assets): verify ready exports and fix sandbox bridge

This commit is contained in:
2026-08-15 09:45:51 +02:00
parent 4ff160ee73
commit aa3a925e5a
6 changed files with 175 additions and 14 deletions
+10 -2
View File
@@ -42,6 +42,14 @@ tasks.register<JavaExec>("validateFanContent") {
args("validate", fanContentRoot.asFile.absolutePath)
}
tasks.named("check") {
dependsOn("validateFanContent")
tasks.register<JavaExec>("validateReadyFanAssets") {
group = "verification"
description = "Refuses READY catalog records whose Blockbench export artifacts are absent or malformed."
classpath = sourceSets.main.get().runtimeClasspath
mainClass = application.mainClass
args("ready-assets", fanContentRoot.asFile.absolutePath, rootProject.layout.projectDirectory.asFile.absolutePath)
}
tasks.named("check") {
dependsOn("validateFanContent", "validateReadyFanAssets")
}
@@ -15,7 +15,7 @@ public final class ContentTool {
}
static int run(String[] args, PrintStream out, PrintStream err) {
if (args.length != 2) {
if ((args.length != 2) && (args.length != 3 || !"ready-assets".equals(args[0]))) {
printUsage(err);
return 64;
}
@@ -24,6 +24,7 @@ public final class ContentTool {
return switch (args[0]) {
case "validate" -> validate(contentRoot, out, err);
case "digest" -> digest(contentRoot, out, err);
case "ready-assets" -> validateReadyAssets(contentRoot, Path.of(args[2]), out, err);
default -> {
err.println("Unknown command: " + args[0]);
printUsage(err);
@@ -59,9 +60,21 @@ public final class ContentTool {
}
}
private static int validateReadyAssets(Path contentRoot, Path projectRoot, PrintStream out, PrintStream err) {
ValidationReport report = new ReadyAssetValidator().validate(contentRoot, projectRoot);
if (!report.isValid()) {
err.println("Ready asset validation failed with " + report.issues().size() + " issue(s):");
report.issues().forEach(issue -> err.println("- " + issue));
return 2;
}
out.println("Ready asset validation passed: " + contentRoot);
return 0;
}
private static void printUsage(PrintStream stream) {
stream.println("Usage:");
stream.println(" content-tools validate <content-root>");
stream.println(" content-tools digest <content-root>");
stream.println(" content-tools ready-assets <content-root> <project-root>");
}
}
@@ -0,0 +1,108 @@
package dev.projectcapture.contenttools;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Verifies the on-disk deliverables for content which claims to be ready for
* shipping. Planned records deliberately remain data-only while artists work.
*/
public final class ReadyAssetValidator {
private static final byte[] PNG_SIGNATURE = new byte[] {
(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
};
public ValidationReport validate(Path contentRoot, Path projectRoot) {
ValidationReport report = new ValidationReport();
Path normalizedProjectRoot = projectRoot.toAbsolutePath().normalize();
try {
ContentBundle bundle = ContentBundle.load(contentRoot);
JsonNode assets = bundle.document("assets.json").path("items");
for (int index = 0; index < assets.size(); index++) {
JsonNode asset = assets.path(index);
if (!"READY".equals(asset.path("status").asText())) {
continue;
}
String location = "assets.json.items[" + index + "]";
validateJsonArtifact(asset.path("source").asText(), normalizedProjectRoot, location + ".source", report);
validateJsonArtifact(asset.path("model").asText(), normalizedProjectRoot, location + ".model", report);
validatePng(asset.path("texture").asText(), normalizedProjectRoot, location + ".texture", report);
validateJsonArtifact(asset.path("recipe").asText(), normalizedProjectRoot, location + ".recipe", report);
JsonNode animations = asset.path("animations");
for (String name : List.of("idle", "walk", "attack")) {
validateJsonArtifact(animations.path(name).asText(), normalizedProjectRoot,
location + ".animations." + name, report);
}
}
} catch (IOException | RuntimeException exception) {
report.add("READY_ASSET_CATALOG", "assets.json", exception.getMessage());
}
return report;
}
private void validateJsonArtifact(String declaredPath, Path projectRoot, String location, ValidationReport report) {
Path artifact = resolve(declaredPath, projectRoot, location, report);
if (artifact == null || !requireNonEmptyFile(artifact, location, report)) {
return;
}
try {
if (ContentBundle.json().readTree(artifact.toFile()) == null) {
report.add("READY_ASSET_JSON", location, "Artifact is empty JSON: " + declaredPath);
}
} catch (IOException exception) {
report.add("READY_ASSET_JSON", location, "Artifact is not valid JSON: " + declaredPath);
}
}
private void validatePng(String declaredPath, Path projectRoot, String location, ValidationReport report) {
Path artifact = resolve(declaredPath, projectRoot, location, report);
if (artifact == null || !requireNonEmptyFile(artifact, location, report)) {
return;
}
try {
byte[] bytes = Files.readAllBytes(artifact);
if (bytes.length < PNG_SIGNATURE.length) {
report.add("READY_ASSET_PNG", location, "Texture is too small to be a PNG: " + declaredPath);
return;
}
for (int index = 0; index < PNG_SIGNATURE.length; index++) {
if (bytes[index] != PNG_SIGNATURE[index]) {
report.add("READY_ASSET_PNG", location, "Texture has no PNG signature: " + declaredPath);
return;
}
}
} catch (IOException exception) {
report.add("READY_ASSET_PNG", location, "Could not inspect texture: " + declaredPath);
}
}
private Path resolve(String declaredPath, Path projectRoot, String location, ValidationReport report) {
if (declaredPath.isBlank()) {
report.add("READY_ASSET_PATH", location, "Ready asset is missing a declared path");
return null;
}
Path artifact = projectRoot.resolve(declaredPath).normalize();
if (!artifact.startsWith(projectRoot)) {
report.add("READY_ASSET_PATH", location, "Artifact path escapes project root: " + declaredPath);
return null;
}
return artifact;
}
private boolean requireNonEmptyFile(Path artifact, String location, ValidationReport report) {
try {
if (!Files.isRegularFile(artifact) || Files.size(artifact) == 0) {
report.add("READY_ASSET_MISSING", location, "Required generated artifact is missing: " + artifact);
return false;
}
return true;
} catch (IOException exception) {
report.add("READY_ASSET_MISSING", location, "Could not inspect generated artifact: " + artifact);
return false;
}
}
}
@@ -49,6 +49,17 @@ class ContentValidatorTest {
assertEquals(ContentValidator.UNOFFICIAL_SLICE_SPECIES, ids);
}
@Test
void readyAssetRecordsRequireTheirGeneratedFiles() throws IOException {
ObjectNode assets = object("assets.json");
((ObjectNode) assets.path("items").path(0)).put("status", "READY");
write("assets.json", assets);
ValidationReport report = new ReadyAssetValidator().validate(contentRoot, temporaryDirectory);
assertTrue(report.containsCode("READY_ASSET_MISSING"));
}
@Test
void allTwelveSpeciesUseEngineElementRarityAndCaptureSchema() throws IOException {
JsonNode species = ContentBundle.load(contentRoot).document("species.json").path("items");
@@ -395,7 +395,7 @@ new file mode 100644
index 0000000..eda0255
--- /dev/null
+++ b/lib/projectcaptureSecurity.ts
@@ -0,0 +1,202 @@
@@ -0,0 +1,223 @@
+import path from "node:path";
+
+export const PROJECTCAPTURE_LOOPBACK_HOST = "127.0.0.1" as const;
@@ -411,15 +411,36 @@ index 0000000..eda0255
+
+export interface AssetSandboxFs {
+ existsSync(path: string): boolean;
+ lstatSync(path: string): {
+ lstatSync?(path: string): {
+ isDirectory(): boolean;
+ isFile(): boolean;
+ isSymbolicLink(): boolean;
+ };
+ statSync?(path: string): {
+ isDirectory(): boolean;
+ isFile(): boolean;
+ };
+ realpathSync(path: string): string;
+ mkdirSync(path: string, options?: { mode?: number }): unknown;
+}
+
+type AssetStat = {
+ isDirectory(): boolean;
+ isFile(): boolean;
+ isSymbolicLink?(): boolean;
+};
+
+function inspectPath(pathname: string, fs: AssetSandboxFs): AssetStat {
+ if (typeof fs.lstatSync === "function") return fs.lstatSync(pathname);
+ if (typeof fs.statSync === "function") return fs.statSync(pathname);
+ throw new Error("Blockbench filesystem bridge does not expose a safe stat operation.");
+}
+
+function isSymbolicLink(pathname: string, stat: AssetStat, fs: AssetSandboxFs): boolean {
+ if (typeof stat.isSymbolicLink === "function") return stat.isSymbolicLink();
+ return fs.realpathSync(pathname) !== path.resolve(pathname);
+}
+
+function requireSecretToken(value: string | undefined): string {
+ const token = value?.trim();
+ if (!token || token.length < PROJECTCAPTURE_MIN_TOKEN_LENGTH) {
@@ -520,8 +541,8 @@ index 0000000..eda0255
+ if (!fs.existsSync(root)) {
+ throw new Error("Configured ProjectCapture asset root does not exist.");
+ }
+ const rootStat = fs.lstatSync(root);
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
+ const rootStat = inspectPath(root, fs);
+ if (isSymbolicLink(root, rootStat, fs) || !rootStat.isDirectory()) {
+ throw new Error("Configured ProjectCapture asset root must be a real directory, not a symlink.");
+ }
+ return fs.realpathSync(root);
@@ -541,8 +562,8 @@ index 0000000..eda0255
+ cursor = path.join(cursor, segment);
+ assertInsideRoot(root, cursor);
+ if (fs.existsSync(cursor)) {
+ const stat = fs.lstatSync(cursor);
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
+ const stat = inspectPath(cursor, fs);
+ if (isSymbolicLink(cursor, stat, fs) || !stat.isDirectory()) {
+ throw new Error("Asset path contains a symlink or non-directory parent.");
+ }
+ } else {
@@ -568,8 +589,8 @@ index 0000000..eda0255
+ const candidate = path.resolve(root, relativePath);
+ assertInsideRoot(root, candidate);
+ if (fs.existsSync(candidate)) {
+ const stat = fs.lstatSync(candidate);
+ if (stat.isSymbolicLink() || !stat.isFile()) {
+ const stat = inspectPath(candidate, fs);
+ if (isSymbolicLink(candidate, stat, fs) || !stat.isFile()) {
+ throw new Error("Asset target must be a regular file and must not be a symlink.");
+ }
+ }
@@ -590,8 +611,8 @@ index 0000000..eda0255
+ if (!fs.existsSync(lexicalCandidate)) {
+ throw new Error("Requested asset does not exist.");
+ }
+ const stat = fs.lstatSync(lexicalCandidate);
+ if (stat.isSymbolicLink() || !stat.isFile()) {
+ const stat = inspectPath(lexicalCandidate, fs);
+ if (isSymbolicLink(lexicalCandidate, stat, fs) || !stat.isFile()) {
+ throw new Error("Requested asset must be a regular file and must not be a symlink.");
+ }
+ const realCandidate = fs.realpathSync(lexicalCandidate);
+1 -1
View File
@@ -5,7 +5,7 @@ TOOL_ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
MCP_REPOSITORY="https://github.com/jasonjgardner/blockbench-mcp-plugin.git"
MCP_COMMIT="6b069e308fdfc9b0a1c15bc924ca78150815f143"
MCP_PATCH="${TOOL_ROOT}/patches/0001-projectcapture-hardening.patch"
MCP_PATCH_SHA256="9de0b3767e96db7a68ef4c7082d7c76bf140b4932fc9550ad6e99d9a24a048c3"
MCP_PATCH_SHA256="092d5446fcdd1f28d6abcc931be2895727fd9ab3dd56728874b6847a614ed8fa"
BUN_VERSION="1.3.14"
require_command() {