Protect private bank configuration from drift reconciliation (Refs #152)

This commit is contained in:
2026-10-12 23:32:20 -04:00
parent 4e3df81786
commit d9fa15cdf6
23 changed files with 597 additions and 28 deletions
+8
View File
@@ -2,6 +2,14 @@
## 2026-10-12
### 1.9.3 — Protect private bank configuration (EC #152)
- Exclude the generated bank config from file manifests and reject file reads,
staged changes, stale activations, rollback snapshots and log reads targeting
it. Creation and rebuilding still inject the private instance credential.
- Package and offline-sign this release, then update agents before resuming
drift recovery. Old private-file stages fail safely and require a fresh plan.
### 1.9.1 — Public game-bank endpoint
- Companion to [TWS scripts #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13).
+23 -2
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.9.2"
VERSION = "1.9.3"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000
@@ -400,7 +400,17 @@ def managed_open(root, value):
os.close(fd)
def private_bank_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg"
def require_public_file(value):
if private_bank_config(value):
raise Rejected("Private bank configuration cannot be read or changed by file operations")
def managed_read(root, value):
require_public_file(value)
try:
with managed_open(root, value) as stream:
if os.fstat(stream.fileno()).st_size > MAX_FILE:
@@ -446,6 +456,8 @@ def managed_manifest(root, payload, stopping):
def validate_paths():
for value in paths:
if private_bank_config(value):
continue
check_budget()
try:
with managed_open(root, value):
@@ -480,7 +492,7 @@ def managed_manifest(root, payload, stopping):
entries += 1
if entries > MANIFEST_MAX_ENTRIES:
raise AgentError("Manifest scan exceeded its entry limit")
if child.name in ignored:
if child.name in ignored or private_bank_config(relative + "/" + child.name):
continue
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
if stat_module.S_ISLNK(info.st_mode):
@@ -1949,6 +1961,8 @@ class Agent:
files = payload.get("files", [])
if not isinstance(files, list):
raise Rejected("Invalid initial file list")
for item in files:
require_public_file(item["path"])
bank = payload.get("bank")
if bank is None:
return files
@@ -2219,6 +2233,7 @@ class Agent:
paths = set()
total_size = 0
for item in files:
require_public_file(item["path"])
scoped(directory / "app", item["path"])
if item["path"] in paths:
raise AgentError("Duplicate staged file path")
@@ -2267,6 +2282,8 @@ class Agent:
stage = directory / "stages" / identifier(stage_id)
marker = stage / "manifest.json"
saved = json.loads(marker.read_text())
for item in saved["files"]:
require_public_file(item["path"])
if saved["state"] == "activated":
return saved["before"]
if saved["state"] != "staged":
@@ -2297,6 +2314,8 @@ class Agent:
return before
def restore_files(self, app, before):
for item in before:
require_public_file(item["path"])
for item in before:
managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
@@ -2351,6 +2370,7 @@ class Agent:
def read_log(self, instance_id, generation, payload):
app = self.instance_dir(instance_id, generation) / "app"
value = payload["path"]
require_public_file(value)
count = min(2000, max(1, int(payload.get("lines", 200))))
# Validate the original relative spelling independently of symlinks.
scoped(self.root / "agent" / "log-validation", value)
@@ -2367,6 +2387,7 @@ class Agent:
raise AgentError("Log symlink resolves outside the managed instance") from None
# Reopen the resolved relative target through no-follow directory FDs;
# swapping either the original link or a target component cannot escape.
require_public_file(relative)
with managed_open(app, relative) as stream:
stat = os.fstat(stream.fileno())
if payload.get("mode") == "incremental":
+50 -3
View File
@@ -1644,9 +1644,51 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_create_materializes_private_bank_config_from_instance_credential(self):
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self):
self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
value = "configs/electric_cherry_bank.cfg"
target = app / value
secret = b'set tws_bank_token "ec_bank_private_regression"\n'
target.write_bytes(secret)
files = [{"id": 9, "path": value, "delete": True, "contentBase64": None}]
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.stage(INSTANCE, 1, {"stageId": "private", "files": files}, "stage")
marker = self.agent.instance_dir(INSTANCE, 1) / "stages/private/manifest.json"
# A stage written by a previous agent must be rejected on activation too.
module.atomic_json(marker, {"state": "staged", "files": files})
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.activate(INSTANCE, 1, "private", override_safety=True)
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.rebuild_before(INSTANCE, 1, "private", {"files": []})
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.restore_files(app, [{"path": value, "contentBase64": None}])
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.file_operation("files.read", INSTANCE, 1, {"path": value}, "read")
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.read_log(INSTANCE, 1, {"path": value})
manifest = self.agent.file_operation("files.manifest", INSTANCE, 1,
{"roots": ["configs"], "paths": [value], "ignoreNames": []}, "manifest")
self.assertNotIn(value, [item["path"] for item in manifest["files"]])
self.assertEqual(target.read_bytes(), secret)
self.assertNotIn("before", json.loads(marker.read_text()))
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.initial_files({"files": [{"path": value, "contentBase64": base64.b64encode(secret).decode()}]})
def prepare_t6_library(self):
target = self.agent.library_dir("t6", self.library_id)
module.shutil.copytree(self.library, target)
manifest = json.loads((target / "manifest.json").read_text())
manifest["game"] = "t6"
module.atomic_json(target / "manifest.json", manifest)
def test_t6_create_materializes_private_bank_config(self):
self.prepare_t6_library()
self.test_create_materializes_private_bank_config_from_instance_credential(game="t6")
def test_create_materializes_private_bank_config_from_instance_credential(self, game="t5"):
secret = "ec_bank_" + "a" * 43
payload = {"name": "server", "game": "t5", "libraryId": self.library_id,
payload = {"name": "server", "game": game, "libraryId": self.library_id,
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960,
"bank": {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}}
operation = self.operation("instance.create", payload, "create")
@@ -1699,9 +1741,14 @@ class AgentTests(unittest.TestCase):
self.assertFalse(path.exists())
self.assertEqual(self.docker.calls, calls)
def test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(self):
def test_t6_rebuild_preserves_private_bank_configuration(self):
self.prepare_t6_library()
self.test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(game="t6")
def test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(self, game="t5"):
secret = "ec_bank_" + "a" * 43
payload = self.create()
payload["game"] = game
payload["bank"] = {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}
paths = [self.bank_journal(game=game, endpoint=endpoint)
for game in ("t5", "t6")