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
+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":