ref:1c0f8e5431e333929d461ff13e12b21f7d823189

Add Huorn distribution/admin implementation plan

27 tasks across 9 phases: rename, config, CI pipeline, permissions, terminal management, admin commands, sandbox architecture, audit logging, and integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: 1c0f8e5431e333929d461ff13e12b21f7d823189
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-20 19:36
Parents: 5aae81d
1 files changed +1792 -0
Type
docs/superpowers/plans/2026-03-20-huorn-distribution-admin.md +1792 −0
@@ -1,0 +1,1792 @@
# Huorn Distribution, Admin & Security Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make Huorn installable by end users and manageable by server admins — rename, CI pipeline, permissions, commands, pluggable sandbox (plain + Docker), and audit logging.
**Architecture:** Pipeline-first approach. Rename first (clean slate), then CI (dogfood distribution from day one), then admin layer (config, permissions, commands), then sandbox architecture (trait extraction + Docker), then audit logging. Each phase produces working, testable software.
**Tech Stack:** Java 17, Minecraft 1.20.1, Architectury 9.2.14, Fabric Permissions API, Forge PermissionAPI, Brigadier, Rust (alacritty_terminal 0.25, hyper, hyperlocal), Docker Engine API, JSONL.
**Spec:** `docs/superpowers/specs/2026-03-20-huorn-distribution-admin-design.md`
---
## File Structure
### New Files
```
# CI / Release
ci/ # New directory
release.sh # CalVer version-bump + release script
.anvil-ci.yml # Anvil CI pipeline config
# Java — Common Module (post-rename paths)
common/src/main/java/io/fangorn/huorn/
permissions/
HuornPermissions.java # Permission node constants + check API
HuornPermissionsImpl.java # @ExpectPlatform stub (common side)
command/
HuornCommand.java # Root /huorn command, registers subcommands
ReloadCommand.java # /huorn reload
ListCommand.java # /huorn list
KillCommand.java # /huorn kill <player|all>
StatusCommand.java # /huorn status
AuditCommand.java # /huorn audit [player] [--last N]
service/
TerminalManager.java # Centralized terminal tracking (per-player + global counts)
# Java — Fabric Platform
fabric/src/main/java/io/fangorn/huorn/fabric/
permissions/
HuornPermissionsImplImpl.java # Fabric Permissions API implementation
# Java — Forge Platform
forge/src/main/java/io/fangorn/huorn/forge/
permissions/
HuornPermissionsImplImpl.java # Forge PermissionAPI implementation
# Rust — Backend Module
rust/src/
backend/
mod.rs # TerminalBackend + TerminalSession traits, BackendRegistry
plain.rs # PlainShellBackend (extracted from terminal.rs)
docker.rs # DockerBackend (Docker Engine API over unix socket)
audit.rs # AuditLogger: JSONL writer, single-file append
security.rs # InputFilter: line accumulation + blocklist matching
# Tests
rust/tests/
backend_integration.rs # Backend trait contract tests
audit_test.rs # Audit log format + rotation-safe tests
security_test.rs # Blocklist matching tests
```
### Modified Files (post-rename paths)
```
# Rust
rust/Cargo.toml # Crate name, [lib] name, new deps (hyper, hyperlocal, chrono, serde_json)
rust/src/lib.rs # JNI class path, new native functions (audit, backend param)
rust/src/terminal.rs # Hold Box<dyn TerminalSession> instead of owning PTY directly
# Java — Common
common/src/main/java/io/fangorn/huorn/
HuornMod.java # Register commands, load config
config/HuornConfig.java # Restructured nested config
nativelib/NativeLoader.java # Library filename: libhuorn_minecraft.*
nativelib/NativeTerminal.java # New native methods (audit, backend param on create)
block/TerminalBlock.java # Permission check via HuornPermissions
block/TerminalBlockEntity.java # Use TerminalManager, server-side interaction, backend field in NBT
client/HuornModClient.java # (renamed from AlacrittyModClient)
# Build
build_natives.sh # Output filenames libhuorn_minecraft.*
gradle.properties # mod_id=huorn, version injection support
common/build.gradle # fabric-permissions-api dependency
fabric/build.gradle # (dependency updates if needed)
# Resources (all renamed from alacrittymc -> huorn)
common/src/main/resources/
huorn.mixins.json # Package references
huorn.accesswidener
assets/huorn/... # Blockstates, models, lang, textures
data/huorn/... # Recipes, loot tables
fabric/src/main/resources/fabric.mod.json
forge/src/main/resources/META-INF/mods.toml
```
---
## Phase 1: Rename
### Task 1: Rename Rust Crate and Native Library
**Files:**
- Modify: `rust/Cargo.toml`
- Modify: `rust/src/lib.rs` (JNI_OnLoad class path)
- Modify: `build_natives.sh` (output filenames)
- Modify: `common/src/main/java/io/fangorn/alacrittymc/nativelib/NativeLoader.java` (library filename)
- [ ] **Step 1: Update Cargo.toml crate name and lib name**
In `rust/Cargo.toml`, change:
```toml
[package]
name = "huorn-minecraft"
[lib]
name = "huorn_minecraft"
crate-type = ["cdylib"]
```
- [ ] **Step 2: Update JNI_OnLoad class path in lib.rs**
In `rust/src/lib.rs`, change the `RegisterNatives` class path from `io/fangorn/alacrittymc/nativelib/NativeTerminal` to `io/fangorn/huorn/nativelib/NativeTerminal`.
- [ ] **Step 3: Update build_natives.sh output filenames**
Replace all occurrences of `libalacritty_minecraft` with `libhuorn_minecraft` and `alacritty_minecraft` with `huorn_minecraft` (for Windows DLL).
- [ ] **Step 4: Update NativeLoader.java library filename**
In `NativeLoader.java`, change `libraryFileName()` to return `libhuorn_minecraft.dylib`, `libhuorn_minecraft.so`, and `huorn_minecraft.dll`.
- [ ] **Step 5: Rename native library files in resources**
```bash
cd common/src/main/resources/natives
mv macos-aarch64/libalacritty_minecraft.dylib macos-aarch64/libhuorn_minecraft.dylib
mv macos-x86_64/libalacritty_minecraft.dylib macos-x86_64/libhuorn_minecraft.dylib
mv linux-x86_64/libalacritty_minecraft.so linux-x86_64/libhuorn_minecraft.so
mv linux-aarch64/libalacritty_minecraft.so linux-aarch64/libhuorn_minecraft.so
mv windows-x86_64/alacritty_minecraft.dll windows-x86_64/huorn_minecraft.dll
```
- [ ] **Step 6: Verify Rust builds**
```bash
cd rust && cargo build && cargo test
```
Expected: All 40 tests pass, output is `libhuorn_minecraft.dylib`.
- [ ] **Step 7: Commit**
```bash
git add -A && git commit -m "rename: Rust crate and native library to huorn-minecraft"
```
### Task 2: Rename Java Packages and Mod ID
**Files:**
- Move: `common/src/main/java/io/fangorn/alacrittymc/` -> `common/src/main/java/io/fangorn/huorn/`
- Move: `fabric/src/main/java/io/fangorn/alacrittymc/` -> `fabric/src/main/java/io/fangorn/huorn/`
- Move: `forge/src/main/java/io/fangorn/alacrittymc/` -> `forge/src/main/java/io/fangorn/huorn/`
- Modify: `gradle.properties` (mod_id)
- [ ] **Step 1: Move package directories**
```bash
# Common
mkdir -p common/src/main/java/io/fangorn/huorn
mv common/src/main/java/io/fangorn/alacrittymc/* common/src/main/java/io/fangorn/huorn/
rmdir common/src/main/java/io/fangorn/alacrittymc
# Fabric
mkdir -p fabric/src/main/java/io/fangorn/huorn
mv fabric/src/main/java/io/fangorn/alacrittymc/* fabric/src/main/java/io/fangorn/huorn/
rmdir fabric/src/main/java/io/fangorn/alacrittymc
# Forge
mkdir -p forge/src/main/java/io/fangorn/huorn
mv forge/src/main/java/io/fangorn/alacrittymc/* forge/src/main/java/io/fangorn/huorn/
rmdir forge/src/main/java/io/fangorn/alacrittymc
```
- [ ] **Step 2: Find and replace package declarations and imports in all Java files**
```bash
# Package declarations
find . -name "*.java" -exec sed -i '' 's/io\.fangorn\.alacrittymc/io.fangorn.huorn/g' {} +
# MOD_ID constant
find . -name "*.java" -exec sed -i '' 's/"alacrittymc"/"huorn"/g' {} +
```
- [ ] **Step 3: Rename class names**
Rename in all Java files:
- `AlacrittyMod` -> `HuornMod`
- `AlacrittyModClient` -> `HuornModClient`
- `AlacrittyConfig` -> `HuornConfig`
- `AlacrittyModFabric` -> `HuornModFabric`
- `AlacrittyModFabricClient` -> `HuornModFabricClient`
- `AlacrittyModForge` -> `HuornModForge`
Then rename the files themselves to match.
- [ ] **Step 4: Update gradle.properties**
Change `mod_id=alacrittymc` to `mod_id=huorn`.
Change `mod_name=Alacritty Minecraft` to `mod_name=Huorn`.
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "rename: Java packages and mod ID to huorn"
```
### Task 3: Rename Resources and Metadata
**Files:**
- Rename: `common/src/main/resources/alacrittymc.mixins.json` -> `huorn.mixins.json`
- Rename: `common/src/main/resources/alacrittymc.accesswidener` -> `huorn.accesswidener`
- Move: `common/src/main/resources/assets/alacrittymc/` -> `assets/huorn/`
- Move: `common/src/main/resources/data/alacrittymc/` -> `data/huorn/`
- Modify: `fabric/src/main/resources/fabric.mod.json`
- Modify: `forge/src/main/resources/META-INF/mods.toml`
- Modify: `common/build.gradle` (access widener reference)
- Modify: `common/src/main/resources/assets/huorn/lang/en_us.json`
- [ ] **Step 1: Rename resource files and directories**
```bash
cd common/src/main/resources
# Mixin config
mv alacrittymc.mixins.json huorn.mixins.json
# Access widener
mv alacrittymc.accesswidener huorn.accesswidener
# Asset directories
mv assets/alacrittymc assets/huorn
# Data directories
mv data/alacrittymc data/huorn
```
- [ ] **Step 2: Update contents of renamed resource files**
In `huorn.mixins.json`: change `"package": "io.fangorn.alacrittymc.mixin"` to `"package": "io.fangorn.huorn.mixin"`.
In `assets/huorn/lang/en_us.json`: change keys from `block.alacrittymc.terminal_block` to `block.huorn.terminal_block`, `itemGroup.alacrittymc.main` to `itemGroup.huorn.main`, update display name to `"Huorn"`.
In recipes and loot tables: replace `alacrittymc:terminal_block` with `huorn:terminal_block`.
- [ ] **Step 3: Update fabric.mod.json**
Change: `"id": "alacrittymc"` -> `"id": "huorn"`, `"name": "Alacritty Minecraft"` -> `"name": "Huorn"`, entrypoint class paths to `io.fangorn.huorn.fabric.*`, mixins to `huorn.mixins.json`.
- [ ] **Step 4: Update mods.toml**
Change: `modId="alacrittymc"` -> `modId="huorn"`, `displayName="Alacritty Minecraft"` -> `displayName="Huorn"`.
- [ ] **Step 5: Update common/build.gradle access widener reference**
Change `alacrittymc.accesswidener` to `huorn.accesswidener`.
- [ ] **Step 6: Update any remaining references in build.gradle files**
Search all `*.gradle` files for `alacrittymc` and replace with `huorn`.
- [ ] **Step 7: Commit**
```bash
git add -A && git commit -m "rename: resources and metadata to huorn"
```
### Task 4: Verify Full Build After Rename
- [ ] **Step 1: Run Rust tests**
```bash
cd rust && cargo test
```
Expected: All tests pass.
- [ ] **Step 2: Run Gradle build**
```bash
./gradlew build
```
Expected: Both fabric and forge JARs build successfully.
- [ ] **Step 3: Verify JAR contents**
```bash
jar tf fabric/build/libs/fabric-*.jar | grep -E "(huorn|alacritty)" | head -20
```
Expected: All paths reference `huorn`, no remaining `alacrittymc` references.
- [ ] **Step 4: Search for any remaining old references**
```bash
rg -l "alacrittymc" --type java --type json --type toml --type rust
rg -l "AlacrittyMod\|AlacrittyConfig\|AlacrittyModClient" --type java
```
Expected: No matches (except comments explaining Alacritty is the upstream library).
- [ ] **Step 5: Commit any fixups, tag as rename-complete**
```bash
git add -A && git commit -m "rename: verify and fix remaining alacrittymc references"
```
---
## Phase 2: Config Restructure
### Task 5: New HuornConfig with Nested Structure
**Files:**
- Modify: `common/src/main/java/io/fangorn/huorn/config/HuornConfig.java`
- Test: Manual verification — load default config, verify JSON structure
- [ ] **Step 1: Write test for config loading**
Create a simple unit test (or main-method test if MC test infra is too heavy) that:
- Loads a `HuornConfig` with no file present (defaults)
- Verifies `server.maxTerminalsPerPlayer == 4`
- Verifies `server.maxTerminalsTotal == 32`
- Verifies `server.defaultBackend == "plain"`
- Verifies `backends.plain.enabled == true`
- Verifies `backends.docker.enabled == false`
- Verifies `security.auditLog.enabled == true`
- [ ] **Step 2: Run test to verify it fails**
Expected: Fails because HuornConfig still has flat structure.
- [ ] **Step 3: Rewrite HuornConfig with nested structure**
Replace the flat `HuornConfig` class with nested inner classes:
```java
public class HuornConfig {
public static final String CONFIG_PATH = "config/huorn.json";
public static final String OLD_CONFIG_PATH = "config/alacrittymc.json";
private static HuornConfig INSTANCE;
// Top-level sections
public ServerConfig server = new ServerConfig();
public BackendsConfig backends = new BackendsConfig();
public SecurityConfig security = new SecurityConfig();
public DisplayConfig display = new DisplayConfig();
public static class ServerConfig {
public boolean enableOnServers = false;
public int maxTerminalsPerPlayer = 4;
public int maxTerminalsTotal = 32;
public int idleTimeoutMinutes = 30;
public String defaultBackend = "plain";
public int defaultOpLevel = 4;
}
public static class BackendsConfig {
public PlainBackendConfig plain = new PlainBackendConfig();
public DockerBackendConfig docker = new DockerBackendConfig();
}
public static class PlainBackendConfig {
public boolean enabled = true;
public List<String> allowedShells = List.of("/bin/bash", "/bin/zsh");
}
public static class DockerBackendConfig {
public boolean enabled = false;
public String image = "ubuntu:24.04";
public String memoryLimit = "256m";
public double cpuLimit = 0.5;
public boolean networkEnabled = false;
public List<String> mountPaths = new ArrayList<>();
}
public static class SecurityConfig {
public List<String> commandBlocklist = List.of("rm -rf /", ":(){ :|:& };:");
public AuditLogConfig auditLog = new AuditLogConfig();
}
public static class AuditLogConfig {
public boolean enabled = true;
public String logFile = "logs/huorn-audit.log";
public boolean logCommands = true;
public boolean logConnections = true;
}
public static class DisplayConfig {
public float fontSize = 14.0f;
public boolean craftable = true;
}
// load(), save(), getInstance(), reload() methods
// Old canUse() method REMOVED — replaced by HuornPermissions
}
```
- [ ] **Step 4: Add config migration warning**
In `load()`, after loading the new config, check if `config/alacrittymc.json` exists. If it does, log a warning:
```java
if (Files.exists(Path.of(OLD_CONFIG_PATH))) {
LOGGER.warn("[Huorn] Found old config file '{}'. Please migrate settings to '{}' manually.",
OLD_CONFIG_PATH, CONFIG_PATH);
}
```
- [ ] **Step 5: Update all callers of the old config API**
Search for `AlacrittyConfig.getInstance()` and `config.canUse()` calls. Update to use new nested structure (e.g., `HuornConfig.getInstance().server.maxTerminalsPerPlayer`). Remove `canUse()` calls — will be replaced by permission checks in Task 8.
- [ ] **Step 6: Run test to verify it passes**
Expected: Config test passes with correct defaults.
- [ ] **Step 7: Run Gradle build**
```bash
./gradlew build
```
Expected: Compiles. Existing game tests may need updating for renamed config.
- [ ] **Step 8: Commit**
```bash
git add -A && git commit -m "feat: restructure HuornConfig with nested sections"
```
---
## Phase 3: CI/Release Pipeline
### Task 6: CalVer Version-Bump Script
**Files:**
- Create: `ci/release.sh`
- [ ] **Step 1: Write version-bump script**
```bash
#!/usr/bin/env bash
set -euo pipefail
# Compute next CalVer version: YYYY.MM.BUILD
# Reads latest Anvil release tag, increments build number
# Resets build to 1 on new month
# Defaults to YYYY.MM.1 if no prior release exists
ANVIL="/Users/chaos/src/notifd_src/anvil_cli/target/release/anvil"
YEAR_MONTH=$(date +"%Y.%m")
# Get latest release tag (may fail if no releases exist)
LATEST=$($ANVIL release list --format json 2>/dev/null | jq -r '.[0].tag // empty' || true)
if [ -z "$LATEST" ]; then
echo "${YEAR_MONTH}.1"
exit 0
fi
# Parse existing tag
LATEST_YM=$(echo "$LATEST" | cut -d. -f1-2)
LATEST_BUILD=$(echo "$LATEST" | cut -d. -f3)
if [ "$LATEST_YM" = "$YEAR_MONTH" ]; then
echo "${YEAR_MONTH}.$((LATEST_BUILD + 1))"
else
echo "${YEAR_MONTH}.1"
fi
```
- [ ] **Step 2: Test version-bump script locally**
```bash
chmod +x ci/release.sh && bash ci/release.sh
```
Expected: Outputs something like `2026.03.1` (or incremented if releases exist).
- [ ] **Step 3: Commit**
```bash
git add ci/release.sh && git commit -m "feat: add CalVer version-bump script"
```
### Task 7: Anvil CI Configuration
**Files:**
- Create: `.anvil-ci.yml`
- Modify: `gradle.properties` (version injection)
- Modify: `build.gradle` (accept `-Pversion`)
- [ ] **Step 1: Update gradle.properties for version injection**
Change `mod_version=0.1.0` to `mod_version=0.0.0-dev` (default for local builds). The CI pipeline overrides this via `-Pmod_version=YYYY.MM.BUILD`.
- [ ] **Step 2: Ensure build.gradle reads version from property**
Verify `build.gradle` already uses `project.mod_version` or equivalent. If it uses a hardcoded version, change it to read from `gradle.properties`.
- [ ] **Step 3: Write Anvil CI config**
```yaml
# .anvil-ci.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: linux
steps:
- uses: actions/checkout@v1
- name: Compute version
run: |
VERSION=$(bash ci/release.sh)
echo "VERSION=$VERSION" >> $ANVIL_ENV
- name: Build native libraries
run: bash build_natives.sh
- name: Build mod JARs
run: ./gradlew build -Pmod_version=$VERSION
- name: Run tests
run: |
cd rust && cargo test
cd .. && ./gradlew test
- name: Create release
run: |
CHANGELOG=$(git log --oneline $(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD)..HEAD)
anvil release create \
--tag "$VERSION" \
--title "Huorn $VERSION" \
--body "$CHANGELOG" \
--attach fabric/build/libs/fabric-${VERSION}.jar \
--attach forge/build/libs/forge-${VERSION}.jar
- name: Tag commit
run: |
git tag "$VERSION"
git push origin "$VERSION"
```
Note: This is a starting point. Anvil CI's actual config format may differ — adapt to whatever `anvil ci` supports. The key flow (version -> build natives -> build JARs -> test -> release -> tag) is what matters.
- [ ] **Step 4: Commit**
```bash
git add .anvil-ci.yml gradle.properties build.gradle && git commit -m "feat: add Anvil CI pipeline with CalVer releases"
```
---
## Phase 4: Permission System
### Task 8: Permission Abstraction (Common Module)
**Files:**
- Create: `common/src/main/java/io/fangorn/huorn/permissions/HuornPermissions.java`
- Create: `common/src/main/java/io/fangorn/huorn/permissions/HuornPermissionsImpl.java`
- [ ] **Step 1: Write HuornPermissions constants and API**
```java
package io.fangorn.huorn.permissions;
import net.minecraft.server.level.ServerPlayer;
public class HuornPermissions {
// Permission node constants
public static final String USE = "huorn.use";
public static final String USE_DOCKER = "huorn.use.docker";
public static final String ADMIN_RELOAD = "huorn.admin.reload";
public static final String ADMIN_LIST = "huorn.admin.list";
public static final String ADMIN_KILL = "huorn.admin.kill";
public static final String ADMIN_AUDIT = "huorn.admin.audit";
/**
* Check if a player has a permission.
* Delegates to platform-specific implementation.
* Falls back to op level check if no permissions mod installed.
*/
public static boolean hasPermission(ServerPlayer player, String permission) {
return HuornPermissionsImpl.check(player, permission);
}
}
```
- [ ] **Step 2: Write @ExpectPlatform stub**
```java
package io.fangorn.huorn.permissions;
import dev.architectury.injectables.annotations.ExpectPlatform;
import net.minecraft.server.level.ServerPlayer;
public class HuornPermissionsImpl {
@ExpectPlatform
public static boolean check(ServerPlayer player, String permission) {
throw new AssertionError("Platform implementation missing");
}
}
```
- [ ] **Step 3: Commit**
```bash
git add -A && git commit -m "feat: add permission abstraction with @ExpectPlatform"
```
### Task 9: Fabric Permissions Implementation
**Files:**
- Create: `fabric/src/main/java/io/fangorn/huorn/fabric/permissions/HuornPermissionsImplImpl.java`
- Modify: `common/build.gradle` or `fabric/build.gradle` (add fabric-permissions-api dependency)
- [ ] **Step 1: Add fabric-permissions-api dependency**
In `fabric/build.gradle`, add:
```gradle
modImplementation "me.lucko:fabric-permissions-api:0.3-SNAPSHOT"
```
Or if Architectury provides it transitively via Fabric API, verify it's already available.
- [ ] **Step 2: Write Fabric permissions implementation**
```java
package io.fangorn.huorn.fabric.permissions;
import io.fangorn.huorn.config.HuornConfig;
import me.lucko.fabric.api.permissions.v0.Permissions;
import net.minecraft.server.level.ServerPlayer;
public class HuornPermissionsImplImpl {
public static boolean check(ServerPlayer player, String permission) {
int defaultOpLevel = HuornConfig.getInstance().server.defaultOpLevel;
return Permissions.check(player, permission, defaultOpLevel);
}
}
```
The Fabric Permissions API's `Permissions.check(player, permission, defaultOpLevel)` checks LuckPerms first, falls back to op level if no permissions mod is installed.
- [ ] **Step 3: Commit**
```bash
git add -A && git commit -m "feat: Fabric permissions via fabric-permissions-api"
```
### Task 10: Forge Permissions Implementation
**Files:**
- Create: `forge/src/main/java/io/fangorn/huorn/forge/permissions/HuornPermissionsImplImpl.java`
- [ ] **Step 1: Write Forge permissions implementation**
```java
package io.fangorn.huorn.forge.permissions;
import io.fangorn.huorn.config.HuornConfig;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.server.permission.PermissionAPI;
import net.minecraftforge.server.permission.nodes.PermissionNode;
import net.minecraftforge.server.permission.nodes.PermissionTypes;
public class HuornPermissionsImplImpl {
// Register permission nodes on mod init
// Forge requires explicit node registration unlike Fabric's string-based API
public static boolean check(ServerPlayer player, String permission) {
// Forge PermissionAPI requires registered PermissionNode objects
// For now, fall back to op level check
int defaultOpLevel = HuornConfig.getInstance().server.defaultOpLevel;
return player.hasPermissions(defaultOpLevel);
}
}
```
Note: Forge's PermissionAPI requires registering `PermissionNode` objects at mod init time. The full implementation needs `PermissionNode<Boolean>` objects registered in `HuornModForge.init()`. The above is a fallback that works immediately; full Forge permission node registration can be added as a follow-up step.
- [ ] **Step 2: Commit**
```bash
git add -A && git commit -m "feat: Forge permissions with op-level fallback"
```
### Task 11: Server-Side Permission Enforcement
**Files:**
- Modify: `common/src/main/java/io/fangorn/huorn/block/TerminalBlock.java`
- Modify: `common/src/main/java/io/fangorn/huorn/block/TerminalBlockEntity.java`
- [ ] **Step 1: Refactor TerminalBlock.use() to enforce permissions server-side**
Currently `TerminalBlockEntity.onPlayerInteract()` has `if (level == null || !level.isClientSide()) return;` — it only runs client-side. This must be flipped: permission checks run server-side, and the server triggers the client.
In `TerminalBlock.use()`:
```java
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (level.isClientSide()) {
return InteractionResult.SUCCESS; // Client: optimistic, let server decide
}
// Server-side permission check
if (player instanceof ServerPlayer serverPlayer) {
if (!HuornPermissions.hasPermission(serverPlayer, HuornPermissions.USE)) {
serverPlayer.sendSystemMessage(Component.literal("You don't have permission to use terminals."));
return InteractionResult.FAIL;
}
}
// Server-side: start terminal, notify client
TerminalBlockEntity entity = (TerminalBlockEntity) level.getBlockEntity(pos);
if (entity != null) {
entity.onPlayerInteract(player);
}
return InteractionResult.CONSUME;
}
```
- [ ] **Step 2: Remove old canUse() calls from TerminalBlock and TerminalBlockEntity**
Delete any remaining `AlacrittyConfig.canUse()` / `HuornConfig.canUse()` calls. The permission check is now in `TerminalBlock.use()` via `HuornPermissions`.
- [ ] **Step 3: Verify Gradle builds**
```bash
./gradlew build
```
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat: server-side permission enforcement via HuornPermissions"
```
---
## Phase 5: Terminal Management Service
### Task 12: TerminalManager
**Files:**
- Create: `common/src/main/java/io/fangorn/huorn/service/TerminalManager.java`
- Test: Unit test for TerminalManager counting logic
- [ ] **Step 1: Write test for TerminalManager**
Test that:
- `canCreateTerminal(uuid)` returns true when under both limits
- `canCreateTerminal(uuid)` returns false when player at per-player limit
- `canCreateTerminal(uuid)` returns false when server at total limit
- `registerTerminal(uuid)` increments both counters
- `unregisterTerminal(uuid)` decrements both counters
- Thread-safe: concurrent register/unregister doesn't corrupt counts
- [ ] **Step 2: Run test to verify it fails**
Expected: `TerminalManager` class doesn't exist yet.
- [ ] **Step 3: Implement TerminalManager**
```java
package io.fangorn.huorn.service;
import io.fangorn.huorn.config.HuornConfig;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class TerminalManager {
private static final TerminalManager INSTANCE = new TerminalManager();
private final ConcurrentHashMap<UUID, AtomicInteger> perPlayerCounts = new ConcurrentHashMap<>();
private final AtomicInteger totalCount = new AtomicInteger(0);
// Track active sessions: sessionId -> metadata (player, location, backend, start time)
private final ConcurrentHashMap<Long, SessionInfo> activeSessions = new ConcurrentHashMap<>();
public static TerminalManager getInstance() { return INSTANCE; }
public boolean canCreateTerminal(UUID playerUuid) {
HuornConfig config = HuornConfig.getInstance();
if (totalCount.get() >= config.server.maxTerminalsTotal) return false;
AtomicInteger playerCount = perPlayerCounts.get(playerUuid);
if (playerCount != null && playerCount.get() >= config.server.maxTerminalsPerPlayer) return false;
return true;
}
public void registerTerminal(UUID playerUuid, long sessionHandle, SessionInfo info) {
perPlayerCounts.computeIfAbsent(playerUuid, k -> new AtomicInteger(0)).incrementAndGet();
totalCount.incrementAndGet();
activeSessions.put(sessionHandle, info);
}
public void unregisterTerminal(UUID playerUuid, long sessionHandle) {
AtomicInteger count = perPlayerCounts.get(playerUuid);
if (count != null) count.decrementAndGet();
totalCount.decrementAndGet();
activeSessions.remove(sessionHandle);
}
public ConcurrentHashMap<Long, SessionInfo> getActiveSessions() {
return activeSessions;
}
public int getTotalCount() { return totalCount.get(); }
public int getPlayerCount(UUID playerUuid) {
AtomicInteger count = perPlayerCounts.get(playerUuid);
return count != null ? count.get() : 0;
}
public record SessionInfo(UUID playerUuid, String playerName, String backend,
String location, long startTimeMs) {}
}
```
- [ ] **Step 4: Run test to verify it passes**
- [ ] **Step 5: Integrate TerminalManager into TerminalBlockEntity**
In `TerminalBlockEntity.startTerminalIfNeeded()`:
- Before creating terminal: check `TerminalManager.getInstance().canCreateTerminal(player.getUUID())`
- After creating terminal: call `TerminalManager.getInstance().registerTerminal(uuid, handle, info)`
- In `stopTerminal()` and `onBlockRemoved()`: call `TerminalManager.getInstance().unregisterTerminal(uuid, handle)`
- [ ] **Step 6: Verify Gradle builds**
```bash
./gradlew build
```
- [ ] **Step 7: Commit**
```bash
git add -A && git commit -m "feat: TerminalManager with per-player and global terminal tracking"
```
---
## Phase 6: Admin Commands
### Task 13: Root Command + Reload
**Files:**
- Create: `common/src/main/java/io/fangorn/huorn/command/HuornCommand.java`
- Create: `common/src/main/java/io/fangorn/huorn/command/ReloadCommand.java`
- Modify: `common/src/main/java/io/fangorn/huorn/HuornMod.java` (register commands)
- [ ] **Step 1: Write HuornCommand root**
```java
package io.fangorn.huorn.command;
import com.mojang.brigadier.CommandDispatcher;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
public class HuornCommand {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("huorn")
.then(ReloadCommand.register())
.then(ListCommand.register())
.then(KillCommand.register())
.then(StatusCommand.register())
.then(AuditCommand.register())
);
}
}
```
- [ ] **Step 2: Write ReloadCommand**
```java
package io.fangorn.huorn.command;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import io.fangorn.huorn.config.HuornConfig;
import io.fangorn.huorn.permissions.HuornPermissions;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
public class ReloadCommand {
public static LiteralArgumentBuilder<CommandSourceStack> register() {
return Commands.literal("reload")
.requires(source -> {
if (source.getEntity() instanceof ServerPlayer player) {
return HuornPermissions.hasPermission(player, HuornPermissions.ADMIN_RELOAD);
}
return source.hasPermission(4); // Console always allowed
})
.executes(context -> {
HuornConfig.load();
context.getSource().sendSuccess(
() -> Component.literal("[Huorn] Config reloaded."), true);
return 1;
});
}
}
```
- [ ] **Step 3: Register commands in HuornMod.init()**
Use Architectury's command registration event:
```java
CommandRegistrationEvent.EVENT.register((dispatcher, registryAccess, environment) -> {
HuornCommand.register(dispatcher);
});
```
- [ ] **Step 4: Verify Gradle builds**
```bash
./gradlew build
```
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: /huorn command root + /huorn reload"
```
### Task 14: List, Kill, Status Commands
**Files:**
- Create: `common/src/main/java/io/fangorn/huorn/command/ListCommand.java`
- Create: `common/src/main/java/io/fangorn/huorn/command/KillCommand.java`
- Create: `common/src/main/java/io/fangorn/huorn/command/StatusCommand.java`
- [ ] **Step 1: Write ListCommand**
Lists all active terminals from `TerminalManager.getActiveSessions()`. Shows: player name, location, backend, uptime. Permission: `huorn.admin.list`.
- [ ] **Step 2: Write KillCommand**
Accepts `<player>` argument (or `all`). Looks up sessions for that player in `TerminalManager`, calls `NativeTerminal.close()` on each, unregisters them. Permission: `huorn.admin.kill`.
- [ ] **Step 3: Write StatusCommand**
Shows: total active terminals, per-backend counts, which backends are available (calls `is_available()` via JNI when sandbox architecture is wired — for now, just reports plain shell as available). Permission: none (informational, available to all ops).
- [ ] **Step 4: Verify Gradle builds**
```bash
./gradlew build
```
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: /huorn list, kill, status commands"
```
### Task 15: Audit Command (Placeholder)
**Files:**
- Create: `common/src/main/java/io/fangorn/huorn/command/AuditCommand.java`
- [ ] **Step 1: Write AuditCommand**
Reads `logs/huorn-audit.log` line by line, parses JSONL, filters by player name and `--last N`. For now, this is a placeholder that reports "Audit log not yet available" if the file doesn't exist. Full implementation after Task 25 (audit logging).
- [ ] **Step 2: Commit**
```bash
git add -A && git commit -m "feat: /huorn audit command (placeholder, reads log file)"
```
---
## Phase 7: Pluggable Sandbox Architecture
### Task 16: Backend Traits in Rust
**Files:**
- Create: `rust/src/backend/mod.rs`
- Modify: `rust/Cargo.toml` (no new deps yet, just module declaration)
- [ ] **Step 1: Write failing test for backend trait contract**
In `rust/tests/backend_integration.rs`:
```rust
use huorn_minecraft::backend::{TerminalBackend, TerminalSession, BackendConfig, BackendRegistry};
#[test]
fn test_backend_registry_has_plain() {
let registry = BackendRegistry::new();
assert!(registry.get("plain").is_some());
}
#[test]
fn test_plain_backend_is_available() {
let registry = BackendRegistry::new();
let plain = registry.get("plain").unwrap();
assert!(plain.is_available().unwrap());
}
#[test]
fn test_plain_backend_name() {
let registry = BackendRegistry::new();
let plain = registry.get("plain").unwrap();
assert_eq!(plain.name(), "plain");
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd rust && cargo test --test backend_integration
```
Expected: Fails — module doesn't exist.
- [ ] **Step 3: Write backend traits and registry**
```rust
// rust/src/backend/mod.rs
pub mod plain;
pub mod docker;
use std::collections::HashMap;
pub struct BackendConfig {
pub cols: u16,
pub rows: u16,
pub font_size: f32,
pub shell: String,
pub working_dir: String,
// Docker-specific
pub image: Option<String>,
pub memory_limit: Option<String>,
pub cpu_limit: Option<f64>,
pub network_enabled: Option<bool>,
}
pub trait TerminalBackend: Send + Sync {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>, String>;
fn name(&self) -> &'static str;
fn is_available(&self) -> Result<bool, String>;
}
pub trait TerminalSession: Send {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, String>;
fn write(&mut self, data: &[u8]) -> Result<usize, String>;
fn resize(&mut self, cols: u16, rows: u16) -> Result<(), String>;
fn kill(&mut self) -> Result<(), String>;
fn is_alive(&self) -> bool;
}
pub struct BackendRegistry {
backends: HashMap<String, Box<dyn TerminalBackend>>,
}
impl BackendRegistry {
pub fn new() -> Self {
let mut backends: HashMap<String, Box<dyn TerminalBackend>> = HashMap::new();
backends.insert("plain".to_string(), Box::new(plain::PlainShellBackend));
// Docker registered but may not be available
backends.insert("docker".to_string(), Box::new(docker::DockerBackend));
Self { backends }
}
pub fn get(&self, name: &str) -> Option<&dyn TerminalBackend> {
self.backends.get(name).map(|b| b.as_ref())
}
pub fn available_backends(&self) -> Vec<&str> {
self.backends.values()
.filter(|b| b.is_available().unwrap_or(false))
.map(|b| b.name())
.collect()
}
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd rust && cargo test --test backend_integration
```
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: TerminalBackend + TerminalSession traits with BackendRegistry"
```
### Task 17: PlainShellBackend (Extract from terminal.rs)
**Files:**
- Create: `rust/src/backend/plain.rs`
- Modify: `rust/src/terminal.rs` (use `Box<dyn TerminalSession>` instead of direct PTY)
- [ ] **Step 1: Write failing test for PlainShellBackend spawn**
```rust
#[test]
fn test_plain_shell_spawn_and_write() {
let backend = PlainShellBackend;
let config = BackendConfig {
cols: 80, rows: 24, font_size: 14.0,
shell: "/bin/bash".to_string(),
working_dir: "/tmp".to_string(),
image: None, memory_limit: None, cpu_limit: None, network_enabled: None,
};
let mut session = backend.spawn(&config).unwrap();
assert!(session.is_alive());
session.write(b"echo hello\n").unwrap();
std::thread::sleep(std::time::Duration::from_millis(100));
let mut buf = [0u8; 1024];
let n = session.read(&mut buf).unwrap_or(0);
assert!(n > 0); // Should have output
session.kill().unwrap();
}
```
- [ ] **Step 2: Run test to verify it fails**
- [ ] **Step 3: Implement PlainShellBackend**
Extract the PTY spawning logic from `TerminalState::new()` in `terminal.rs` into `PlainShellBackend.spawn()`. The `PlainShellSession` struct wraps `alacritty_terminal::tty::Pty` and implements `TerminalSession`.
```rust
// rust/src/backend/plain.rs
use super::{BackendConfig, TerminalBackend, TerminalSession};
use alacritty_terminal::tty;
use std::path::PathBuf;
pub struct PlainShellBackend;
impl TerminalBackend for PlainShellBackend {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>, String> {
// Create PTY using alacritty_terminal::tty
// (extract from TerminalState::new())
let pty = create_pty(config)?;
Ok(Box::new(PlainShellSession { pty, alive: true }))
}
fn name(&self) -> &'static str { "plain" }
fn is_available(&self) -> Result<bool, String> {
// Check if any allowed shell exists
Ok(PathBuf::from("/bin/bash").exists() || PathBuf::from("/bin/zsh").exists())
}
}
struct PlainShellSession {
pty: tty::Pty,
alive: bool,
}
impl TerminalSession for PlainShellSession {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, String> { /* read from PTY fd */ }
fn write(&mut self, data: &[u8]) -> Result<usize, String> { /* write to PTY fd */ }
fn resize(&mut self, cols: u16, rows: u16) -> Result<(), String> { /* ioctl TIOCSWINSZ */ }
fn kill(&mut self) -> Result<(), String> { /* SIGKILL child, close fd */ }
fn is_alive(&self) -> bool { self.alive }
}
```
- [ ] **Step 4: Refactor terminal.rs to use Box<dyn TerminalSession>**
Replace `pty: Option<tty::Pty>` in `TerminalState` with `session: Option<Box<dyn TerminalSession>>`. Update `poll_pty()`, `send_text()`, `send_key()` to route through `session.read()` and `session.write()`.
- [ ] **Step 5: Run all Rust tests**
```bash
cd rust && cargo test
```
Expected: All existing 40+ tests still pass, plus new backend tests.
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: extract PlainShellBackend from terminal.rs"
```
### Task 18: JNI Backend Parameter
**Files:**
- Modify: `rust/src/lib.rs` (add `backend` parameter to `native_create`)
- Modify: `common/src/main/java/io/fangorn/huorn/nativelib/NativeTerminal.java`
- [ ] **Step 1: Add backend parameter to native_create in lib.rs**
Change `native_create(cols, rows, fontSize, shell, workingDir)` to `native_create(cols, rows, fontSize, shell, workingDir, backend)`. Look up backend in `BackendRegistry`, pass to `TerminalState::new()`.
- [ ] **Step 2: Update NativeTerminal.java**
Add `String backend` parameter to constructor and native method declaration. Default to `"plain"` if null.
- [ ] **Step 3: Update TerminalBlockEntity to pass backend**
Read backend from config (`HuornConfig.getInstance().server.defaultBackend`) and pass to `NativeTerminal` constructor.
- [ ] **Step 4: Re-register native method in JNI_OnLoad**
Update the method signature in the `NativeMethod` array to include the new `String` parameter.
- [ ] **Step 5: Run Rust tests + Gradle build**
```bash
cd rust && cargo test && cd .. && ./gradlew build
```
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: JNI backend parameter for terminal creation"
```
### Task 19: DockerBackend
**Files:**
- Modify: `rust/Cargo.toml` (add hyper, hyperlocal, tokio, serde_json)
- Create: `rust/src/backend/docker.rs`
- [ ] **Step 1: Add Docker dependencies to Cargo.toml**
```toml
[dependencies]
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] }
hyperlocal = "0.9"
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util"] }
serde_json = "1"
```
- [ ] **Step 2: Write failing test for DockerBackend availability**
```rust
#[test]
fn test_docker_backend_is_available_checks_socket() {
let backend = DockerBackend;
let result = backend.is_available();
// On CI without Docker: Ok(false)
// On dev machine with Docker: Ok(true)
assert!(result.is_ok());
}
```
- [ ] **Step 3: Implement DockerBackend**
```rust
// rust/src/backend/docker.rs
pub struct DockerBackend;
impl TerminalBackend for DockerBackend {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>, String> {
let image = config.image.as_deref().unwrap_or("ubuntu:24.04");
let container_id = create_container(image, config)?;
start_container(&container_id)?;
let stream = attach_container(&container_id)?;
Ok(Box::new(DockerSession { container_id, stream, alive: true }))
}
fn name(&self) -> &'static str { "docker" }
fn is_available(&self) -> Result<bool, String> {
// Check /var/run/docker.sock exists and daemon responds to /_ping
check_docker_daemon()
}
}
```
Key implementation details:
- `create_container()`: POST to `/containers/create` with JSON body specifying image, TTY, shell command, resource limits (HostConfig.Memory, HostConfig.NanoCpus, HostConfig.NetworkMode)
- `start_container()`: POST to `/containers/{id}/start`
- `attach_container()`: POST to `/containers/{id}/attach?stream=1&stdin=1&stdout=1&stderr=1` — returns bidirectional stream
- `DockerSession.read/write`: Read/write the attach stream
- `DockerSession.kill/Drop`: POST to `/containers/{id}/kill`, then DELETE `/containers/{id}`
- [ ] **Step 4: Run tests**
```bash
cd rust && cargo test
```
Expected: Docker availability test passes (returns Ok(false) if no Docker, Ok(true) if Docker present). Spawn test only passes if Docker is available (mark with `#[ignore]` for CI).
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: DockerBackend via Docker Engine API over unix socket"
```
---
## Phase 8: Audit Logging & Security
### Task 20: Audit Log Writer in Rust
**Files:**
- Create: `rust/src/audit.rs`
- Modify: `rust/Cargo.toml` (add chrono)
- Test: `rust/tests/audit_test.rs`
- [ ] **Step 1: Write failing test for audit log format**
```rust
#[test]
fn test_audit_log_writes_jsonl() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut logger = AuditLogger::new(tmp.path().to_str().unwrap()).unwrap();
logger.log_event("CONNECT", r#"{"player":"abc-123","name":"Steve","backend":"plain","location":"world:1,2,3"}"#);
let contents = std::fs::read_to_string(tmp.path()).unwrap();
let line: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
assert_eq!(line["event"], "CONNECT");
assert!(line["ts"].as_str().unwrap().contains("2026"));
}
```
- [ ] **Step 2: Run test to verify it fails**
- [ ] **Step 3: Implement AuditLogger**
```rust
// rust/src/audit.rs
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::Mutex;
pub struct AuditLogger {
file: Mutex<File>,
}
impl AuditLogger {
pub fn new(path: &str) -> Result<Self, String> {
let file = OpenOptions::new()
.create(true).append(true).open(path)
.map_err(|e| format!("Failed to open audit log: {}", e))?;
Ok(Self { file: Mutex::new(file) })
}
pub fn log_event(&self, event_type: &str, payload_json: &str) {
let ts = chrono::Utc::now().to_rfc3339();
let line = format!(r#"{{"ts":"{}","event":"{}",{}}}"#,
ts, event_type, payload_json.trim_start_matches('{').trim_end_matches('}'));
if let Ok(mut f) = self.file.lock() {
let _ = writeln!(f, "{}", line);
}
}
}
```
- [ ] **Step 4: Run test to verify it passes**
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: AuditLogger with JSONL format"
```
### Task 21: JNI Audit Functions
**Files:**
- Modify: `rust/src/lib.rs` (add `native_audit_event` and `native_audit_event_global`)
- Modify: `common/src/main/java/io/fangorn/huorn/nativelib/NativeTerminal.java`
- [ ] **Step 1: Add audit JNI functions to lib.rs**
```rust
fn native_audit_event(
mut env: JNIEnv, _class: JClass,
handle: jlong, event_type: JString, payload: JString
) {
// Look up session by handle, log with session context
let event: String = env.get_string(&event_type).unwrap().into();
let json: String = env.get_string(&payload).unwrap().into();
if let Some(logger) = GLOBAL_AUDIT_LOGGER.get() {
logger.log_event(&event, &json);
}
}
fn native_audit_event_global(
mut env: JNIEnv, _class: JClass,
event_type: JString, payload: JString
) {
// Log without session context (BACKEND_ERROR, ADMIN_KILL on dead session)
let event: String = env.get_string(&event_type).unwrap().into();
let json: String = env.get_string(&payload).unwrap().into();
if let Some(logger) = GLOBAL_AUDIT_LOGGER.get() {
logger.log_event(&event, &json);
}
}
```
Use `OnceLock<AuditLogger>` as the global logger, initialized on first call or via a new `native_init_audit(logPath)` JNI function called from Java at server startup.
- [ ] **Step 2: Register new native methods in JNI_OnLoad**
Add `native_audit_event` and `native_audit_event_global` to the `NativeMethod` array.
- [ ] **Step 3: Add audit methods to NativeTerminal.java**
```java
public static native void nativeAuditEvent(long handle, String eventType, String jsonPayload);
public static native void nativeAuditEventGlobal(String eventType, String jsonPayload);
public static native void nativeInitAudit(String logPath);
```
- [ ] **Step 4: Call nativeInitAudit from HuornMod.init()**
```java
String logPath = HuornConfig.getInstance().security.auditLog.logFile;
if (HuornConfig.getInstance().security.auditLog.enabled) {
NativeTerminal.nativeInitAudit(logPath);
}
```
- [ ] **Step 5: Run tests + build**
```bash
cd rust && cargo test && cd .. && ./gradlew build
```
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: JNI audit functions (session-bound + global)"
```
### Task 22: Command Blocklist (Input Filter)
**Files:**
- Create: `rust/src/security.rs`
- Test: `rust/tests/security_test.rs`
- [ ] **Step 1: Write failing tests for blocklist matching**
```rust
#[test]
fn test_blocklist_blocks_exact_match() {
let filter = InputFilter::new(vec!["rm -rf /".to_string()]);
assert_eq!(filter.check_line("rm -rf /"), FilterResult::Blocked("rm -rf /".to_string()));
}
#[test]
fn test_blocklist_allows_safe_input() {
let filter = InputFilter::new(vec!["rm -rf /".to_string()]);
assert_eq!(filter.check_line("ls -la"), FilterResult::Allowed);
}
#[test]
fn test_line_accumulation() {
let mut filter = InputFilter::new(vec!["rm -rf /".to_string()]);
filter.feed(b"rm -r");
assert_eq!(filter.pending_result(), None); // Not a complete line yet
filter.feed(b"f /\n");
assert_eq!(filter.pending_result(), Some(FilterResult::Blocked("rm -rf /".to_string())));
}
```
- [ ] **Step 2: Run tests to verify they fail**
- [ ] **Step 3: Implement InputFilter**
```rust
// rust/src/security.rs
pub struct InputFilter {
blocklist: Vec<String>,
line_buffer: Vec<u8>,
}
pub enum FilterResult {
Allowed,
Blocked(String),
}
impl InputFilter {
pub fn new(blocklist: Vec<String>) -> Self {
Self { blocklist, line_buffer: Vec::new() }
}
pub fn feed(&mut self, data: &[u8]) -> Vec<FilterResult> {
let mut results = Vec::new();
for &byte in data {
if byte == b'\r' || byte == b'\n' {
if !self.line_buffer.is_empty() {
let line = String::from_utf8_lossy(&self.line_buffer).to_string();
results.push(self.check_line(&line));
self.line_buffer.clear();
}
} else {
self.line_buffer.push(byte);
}
}
results
}
pub fn check_line(&self, line: &str) -> FilterResult {
for pattern in &self.blocklist {
if line.contains(pattern.as_str()) {
return FilterResult::Blocked(pattern.clone());
}
}
FilterResult::Allowed
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
- [ ] **Step 5: Wire InputFilter into TerminalSession wrapper**
Create an `AuditedSession` wrapper struct that wraps any `Box<dyn TerminalSession>`, intercepts `write()` calls to feed through `InputFilter`, and logs `COMMAND` and `BLOCKED` events to the `AuditLogger`.
- [ ] **Step 6: Run all tests**
```bash
cd rust && cargo test
```
- [ ] **Step 7: Commit**
```bash
git add -A && git commit -m "feat: command blocklist with line accumulation"
```
### Task 23: Idle Timeout
**Files:**
- Modify: `rust/src/backend/mod.rs` or create `rust/src/timeout.rs`
- [ ] **Step 1: Write failing test for idle timeout detection**
```rust
#[test]
fn test_idle_timeout_detects_idle_session() {
let mut tracker = IdleTracker::new(Duration::from_secs(1)); // 1s for testing
tracker.register(1);
std::thread::sleep(Duration::from_secs(2));
let expired = tracker.check_expired();
assert_eq!(expired, vec![1]);
}
#[test]
fn test_idle_timeout_resets_on_activity() {
let mut tracker = IdleTracker::new(Duration::from_secs(2));
tracker.register(1);
std::thread::sleep(Duration::from_secs(1));
tracker.touch(1); // Activity
std::thread::sleep(Duration::from_secs(1));
let expired = tracker.check_expired();
assert!(expired.is_empty()); // Not expired — was touched
}
```
- [ ] **Step 2: Run tests to verify they fail**
- [ ] **Step 3: Implement IdleTracker**
```rust
use std::collections::HashMap;
use std::time::{Duration, Instant};
pub struct IdleTracker {
timeout: Duration,
sessions: HashMap<u64, Instant>, // session_id -> last_activity
}
impl IdleTracker {
pub fn new(timeout: Duration) -> Self {
Self { timeout, sessions: HashMap::new() }
}
pub fn register(&mut self, session_id: u64) {
self.sessions.insert(session_id, Instant::now());
}
pub fn touch(&mut self, session_id: u64) {
if let Some(ts) = self.sessions.get_mut(&session_id) {
*ts = Instant::now();
}
}
pub fn unregister(&mut self, session_id: u64) {
self.sessions.remove(&session_id);
}
pub fn check_expired(&self) -> Vec<u64> {
let now = Instant::now();
self.sessions.iter()
.filter(|(_, ts)| now.duration_since(**ts) > self.timeout)
.map(|(id, _)| *id)
.collect()
}
}
```
- [ ] **Step 4: Wire into session lifecycle**
The `AuditedSession` wrapper calls `tracker.touch(id)` on every `write()`. A background thread (spawned on `native_init_audit` or a new `native_init_security` JNI call) runs `check_expired()` every 60 seconds and calls `kill()` on expired sessions, logging `TIMEOUT` events.
- [ ] **Step 5: Run tests to verify they pass**
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: idle timeout with background reaper thread"
```
### Task 24: Wire Audit Events from Java Lifecycle
**Files:**
- Modify: `common/src/main/java/io/fangorn/huorn/block/TerminalBlockEntity.java`
- Modify: `common/src/main/java/io/fangorn/huorn/command/KillCommand.java`
- Modify: `common/src/main/java/io/fangorn/huorn/service/TerminalManager.java`
- [ ] **Step 1: Add CONNECT event on terminal start**
In `TerminalBlockEntity.startTerminalIfNeeded()`, after successfully creating the terminal:
```java
if (HuornConfig.getInstance().security.auditLog.logConnections) {
String payload = String.format(
"{\"player\":\"%s\",\"name\":\"%s\",\"backend\":\"%s\",\"location\":\"%s\"}",
player.getStringUUID(), player.getName().getString(),
backend, worldPosition.toShortString());
NativeTerminal.nativeAuditEvent(terminal.getHandle(), "CONNECT", payload);
}
```
- [ ] **Step 2: Add DISCONNECT event on terminal stop**
In `TerminalBlockEntity.stopTerminal()`:
```java
if (HuornConfig.getInstance().security.auditLog.logConnections) {
long duration = System.currentTimeMillis() - sessionStartTime;
String payload = String.format("{\"player\":\"%s\",\"name\":\"%s\",\"duration_ms\":%d}",
playerUuid, playerName, duration);
NativeTerminal.nativeAuditEvent(terminal.getHandle(), "DISCONNECT", payload);
}
```
- [ ] **Step 3: Add ADMIN_KILL event in KillCommand**
```java
String payload = String.format("{\"admin\":\"%s\",\"target_player\":\"%s\"}",
source.getTextName(), targetPlayerName);
NativeTerminal.nativeAuditEventGlobal("ADMIN_KILL", payload);
```
- [ ] **Step 4: Add BACKEND_ERROR event on spawn failure**
In `TerminalBlockEntity.startTerminalIfNeeded()`, in the catch block:
```java
NativeTerminal.nativeAuditEventGlobal("BACKEND_ERROR",
String.format("{\"backend\":\"%s\",\"error\":\"%s\"}", backend, e.getMessage()));
```
- [ ] **Step 5: Verify Gradle builds**
```bash
./gradlew build
```
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: wire audit events from Java lifecycle (CONNECT, DISCONNECT, ADMIN_KILL, BACKEND_ERROR)"
```
---
## Phase 9: Integration & Polish
### Task 25: Backend Selection via Config + Permissions
**Files:**
- Modify: `common/src/main/java/io/fangorn/huorn/block/TerminalBlockEntity.java`
- [ ] **Step 1: Implement backend resolution logic**
In `startTerminalIfNeeded()`, before creating the terminal:
```java
private String resolveBackend(ServerPlayer player) {
HuornConfig config = HuornConfig.getInstance();
// If player has docker permission and docker is enabled, use docker
if (config.backends.docker.enabled
&& HuornPermissions.hasPermission(player, HuornPermissions.USE_DOCKER)) {
return "docker";
}
// Fall back to plain if enabled
if (config.backends.plain.enabled) {
return "plain";
}
// No backend available
return null;
}
```
- [ ] **Step 2: Wire into terminal creation**
Use `resolveBackend()` result when creating `NativeTerminal`. If null, deny with message "No terminal backend available."
- [ ] **Step 3: Persist backend in NBT**
Save and load the backend name in `TerminalBlockEntity.saveAdditional()` / `load()` so it survives chunk unloads.
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat: backend resolution via config + permissions"
```
### Task 26: /huorn audit Reads Log File
**Files:**
- Modify: `common/src/main/java/io/fangorn/huorn/command/AuditCommand.java`
- [ ] **Step 1: Implement log file reader**
Replace placeholder with actual JSONL reader:
```java
// Read log file, parse each line as JSON, filter by player, limit to last N
Path logPath = Path.of(HuornConfig.getInstance().security.auditLog.logFile);
if (!Files.exists(logPath)) {
source.sendFailure(Component.literal("[Huorn] Audit log not found."));
return 0;
}
List<String> lines = Files.readAllLines(logPath);
// Apply --last N filter
// Apply player filter
// Format for in-game display
```
- [ ] **Step 2: Commit**
```bash
git add -A && git commit -m "feat: /huorn audit reads and displays JSONL log"
```
### Task 27: End-to-End Verification
- [ ] **Step 1: Run all Rust tests**
```bash
cd rust && cargo test
```
Expected: All tests pass.
- [ ] **Step 2: Run Gradle build**
```bash
./gradlew build
```
Expected: Both JARs build.
- [ ] **Step 3: Verify no remaining alacrittymc references**
```bash
rg "alacrittymc" --type java --type json --type toml --type rust -l
```
Expected: No matches except upstream library references in comments.
- [ ] **Step 4: Verify config creates correct defaults**
Launch the mod (dev environment), check that `config/huorn.json` is created with the full nested structure.
- [ ] **Step 5: Verify /huorn commands register**
In-game or via console, run `/huorn status`. Expected: Shows terminal count and backend availability.
- [ ] **Step 6: Verify permission checks work**
De-op a test player, try to use terminal block. Expected: "You don't have permission" message.
- [ ] **Step 7: Final commit**
```bash
git add -A && git commit -m "chore: end-to-end verification complete"
```