--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+FileLanguage - a small command language for writing files, Man-In-Grey's
+test language. It is a constrained form of the POSIX file interface.
+
+ open(directory ,name ,mode ,owner ,permission) produces a symbol for the file
+ write(file ,content) appends content to the file
+ unlink(directory ,name) removes a file
+
+The modes of open:
+
+ create the file must not exist
+ replace an existing file is discarded
+ replace_keep an existing file is kept as <name>.YYYYMMDDTHHMMSSZ
+
+There is no close. Each opened file is written to a temporary file in its
+directory, and after the journal's last command every opened file is put in
+place, each in one step.
+
+The module has two halves. The composer half is JournalInterface, through
+which a composer places commands; it checks every argument when the call is
+made, and raises JournalError on a wrong one. The interpreter half checks a
+journal received as data, against the constraints of a bespoke interpreter,
+and executes it.
+
+Constraints are a map:
+
+ caller account names allowed to hand a journal to the interpreter
+ principal_caller account names allowed to name a principal other than themselves
+ file entries {directory ,name ,mode ,owner ,permission}: the files
+ the language allows; mode lists the open modes allowed, and
+ "unlink" when unlink is allowed
+"""
+
+import os ,pwd ,stat ,time
+from pathlib import Path
+
+from Journal import JournalError ,Symbol
+
+NAME = "file"
+
+MODE = ("create" ,"replace" ,"replace_keep")
+
+ARGUMENT = {
+ "open": ("directory" ,"name" ,"mode" ,"owner" ,"permission")
+ ,"write": ("file" ,"content")
+ ,"unlink": ("directory" ,"name")
+}
+
+
+# --------------------------------------------------------------------------------
+# argument checks, shared by both halves
+
+def directory_problem(value):
+ if not isinstance(value ,str) or not value.startswith("/") or "\x00" in value:
+ return "directory is to be an absolute path"
+ if os.path.normpath(value) != value:
+ return f"directory {value!r} is not in normal form; give {os.path.normpath(value)!r}"
+ return None
+
+def name_problem(value):
+ if not isinstance(value ,str) or not value or "/" in value or value in ("." ,"..") or "\x00" in value:
+ return f"name {value!r} is not a bare file name"
+ return None
+
+def permission_value(value):
+ "an int 0..0o7777, or a string of 3 or 4 octal digits; None when neither"
+ if isinstance(value ,bool):
+ return None
+ if isinstance(value ,int):
+ return value if 0 <= value <= 0o7777 else None
+ if isinstance(value ,str) and len(value) in (3 ,4) and all(c in "01234567" for c in value):
+ return int(value ,8)
+ return None
+
+
+# --------------------------------------------------------------------------------
+# composer half
+
+class JournalInterface:
+ """
+ The journal interface of the file language. A composer is given one, and
+ places commands with its three functions. open returns a Symbol: the file
+ the open command will produce when the journal is executed.
+ """
+
+ def __init__(self ,journal ,working_dpath):
+ self._journal = journal
+ self._working_dpath = Path(working_dpath)
+
+ def _directory(self ,directory):
+ if not isinstance(directory ,(str ,Path)) or str(directory) == "":
+ raise JournalError("directory is to be a path")
+ path = Path(directory)
+ if not path.is_absolute():
+ path = self._working_dpath / path
+ return os.path.normpath(str(path))
+
+ def open(self ,directory ,name ,mode ,owner ,permission):
+ directory = self._directory(directory)
+ problem = name_problem(name)
+ if problem: raise JournalError(f"open: {problem}")
+ if mode not in MODE:
+ raise JournalError(f"open: mode {mode!r} is not one of {', '.join(MODE)}")
+ if not isinstance(owner ,str) or not owner:
+ raise JournalError("open: owner is to be an account name")
+ value = permission_value(permission)
+ if value is None:
+ raise JournalError(f"open: permission {permission!r} is not an integer 0 to 0o7777 or three or four octal digits")
+ return self._journal.place("open" ,{
+ "directory": directory ,"name": name ,"mode": mode ,"owner": owner ,"permission": value
+ })
+
+ def write(self ,file ,content):
+ if not isinstance(file ,Symbol) or self._journal.command_list[file.index].command != "open":
+ raise JournalError("write: file is to be the symbol an open returned")
+ if isinstance(content ,str):
+ content = content.encode("utf-8")
+ if not isinstance(content ,(bytes ,bytearray)):
+ raise JournalError("write: content is to be text or bytes")
+ self._journal.place("write" ,{"file": file ,"content": bytes(content)})
+
+ def unlink(self ,directory ,name):
+ directory = self._directory(directory)
+ problem = name_problem(name)
+ if problem: raise JournalError(f"unlink: {problem}")
+ self._journal.place("unlink" ,{"directory": directory ,"name": name})
+
+
+# --------------------------------------------------------------------------------
+# interpreter half: checks, each returning a list of problems
+
+def check_wellformed(data):
+ problem_list = []
+ for index ,command in enumerate(data["command_list"]):
+ name ,argument = command["command"] ,command["argument"]
+ if name not in ARGUMENT:
+ problem_list.append(f"@{index}: unknown command {name!r}"); continue
+ if set(argument) != set(ARGUMENT[name]):
+ problem_list.append(f"@{index} {name}: arguments {sorted(argument)}, expected {sorted(ARGUMENT[name])}"); continue
+ if name in ("open" ,"unlink"):
+ for problem in (directory_problem(argument["directory"]) ,name_problem(argument["name"])):
+ if problem: problem_list.append(f"@{index} {name}: {problem}")
+ if name == "open":
+ if argument["mode"] not in MODE:
+ problem_list.append(f"@{index} open: unknown mode {argument['mode']!r}")
+ if not isinstance(argument["owner"] ,str) or not argument["owner"]:
+ problem_list.append(f"@{index} open: owner is not an account name")
+ if permission_value(argument["permission"]) is None or not isinstance(argument["permission"] ,int):
+ problem_list.append(f"@{index} open: permission {argument['permission']!r} is not valid")
+ if name == "write":
+ file = argument["file"]
+ if not Symbol.is_data(file) or data["command_list"][file["symbol"]]["command"] != "open":
+ problem_list.append(f"@{index} write: file is not the symbol of an open")
+ if not isinstance(argument["content"] ,(bytes ,bytearray)):
+ problem_list.append(f"@{index} write: content is not bytes")
+ return problem_list
+
+def check_constraint(data ,constraint ,caller):
+ problem_list = []
+ if caller not in constraint.get("caller" ,()):
+ return [f"caller {caller!r} is not allowed to hand journals to this interpreter"]
+ principal = data.get("principal")
+ if principal is not None and principal != caller and caller not in constraint.get("principal_caller" ,()):
+ problem_list.append(f"caller {caller!r} is not allowed to name the principal {principal!r}")
+ allowed = {}
+ for entry in constraint.get("file" ,()):
+ allowed[(entry["directory"] ,entry["name"])] = entry
+ for index ,command in enumerate(data["command_list"]):
+ name ,argument = command["command"] ,command["argument"]
+ if name not in ("open" ,"unlink"):
+ continue
+ where = f"{argument['directory']}/{argument['name']}"
+ entry = allowed.get((argument["directory"] ,argument["name"]))
+ if entry is None:
+ problem_list.append(f"@{index} {name}: {where} is not a file this interpreter allows"); continue
+ if name == "unlink":
+ if "unlink" not in entry.get("mode" ,()):
+ problem_list.append(f"@{index} unlink: {where} is not allowed to be unlinked")
+ continue
+ if argument["mode"] not in entry.get("mode" ,()):
+ problem_list.append(f"@{index} open: {where} is not allowed in mode {argument['mode']}")
+ if argument["owner"] != entry.get("owner"):
+ problem_list.append(f"@{index} open: {where} is to be owned by {entry.get('owner')!r}, not {argument['owner']!r}")
+ if argument["permission"] != permission_value(entry.get("permission")):
+ problem_list.append(f"@{index} open: {where} is to have permission {entry.get('permission')}, not {argument['permission']:04o}")
+ return problem_list
+
+def check_validity(data):
+ problem_list = []
+ for index ,command in enumerate(data["command_list"]):
+ if command["command"] == "open":
+ owner = command["argument"]["owner"]
+ try:
+ pwd.getpwnam(owner)
+ except KeyError:
+ problem_list.append(f"@{index} open: no account is named {owner!r}")
+ return problem_list
+
+def _regular_or_absent(dirfd ,name):
+ "True when absent; False when a regular file; raises OSError otherwise"
+ try:
+ st = os.lstat(name ,dir_fd=dirfd)
+ except FileNotFoundError:
+ return True
+ if not stat.S_ISREG(st.st_mode):
+ raise OSError("it exists and is not a regular file")
+ return False
+
+def check_filesystem(data):
+ problem_list = []
+ seen = set()
+ for index ,command in enumerate(data["command_list"]):
+ name ,argument = command["command"] ,command["argument"]
+ if name not in ("open" ,"unlink"):
+ continue
+ directory ,file_name = argument["directory"] ,argument["name"]
+ where = f"{directory}/{file_name}"
+ if (directory ,file_name) in seen:
+ problem_list.append(f"@{index} {name}: {where} is named by more than one command"); continue
+ seen.add((directory ,file_name))
+ if os.path.realpath(directory) != directory:
+ problem_list.append(f"@{index} {name}: {directory} passes through a symbolic link"); continue
+ try:
+ dirfd = os.open(directory ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ except OSError as error:
+ problem_list.append(f"@{index} {name}: cannot open directory {directory} ({error.strerror})"); continue
+ try:
+ absent = _regular_or_absent(dirfd ,file_name)
+ if name == "open" and argument["mode"] == "create" and not absent:
+ problem_list.append(f"@{index} open: {where} exists, and the mode is create")
+ except OSError as error:
+ problem_list.append(f"@{index} {name}: {where}: {error}")
+ finally:
+ os.close(dirfd)
+ return problem_list
+
+CHECK = (
+ ("wellformed" ,lambda data ,constraint ,caller: check_wellformed(data))
+ ,("constraint" ,check_constraint)
+ ,("validity" ,lambda data ,constraint ,caller: check_validity(data))
+ ,("filesystem" ,lambda data ,constraint ,caller: check_filesystem(data))
+)
+
+
+# --------------------------------------------------------------------------------
+# interpreter half: execution
+
+def _backup_name(dirfd ,name):
+ stamp = time.strftime("%Y%m%dT%H%M%SZ" ,time.gmtime())
+ candidate ,n = f"{name}.{stamp}" ,1
+ while True:
+ try:
+ os.lstat(candidate ,dir_fd=dirfd)
+ except FileNotFoundError:
+ return candidate
+ candidate ,n = f"{name}.{stamp}.{n}" ,n + 1
+
+def execute(data ,log):
+ """
+ Execute the commands in order, then put each opened file in place. Stops
+ at the first error, removing every temporary file not yet put in place.
+ Returns 0, or 1 on an error.
+ """
+ opened = {} # symbol index -> {dirfd ,fd ,tmp ,argument}
+ try:
+ for index ,command in enumerate(data["command_list"]):
+ name ,argument = command["command"] ,command["argument"]
+ if name == "open":
+ dirfd = os.open(argument["directory"] ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ tmp = f".{argument['name']}.Man_In_Grey.{os.getpid()}.{index}"
+ fd = os.open(tmp ,os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW ,0o600 ,dir_fd=dirfd)
+ opened[index] = {"dirfd": dirfd ,"fd": fd ,"tmp": tmp ,"argument": argument}
+ elif name == "write":
+ fd ,view = opened[argument["file"]["symbol"]]["fd"] ,memoryview(argument["content"])
+ while view:
+ view = view[os.write(fd ,view):]
+ elif name == "unlink":
+ dirfd = os.open(argument["directory"] ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ try:
+ if not _regular_or_absent(dirfd ,argument["name"]):
+ os.unlink(argument["name"] ,dir_fd=dirfd)
+ os.fsync(dirfd)
+ log(f"unlinked {argument['directory']}/{argument['name']}")
+ finally:
+ os.close(dirfd)
+
+ for index in sorted(opened):
+ record = opened[index]
+ argument ,dirfd ,fd ,tmp = record["argument"] ,record["dirfd"] ,record["fd"] ,record["tmp"]
+ account = pwd.getpwnam(argument["owner"])
+ os.fsync(fd)
+ os.fchown(fd ,account.pw_uid ,account.pw_gid)
+ os.fchmod(fd ,argument["permission"])
+ os.fsync(fd)
+ os.close(fd); record["fd"] = None
+ target = argument["name"]
+ where = f"{argument['directory']}/{target}"
+ if argument["mode"] == "create":
+ os.link(tmp ,target ,src_dir_fd=dirfd ,dst_dir_fd=dirfd ,follow_symlinks=False)
+ os.unlink(tmp ,dir_fd=dirfd)
+ log(f"created {where}")
+ else:
+ kept = None
+ if argument["mode"] == "replace_keep" and not _regular_or_absent(dirfd ,target):
+ kept = _backup_name(dirfd ,target)
+ os.link(target ,kept ,src_dir_fd=dirfd ,dst_dir_fd=dirfd ,follow_symlinks=False)
+ os.rename(tmp ,target ,src_dir_fd=dirfd ,dst_dir_fd=dirfd)
+ log(f"replaced {where}" + (f" (kept as {kept})" if kept else ""))
+ record["tmp"] = None
+ os.fsync(dirfd)
+ return 0
+ except OSError as error:
+ log(f"error {error}")
+ return 1
+ finally:
+ for record in opened.values():
+ if record.get("fd") is not None:
+ os.close(record["fd"])
+ if record.get("tmp") is not None:
+ try:
+ os.unlink(record["tmp"] ,dir_fd=record["dirfd"])
+ except OSError:
+ pass
+ os.close(record["dirfd"])
--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+Journal - the journal of Man-In-Grey, independent of any command language.
+
+A composer places commands into a journal. A command is data: the name of
+the command, its arguments, and the composer that placed it. A command whose
+execution produces a value, such as an open file, is given a symbol when it
+is placed. A later command names that symbol where it needs the value. The
+value does not exist until the journal interpreter executes the command, so
+the composer holds the symbol and nothing more.
+
+The journal travels to the journal interpreter encoded in CBOR. CBOR carries
+strings and byte strings with their lengths, so content is never scanned for
+marks, and decoding it makes maps, lists, strings and bytes, never code.
+
+A command language is defined elsewhere, as a module with a composer half,
+its journal interface, and an interpreter half, its guard and executor.
+"""
+
+import cbor2
+
+JOURNAL_VERSION = 2
+
+
+class JournalError(Exception):
+ """A journal interface call was given a wrong argument, or a journal is malformed."""
+
+
+class Symbol:
+ """
+ The value a command will produce when it is executed. A symbol is the
+ index of the command that produces it, counted from 0. It is encoded in the
+ journal as {"symbol": index}.
+ """
+ __slots__ = ("index" ,)
+
+ def __init__(self ,index):
+ self.index = index
+
+ def as_data(self):
+ return {"symbol": self.index}
+
+ def __repr__(self):
+ return f"Symbol({self.index})"
+
+ @staticmethod
+ def is_data(value):
+ return isinstance(value ,dict) and set(value) == {"symbol"} and isinstance(value["symbol"] ,int)
+
+
+class Command:
+ __slots__ = ("command" ,"argument" ,"composer")
+
+ def __init__(self ,command ,argument ,composer):
+ self.command = command
+ self.argument = argument
+ self.composer = composer
+
+ def as_data(self):
+ return {
+ "command": self.command
+ ,"argument": {k: (v.as_data() if isinstance(v ,Symbol) else v) for k ,v in self.argument.items()}
+ ,"composer": self.composer
+ }
+
+
+class Journal:
+ """
+ The commands of one run, in order, for one command language. principal is
+ the account the journal was composed on behalf of, when that is not the
+ account that hands it to the gate.
+ """
+
+ def __init__(self ,command_language ,principal=None):
+ self.command_language = command_language
+ self.principal = principal
+ self.meta = {}
+ self.command_list = []
+ self.composer = None
+
+ def place(self ,command ,argument):
+ """Place a command; returns its symbol."""
+ self.command_list.append(Command(command ,dict(argument) ,self.composer))
+ return Symbol(len(self.command_list) - 1)
+
+ def as_data(self):
+ data = {
+ "version": JOURNAL_VERSION
+ ,"command_language": self.command_language
+ ,"meta": dict(self.meta)
+ ,"command_list": [c.as_data() for c in self.command_list]
+ }
+ if self.principal is not None:
+ data["principal"] = self.principal
+ return data
+
+ def encode(self):
+ return cbor2.dumps(self.as_data() ,canonical=True)
+
+ def print(self ,file=None):
+ import sys
+ file = file or sys.stdout
+ if not self.command_list:
+ print("(the journal is empty)" ,file=file)
+ for index ,command in enumerate(self.command_list):
+ print(command_line(index ,command.as_data()) ,file=file)
+
+
+def decode(journal_bytes):
+ """
+ Decode a journal from CBOR into its data, checking only the shape common to
+ every command language. Raises JournalError.
+ """
+ try:
+ data = cbor2.loads(journal_bytes)
+ except Exception as error:
+ raise JournalError(f"the journal is not valid CBOR: {error}")
+ if not isinstance(data ,dict):
+ raise JournalError("the journal is not a map")
+ if data.get("version") != JOURNAL_VERSION:
+ raise JournalError(f"journal version {data.get('version')!r}; this interpreter reads version {JOURNAL_VERSION}")
+ if not isinstance(data.get("command_language") ,str):
+ raise JournalError("the journal names no command language")
+ if "principal" in data and not isinstance(data["principal"] ,str):
+ raise JournalError("the journal's principal is not an account name")
+ command_list = data.get("command_list")
+ if not isinstance(command_list ,list):
+ raise JournalError("the journal has no command list")
+ for index ,command in enumerate(command_list):
+ if not (isinstance(command ,dict) and isinstance(command.get("command") ,str) and isinstance(command.get("argument") ,dict)):
+ raise JournalError(f"command {index} is not a command")
+ for name ,value in command["argument"].items():
+ if isinstance(value ,dict) and not Symbol.is_data(value):
+ raise JournalError(f"command {index}: argument {name} is neither a value nor a symbol")
+ if Symbol.is_data(value) and not (0 <= value["symbol"] < index):
+ raise JournalError(f"command {index}: argument {name} names symbol {value['symbol']}, which no earlier command produces")
+ return data
+
+
+def command_line(index ,command):
+ """One line describing a command, for printing a journal."""
+ part = []
+ for name ,value in command["argument"].items():
+ if Symbol.is_data(value):
+ part.append(f"{name}=@{value['symbol']}")
+ elif isinstance(value ,(bytes ,bytearray)):
+ part.append(f"{name}=<{len(value)} bytes>")
+ elif name == "permission" and isinstance(value ,int):
+ part.append(f"{name}={value:04o}")
+ else:
+ part.append(f"{name}={value!r}")
+ composer = command.get("composer")
+ return f"@{index:<3} {command['command']:<8} {' '.join(part)}" + (f" ({composer})" if composer else "")
#!/usr/bin/env bash
-# man_in_grey — canonical entrypoint for Man_In_Grey
-# - Resolves repo → release dirs
-# - Requires the privileged gasket (single code path)
-# - Always invokes the Python orchestrator with --apply-cmd <gasket>
+# Man_In_Grey - runs the orchestrator, Man_In_Grey.py, which is beside this script.
set -euo pipefail
-
-# --- resolve this script’s absolute path (portable) ---
-_this="${BASH_SOURCE[0]:-$0}"
-
-if command -v realpath >/dev/null 2>&1; then
- _this_abs="$(realpath -- "$_this")"
-else
- if _tmp="$(readlink -f -- "$_this" 2>/dev/null)"; then
- _this_abs="${_tmp}"
- else
- _dir="$(cd "$(dirname -- "$_this")" && pwd -P)"
- _base="$(basename -- "$_this")"
- _this_abs="${_dir}/${_base}"
- fi
-fi
-
-_shell_dir="$(cd "$(dirname -- "$_this_abs")" && pwd -P)" # .../release/shell
-_release_dir="$(cd "${_shell_dir}/.." && pwd -P)" # .../release
-_repo_root="$(cd "${_release_dir}/.." && pwd -P)" # repo root
-
-# --- orchestrator path (prefer released copy) ---
-_py_release="${_release_dir}/python3/Man_In_Grey.py"
-_py_dev="${_repo_root}/developer/source/Man_In_Grey.py"
-if [[ -f "$_py_release" ]]; then
- _py_entry="$_py_release"
-elif [[ -f "$_py_dev" ]]; then
- _py_entry="$_py_dev"
-else
- echo "error: Man_In_Grey.py not found in release/python3/ or developer/source/" >&2
- exit 2
-fi
-
-# --- arch normalize (uname -m → release/<arch>) ---
-_arch_raw="$(uname -m | tr '[:upper:]' '[:lower:]')"
-case "$_arch_raw" in
- amd64|x64) _arch="x86_64" ;;
- x86_64) _arch="x86_64" ;;
- i386|i486|i586|i686) _arch="i686" ;;
- arm64|aarch64) _arch="aarch64";;
- armv7l) _arch="armv7l" ;;
- armv6l) _arch="armv6l" ;;
- riscv64) _arch="riscv64";;
- ppc64le|powerpc64le) _arch="ppc64le";;
- s390x) _arch="s390x" ;;
- *) _arch="$_arch_raw" ;;
-esac
-
-_gasket="${_release_dir}/${_arch}/man_in_grey_apply"
-if [[ ! -x "$_gasket" ]]; then
- echo "error: gasket missing: ${_gasket}" >&2
- echo "hint: from \$REPO_HOME/developer: run compile then release" >&2
- exit 2
-fi
-
-# --- run orchestrator (single code path via gasket) ---
-exec "${PYTHON:-python3}" "$_py_entry" --apply-cmd "$_gasket" "$@"
+_this="$(realpath -- "${BASH_SOURCE[0]}")"
+exec "${PYTHON:-python3}" "$(dirname -- "$_this")/Man_In_Grey.py" "$@"
#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
"""
-Man_In_Grey.py — orchestration entrypoint
-
-Phase 0 (bootstrap):
- - Ensure acceptance filter exists (create default in CWD if --input_acceptance omitted)
- - Validate --stage
- - If --phase-0-then-stop: exit here (no scan ,no execution)
-
-Phase 1 (outer):
- - Discover every file under --stage; acceptance filter decides which to include
- - Execute each config’s configure(prov ,planner ,WriteFileMeta) into ONE Planner
- - Optionally print the planner; optionally stop
-
-Phase 2 (apply):
- - Encode plan to CBOR
- - Prefer piping CBOR to privileged gasket at release/<arch>/man_in_grey_apply
- - Else fall back to release/python3/executor_inner.py --plan -
+Man_In_Grey
+
+Runs the composers on a stage, which place commands into one journal, and
+hands the journal to a gate, which starts a bespoke journal interpreter. The
+journal interpreter checks every command against its constraints, and only
+then executes the journal.
+
+A composer is a Python file on the stage defining compose(prov ,journal_iface).
+prov describes where the composer is; journal_iface places commands of the
+file command language: open, write and unlink.
+
+Usage: Man_In_Grey --stage <dir> [option ...]
+ help | -h | --help print this message (also printed when given no arguments)
+ --stage <dir> the stage of composers
+ --input_acceptance <file> a Python file defining accept(prov), true for each stage
+ file that is a composer; by default every file ending in
+ .py whose name does not begin with a dot
+ --gate <file> the gate to hand the journal to; by default
+ ../Man_In_Grey_file_gate/Man_In_Grey_file_gate
+ --constraints <file> constraints for an unprivileged run, given to the
+ journal interpreter
+ --phase-0-then-stop stop after checking the stage and the filter
+ --phase-1-print print the journal after composing
+ --phase-1-then-stop stop after composing; nothing reaches the gate
+ --phase-2-print print the journal as the journal interpreter decoded it
+ --phase-2-then-stop stop after decoding, before any check
+ --phase-2-wellformed-then-stop stop after the named check, reporting OK when it passed
+ --phase-2-constraint-then-stop
+ --phase-2-validity-then-stop
+ --phase-2-filesystem-then-stop
+
+Exit status: 0 when the run did what it was asked; 1 when a check refused the
+journal or a command failed to execute; 2 when the run could not start or a
+composer failed.
"""
-from __future__ import annotations
-
-# no bytecode anywhere
import sys ,os
sys.dont_write_bytecode = True
-os.environ.setdefault("PYTHONDONTWRITEBYTECODE" ,"1")
+import datetime ,getpass ,runpy ,stat ,subprocess ,traceback
from pathlib import Path
-import argparse
-import getpass
-import runpy
-import subprocess
-import datetime as _dt
-import platform
-import stat as _stat
-
-# Local model types (Planner.py beside this file during dev; in release it’s also shipped)
-from Planner import (
- Planner
- ,PlanProvenance
- ,WriteFileMeta
- ,Journal
- ,Command
-)
-
-# ---------- constants ----------
-
-DEFAULT_FILTER_FILENAME = "Man_In_Grey_input_acceptance.py"
-
-DEFAULT_FILTER_SOURCE = """# Man_In_Grey acceptance filter (default template)
-# Return True to include a config file ,False to skip it.
-# You receive a PlanProvenance object named `prov`.
-#
-# Common fields:
-# prov.stage_root_dpath : Path
-# prov.config_abs_fpath : Path
-# prov.config_rel_fpath : Path
-# prov.read_dir_dpath : Path
-# prov.read_fname : str
-#
-# 1) Accept everything (default):
-# def accept(prov):
-# return True
-#
-# 2) Only a namespace:
-# def accept(prov):
-# return prov.config_rel_fpath.as_posix().startswith("dns/")
-#
-# 3) Exclude editor junk:
-# def accept(prov):
-# r = prov.config_rel_fpath.as_posix()
-# return not (r.endswith("~") or r.endswith(".swp"))
-#
-def accept(prov):
- return True
-"""
-
-# ---------- small utils ----------
-
-def iso_utc_now_str()-> str:
- return _dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
-
-def _repo_root_from(start: Path)-> Path|None:
- cur = start.resolve()
- for p in (cur ,*cur.parents):
- if (p/"release").is_dir():
- return p
- return None
-
-def _norm_arch_name()-> str:
- m = (platform.machine() or "").lower()
- table = {
- "amd64": "x86_64"
- ,"x64": "x86_64"
- ,"x86_64": "x86_64"
- ,"i386": "i686"
- ,"i486": "i686"
- ,"i586": "i686"
- ,"i686": "i686"
- ,"arm64": "aarch64"
- ,"aarch64": "aarch64"
- ,"armv7l": "armv7l"
- ,"armv6l": "armv6l"
- ,"riscv64": "riscv64"
- ,"ppc64le": "ppc64le"
- ,"powerpc64le": "ppc64le"
- ,"s390x": "s390x"
- }
- return table.get(m ,m or "unknown")
-def _ensure_filter_file(filter_arg: str|None)-> Path:
- if filter_arg:
- p = Path(filter_arg)
- if not p.is_file():
- raise RuntimeError(f"--input_acceptance file not found: {p}")
- return p
- p = Path.cwd()/DEFAULT_FILTER_FILENAME
- if not p.exists():
- try:
- p.write_text(DEFAULT_FILTER_SOURCE ,encoding="utf-8")
- print(f"(created default filter at {p})")
- except Exception as e:
- raise RuntimeError(f"failed to create default filter {p}: {e}")
- return p
+import Journal
+import FileLanguage
+
+# The promoted layout: this file in Man_In_Grey/, beside Man_In_Grey_file_gate/.
+# The same layout is staged in developer/scratchpad/made/ and promoted to consumer/.
+HERE_DPATH = Path(__file__).resolve().parent
+DEFAULT_GATE = HERE_DPATH.parent / "Man_In_Grey_file_gate" / "Man_In_Grey_file_gate"
+
+STOP_OPTION = {
+ "--phase-2-then-stop": "decode"
+ ,"--phase-2-wellformed-then-stop": "wellformed"
+ ,"--phase-2-constraint-then-stop": "constraint"
+ ,"--phase-2-validity-then-stop": "validity"
+ ,"--phase-2-filesystem-then-stop": "filesystem"
+}
+FLAG = {"--phase-0-then-stop" ,"--phase-1-print" ,"--phase-1-then-stop" ,"--phase-2-print"} | set(STOP_OPTION)
+VALUE = {"--stage" ,"--input_acceptance" ,"--gate" ,"--constraints"}
+
+
+# --------------------------------------------------------------------------------
+# provenance
+
+class Provenance:
+ """
+ Where a composer is. Given to the acceptance filter and to compose().
+ """
+ __slots__ = (
+ "stage_root_dpath"
+ ,"composer_abs_fpath"
+ ,"composer_rel_fpath"
+ ,"composer_dir_dpath"
+ ,"implied_fname"
+ ,"process_user"
+ ,"cwd_dpath"
+ )
-def _load_accept_func(filter_path: Path):
+ def __init__(self ,stage_root ,composer_path):
+ self.stage_root_dpath = stage_root.resolve()
+ self.composer_abs_fpath = composer_path.resolve()
+ self.composer_rel_fpath = self.composer_abs_fpath.relative_to(self.stage_root_dpath)
+ self.composer_dir_dpath = self.composer_abs_fpath.parent
+ name = self.composer_abs_fpath.name
+ for suffix in (".stage.py" ,".py"):
+ if name.endswith(suffix):
+ name = name[:-len(suffix)]; break
+ self.implied_fname = name
+ self.process_user = getpass.getuser()
+ self.cwd_dpath = Path.cwd().resolve()
+
+
+# --------------------------------------------------------------------------------
+# the phases
+
+def default_accept(prov):
+ name = prov.composer_rel_fpath.name
+ return name.endswith(".py") and not name.startswith(".")
+
+def accept_function(filter_path):
+ if filter_path is None:
+ return default_accept
env = runpy.run_path(str(filter_path))
- fn = env.get("accept")
- if not callable(fn):
- raise RuntimeError(f"{filter_path}: missing callable 'accept(prov)'")
- return fn
-
-def _walk_all_files(stage_root: Path):
- root = stage_root.resolve()
- for dirpath ,dirnames ,filenames in os.walk(root ,followlinks=False):
- # prune symlinked dirs
- dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(dirpath ,d))]
- for fname in filenames:
- p = Path(dirpath ,fname)
- try:
- st = p.lstat()
- if _stat.S_ISREG(st.st_mode) or _stat.S_ISLNK(st.st_mode):
- yield p.resolve()
- except Exception:
+ function = env.get("accept")
+ if not callable(function):
+ raise RuntimeError(f"{filter_path} defines no accept(prov)")
+ return function
+
+def composer_list(stage_root ,accept):
+ """The accepted stage files, breadth first, then by name. Symbolic links to directories are not followed."""
+ found = []
+ for dirpath ,dirnames ,filenames in os.walk(stage_root):
+ dirnames[:] = sorted(d for d in dirnames if not os.path.islink(os.path.join(dirpath ,d)))
+ for name in filenames:
+ path = Path(dirpath ,name)
+ if not stat.S_ISREG(path.stat().st_mode):
continue
-
-def find_config_paths(stage_root: Path ,accept_func)-> list[Path]:
- out: list[tuple[int ,str ,Path]] = []
- root = stage_root.resolve()
- for p in _walk_all_files(stage_root):
- prov = PlanProvenance(stage_root=stage_root ,config_path=p)
+ prov = Provenance(stage_root ,path)
+ if accept(prov):
+ found.append((len(prov.composer_rel_fpath.parts) ,prov.composer_rel_fpath.as_posix() ,prov))
+ found.sort(key=lambda t: (t[0] ,t[1]))
+ return [t[2] for t in found]
+
+def compose_all(stage_root ,prov_list):
+ """Run each composer into one journal. Raises RuntimeError naming the composer that failed."""
+ journal = Journal.Journal(FileLanguage.NAME)
+ journal_iface = FileLanguage.JournalInterface(journal ,Path.cwd())
+ for prov in prov_list:
+ rel = prov.composer_rel_fpath.as_posix()
+ journal.composer = rel
try:
- if accept_func(prov):
- rel = p.resolve().relative_to(root)
- out.append((len(rel.parts) ,rel.as_posix() ,p.resolve()))
- except Exception as e:
- raise RuntimeError(f"accept() failed on {prov.config_rel_fpath.as_posix()}: {e}")
- out.sort(key=lambda t: (t[0] ,t[1])) # breadth-first ,then lexicographic
- return [t[2] for t in out]
-
-def _run_all_configs_into_single_planner(stage_root: Path ,cfgs: list[Path])-> Planner:
- agg = PlanProvenance(stage_root=stage_root ,config_path=stage_root/"(aggregate).py")
- planner = Planner(provenance=agg)
- for cfg in cfgs:
- prov = PlanProvenance(stage_root=stage_root ,config_path=cfg)
- # requires Planner.set_provenance(prov) (already added previously)
- planner.set_provenance(prov)
- env = runpy.run_path(str(cfg))
- fn = env.get("configure")
- if not callable(fn):
- raise RuntimeError(f"{cfg}: missing callable configure(prov ,planner ,WriteFileMeta)")
- fn(prov ,planner ,WriteFileMeta)
- j = planner.journal()
- j.set_meta(
- generator_prog_str="Man_In_Grey.py"
- ,generated_at_utc_str=iso_utc_now_str()
- ,user_name_str=getpass.getuser()
- ,host_name_str=os.uname().nodename if hasattr(os ,"uname") else "unknown"
- ,stage_root_dpath_str=str(stage_root.resolve())
- ,configs_list=[str(p.resolve().relative_to(stage_root.resolve())) for p in cfgs]
- )
- return planner
-
-def _plan_to_cbor_bytes(planner: Planner)-> bytes:
- try:
- import cbor2
- except Exception as e:
- raise RuntimeError(f"cbor2 is required: {e}")
- return cbor2.dumps(planner.journal().as_dictionary() ,canonical=True)
-
-# ---------- apply paths ----------
-
-def _find_apply_cmd(repo_root: Path)-> Path|None:
- arch = _norm_arch_name()
- cand = repo_root/"release"/arch/"man_in_grey_apply"
- if cand.exists() and os.access(cand ,os.X_OK):
- return cand
- return None
-
-def _find_inner_py(repo_root: Path)-> Path|None:
- cand = repo_root/"release"/"python3"/"executor_inner.py"
- return cand if cand.is_file() else None
-
-def _apply_via_gasket(cbor_bytes: bytes, apply_cmd: Path, args) -> int:
- cmd = [str(apply_cmd), "--plan", "-"] # <— tell gasket to read from stdin
- if args.phase_2_print: cmd.append("--phase-2-print")
- if args.phase_2_then_stop: cmd.append("--phase-2-then-stop")
- if args.phase_2_wellformed_then_stop: cmd.append("--phase-2-wellformed-then-stop")
- if args.phase_2_sanity1_then_stop: cmd.append("--phase-2-sanity1-then-stop")
- if args.phase_2_validity_then_stop: cmd.append("--phase-2-validity-then-stop")
- if args.phase_2_sanity2_then_stop: cmd.append("--phase-2-sanity2-then-stop")
- proc = subprocess.run(cmd, input=cbor_bytes)
- return proc.returncode
-
-def _apply_via_inner_py(cbor_bytes: bytes ,inner_py: Path ,args)-> int:
- cmd = [
- sys.executable
- ,str(inner_py)
- ,"--plan" ,"-"
- ]
- if args.phase_2_print: cmd.append("--phase-2-print")
- if args.phase_2_then_stop: cmd.append("--phase-2-then-stop")
- if args.phase_2_wellformed_then_stop: cmd.append("--phase-2-wellformed-then-stop")
- if args.phase_2_sanity1_then_stop: cmd.append("--phase-2-sanity1-then-stop")
- if args.phase_2_validity_then_stop: cmd.append("--phase-2-validity-then-stop")
- if args.phase_2_sanity2_then_stop: cmd.append("--phase-2-sanity2-then-stop")
- proc = subprocess.run(cmd ,input=cbor_bytes)
- return proc.returncode
-
-# ---------- CLI / orchestration ----------
-
-def main(argv: list[str]|None=None)-> int:
- ap = argparse.ArgumentParser(
- prog="Man_In_Grey.py"
- ,description="Man_In_Grey orchestrator (discover → plan → CBOR → apply)"
- )
- ap.add_argument("--stage" ,default="stage"
- ,help="stage root directory (default: ./stage)")
- ap.add_argument("--input_acceptance" ,default=""
- ,help=f"path to acceptance filter exporting accept(prov) "
- f"(default: ./{DEFAULT_FILTER_FILENAME}; created if missing)")
- ap.add_argument("--phase-0-then-stop" ,action="store_true"
- ,help="stop after arg checks & filter bootstrap (no stage scan)")
- # Phase-1 controls
- ap.add_argument("--phase-1-print" ,action="store_true"
- ,help="print master planner (phase 1)")
- ap.add_argument("--phase-1-then-stop" ,action="store_true"
- ,help="stop after phase 1")
- # Phase-2 controls (forwarded to gasket/inner)
- ap.add_argument("--phase-2-print" ,action="store_true"
- ,help="print decoded journal (phase 2)")
- ap.add_argument("--phase-2-then-stop" ,action="store_true"
- ,help="stop after phase 2 decode")
- ap.add_argument("--phase-2-wellformed-then-stop" ,action="store_true")
- ap.add_argument("--phase-2-sanity1-then-stop" ,action="store_true")
- ap.add_argument("--phase-2-validity-then-stop" ,action="store_true")
- ap.add_argument("--phase-2-sanity2-then-stop" ,action="store_true")
- # Optional explicit paths
- ap.add_argument("--apply-cmd" ,default=""
- ,help="override path to privileged gasket (pipes CBOR to stdin)")
- ap.add_argument("--inner-py" ,default=""
- ,help="override path to executor_inner.py (fallback path)")
-
- args = ap.parse_args(argv)
-
- # Repo root
- repo_root = _repo_root_from(Path.cwd()) or _repo_root_from(Path(__file__).resolve()) or Path.cwd()
-
- # Phase 0 bootstrap
- stage_root = Path(args.stage)
- try:
- filter_path = _ensure_filter_file(args.input_acceptance or None)
- except Exception as e:
- print(f"error: {e}" ,file=sys.stderr)
- return 2
+ env = runpy.run_path(str(prov.composer_abs_fpath))
+ compose = env.get("compose")
+ if not callable(compose):
+ raise RuntimeError("defines no compose(prov ,journal_iface)")
+ compose(prov ,journal_iface)
+ except Journal.JournalError as error:
+ raise RuntimeError(f"{rel}: {error}")
+ except RuntimeError as error:
+ raise RuntimeError(f"{rel}: {error}")
+ except Exception:
+ raise RuntimeError(f"{rel}: the composer raised an error\n{traceback.format_exc()}")
+ journal.composer = None
+ journal.meta = {
+ "composed_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
+ ,"process_user": getpass.getuser()
+ ,"host": os.uname().nodename
+ ,"stage_root": str(stage_root.resolve())
+ ,"composer_list": [p.composer_rel_fpath.as_posix() for p in prov_list]
+ }
+ return journal
- if not stage_root.exists():
- print(f"error: --stage not found: {stage_root}" ,file=sys.stderr)
- return 2
+def run(stage ,input_acceptance ,gate ,constraints ,flag ,stop_after):
+ stage_root = Path(stage)
if not stage_root.is_dir():
- print(f"error: --stage is not a directory: {stage_root}" ,file=sys.stderr)
+ print(f"error: the stage {stage_root} is not a directory" ,file=sys.stderr)
return 2
-
- if args.phase_0_then_stop:
- print(f"phase-0 OK: stage at {stage_root.resolve()} ,filter at {filter_path}")
- return 0
-
- # Acceptance
try:
- accept_func = _load_accept_func(filter_path)
- except Exception as e:
- print(f"error: {e}" ,file=sys.stderr)
+ accept = accept_function(Path(input_acceptance) if input_acceptance else None)
+ except Exception as error:
+ print(f"error: acceptance filter: {error}" ,file=sys.stderr)
return 2
-
- # Phase 1: discover + plan
- cfgs = find_config_paths(stage_root ,accept_func)
- if not cfgs:
- print("No configuration files found.")
+ if "--phase-0-then-stop" in flag:
+ print(f"phase 0: OK, stage {stage_root.resolve()}")
return 0
try:
- master = _run_all_configs_into_single_planner(stage_root ,cfgs)
- except SystemExit:
- raise
- except Exception as e:
- print(f"error: executing configs: {e}" ,file=sys.stderr)
+ prov_list = composer_list(stage_root ,accept)
+ journal = compose_all(stage_root ,prov_list)
+ except Exception as error:
+ print(f"error: {error}" ,file=sys.stderr)
return 2
-
- if args.phase_1_print:
- master.print()
- if args.phase_1_then_stop:
+ if "--phase-1-print" in flag:
+ journal.print()
+ if "--phase-1-then-stop" in flag:
return 0
- # Phase 2: encode + apply
- try:
- cbor_bytes = _plan_to_cbor_bytes(master)
- except Exception as e:
- print(f"error: CBOR encode failed: {e}" ,file=sys.stderr)
+ gate_path = Path(gate) if gate else DEFAULT_GATE
+ if not os.access(gate_path ,os.X_OK):
+ print(f"error: the gate {gate_path} is missing; in the developer role, build and promote it" ,file=sys.stderr)
return 2
-
- # Always use the gasket under release/<arch>/man_in_grey_apply (or explicit --apply-cmd)
- apply_cmd = Path(args.apply_cmd).resolve() if args.apply_cmd else (_find_apply_cmd(repo_root) or None)
- if not apply_cmd:
- print("error: gasket not found; build/release first (release/<arch>/man_in_grey_apply)", file=sys.stderr)
+ command = [str(gate_path)]
+ if constraints:
+ command += ["--constraints" ,str(Path(constraints).resolve())]
+ if "--phase-2-print" in flag:
+ command.append("--print")
+ if stop_after:
+ command += ["--stop-after" ,stop_after]
+ return subprocess.run(command ,input=journal.encode()).returncode
+
+
+# --------------------------------------------------------------------------------
+# command line
+
+def print_help():
+ print(__doc__.strip())
+ return 0
+
+def CLI(argv=None):
+ argv = sys.argv[1:] if argv is None else argv
+ if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+ value ,flag = {} ,set()
+ i = 0
+ while i < len(argv):
+ word = argv[i]
+ if word in VALUE and i + 1 < len(argv):
+ value[word] = argv[i + 1]; i += 2
+ elif word in FLAG:
+ flag.add(word); i += 1
+ else:
+ print(f"Error: unknown or incomplete option '{word}'." ,file=sys.stderr)
+ return 2
+ if "--stage" not in value:
+ print("Error: --stage <dir> is required." ,file=sys.stderr)
return 2
-
- try:
- return _apply_via_gasket(cbor_bytes ,apply_cmd ,args)
- except Exception as e:
- print(f"error: apply-cmd failed: {e}" ,file=sys.stderr)
+ stop = [STOP_OPTION[f] for f in flag if f in STOP_OPTION]
+ if len(stop) > 1:
+ print("Error: give at most one --phase-2-...-then-stop option." ,file=sys.stderr)
return 2
-
- inner_py = Path(args.inner_py).resolve() if args.inner_py else (_find_inner_py(repo_root) or None)
- if inner_py:
- try:
- return _apply_via_inner_py(cbor_bytes ,inner_py ,args)
- except Exception as e:
- print(f"error: inner executor failed: {e}" ,file=sys.stderr)
- return 2
-
- print("error: no apply path found (neither gasket nor inner Python)", file=sys.stderr)
- return 2
+ return run(
+ value["--stage"] ,value.get("--input_acceptance") ,value.get("--gate") ,value.get("--constraints")
+ ,flag ,stop[0] if stop else None
+ )
if __name__ == "__main__":
- sys.exit(main())
+ sys.exit(CLI())
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-"""
-Planner.py — plan builder for staged configuration (UNPRIVILEGED).
-
-Given: runner-side provenance (PlanProvenance) and optional defaults (WriteFileMeta).
-Does: expose Planner whose command methods (copy/displace/delete) build Command entries,
- resolving arguments with precedence: kwarg > per-call WriteFileMeta > planner default
- (and for filename, fallback to provenance-derived basename). On any argument error,
- the Command is returned with errors and NOT appended to the Journal.
-Returns: Journal (model only; dict in/out) via planner.journal().
-"""
-
-from __future__ import annotations
-
-# no bytecode anywhere (works under sudo/root shells too)
-import sys ,os
-sys.dont_write_bytecode = True
-os.environ.setdefault("PYTHONDONTWRITEBYTECODE" ,"1")
-
-from pathlib import Path
-import getpass
-
-# ===== Utilities =====
-
-def norm_perm(value: int|str)-> tuple[int ,str]|None:
- "Given int or 3/4-char octal string (optionally 0o-prefixed). Does validate/normalize. Returns (int ,'%04o') or None."
- if isinstance(value ,int):
- if 0 <= value <= 0o7777:
- return value ,f"{value:04o}"
- return None
- if isinstance(value ,str):
- s = value.strip().lower()
- if s.startswith("0o"):
- try:
- v = int(s ,8)
- return v ,f"{v:04o}"
- except Exception:
- return None
- if len(s) in (3 ,4) and all(ch in "01234567" for ch in s):
- try:
- v = int(s ,8)
- return v ,f"{v:04o}"
- except Exception:
- return None
- return None
-
-def is_abs_dpath(dpath_str: str|None)-> bool:
- "Given path string. Does quick abs dir check. Returns bool."
- return isinstance(dpath_str ,str) and dpath_str.startswith("/") and "\x00" not in dpath_str
-
-def norm_abs_dpath_str(value: str|Path|None)-> str|None:
- "Given str/Path/None. Does normalize absolute dir path string. Returns str or None."
- if value is None: return None
- s = value.as_posix() if isinstance(value ,Path) else str(value)
- return s if is_abs_dpath(s) else None
-
-def norm_dpath_str(value: str|Path|None)-> str|None:
- "Given str/Path/None. Does minimal sanitize; allows relative. Returns str or None."
- if value is None: return None
- s = value.as_posix() if isinstance(value ,Path) else str(value)
- if not s or "\x00" in s: return None
- return s
-
-def norm_fname_or_none(value: str|None)-> str|None:
- "Given candidate filename or None. Does validate bare filename. Returns str or None."
- if value is None: return None
- s = str(value)
- if not s: return None
- if "/" in s or s in ("." ,"..") or "\x00" in s: return None
- return s
-
-def norm_nonempty_owner(value: str|None)-> str|None:
- "Given owner string or None. Does minimally validate (non-empty). Returns str or None."
- if value is None: return None
- s = str(value).strip()
- return s if s else None
-
-def parse_mode(value: int|str|None)-> tuple[int|None ,str|None]:
- "Given int/str/None. Does normalize via norm_perm. Returns (int ,'%04o') or (None ,None)."
- if value is None: return None ,None
- r = norm_perm(value)
- return r if r is not None else (None ,None)
-
-def norm_content_bytes(value: bytes|str|None)-> bytes|None:
- "Given bytes/str/None. Does normalize to UTF-8 bytes or None. Returns bytes|None."
- if value is None: return None
- if isinstance(value ,bytes): return value
- return value.encode("utf-8")
-
-# ===== Wire-ready model types (no CBOR here) =====
-
-class Command:
- """
- Command — a single planned operation.
-
- Given name_str ('copy'|'displace'|'delete'), optional arg_dict, optional errors_list.
- Does hold op name, own a fresh arg_dict, collect per-entry errors.
- Returns dictionary via as_dictionary().
- """
- __slots__ = (
- "name_str"
- ,"arg_dict"
- ,"errors_list"
- )
-
- def __init__(self ,name_str: str ,arg_dict: dict|None=None ,errors_list: list[str]|None=None)-> None:
- self.name_str = name_str
- self.arg_dict = dict(arg_dict) if arg_dict is not None else {}
- self.errors_list = list(errors_list) if errors_list is not None else []
-
- def add_error(self ,msg_str: str)-> None:
- self.errors_list.append(msg_str)
-
- def as_dictionary(self)-> dict:
- return {
- "op": self.name_str
- ,"arg_dict": dict(self.arg_dict)
- ,"errors_list": list(self.errors_list)
- }
-
- def print(self ,* ,index: int|None=None ,file=None)-> None:
- """
- Given: optional index for numbering and optional file-like (defaults to stdout).
- Does: print a compact, human-readable one-line summary of this command; prints any errors indented below.
- Returns: None.
- """
- if file is None:
- import sys as _sys
- file = _sys.stdout
-
- op = self.name_str
- ad = self.arg_dict or {}
-
- # Compose destination path for display (normalize to collapse '..')
- d = ad.get("write_file_dpath_str") or ""
- f = ad.get("write_file_fname") or ""
- try:
- from pathlib import Path as _Path
- if d and f and "/" not in f:
- dst = (_Path(d)/f).resolve().as_posix()
- else:
- dst = "?"
- except Exception:
- dst = "?"
-
- prefix = f"{index:02d}. " if index is not None else ""
-
- if op == "copy":
- mode = ad.get("mode_int")
- owner = ad.get("owner_name")
- size = len(ad.get("content_bytes") or b"")
- line = f"{prefix}copy -> {dst} mode {mode:04o} owner {owner} bytes {size}"
- elif op == "displace":
- line = f"{prefix}displace -> {dst}"
- elif op == "delete":
- line = f"{prefix}delete -> {dst}"
- else:
- line = f"{prefix}?op? -> {dst}"
-
- print(line ,file=file)
-
- for err in self.errors_list:
- print(f" ! {err}" ,file=file)
-
-class Journal:
- """
- Journal — ordered list of Command plus provenance metadata (model only; no CBOR).
-
- Given optional plan_dict in wire shape (for reconstruction).
- Does manage meta, append commands, expose entries, and pack to dict.
- Returns dict via as_dictionary().
- """
- __slots__ = (
- "meta_dict"
- ,"command_list"
- )
-
- def __init__(self ,plan_dict: dict|None=None)-> None:
- self.meta_dict = {}
- self.command_list = []
- if plan_dict is not None:
- self._init_from_dict(plan_dict)
-
- def _init_from_dict(self ,plan_dict: dict)-> None:
- if not isinstance(plan_dict ,dict):
- raise ValueError("plan_dict must be a dict")
- meta = dict(plan_dict.get("meta_dict") or {})
- entries = plan_dict.get("entries_list") or []
- self.meta_dict.update(meta)
- for e in entries:
- if not isinstance(e ,dict):
- continue
- op = e.get("op") or "?"
- args = e.get("arg_dict") or {}
- errs = e.get("errors_list") or []
- self.command_list.append(Command(name_str=op ,arg_dict=dict(args) ,errors_list=list(errs)))
-
- def set_meta(self ,**kv)-> None:
- self.meta_dict.update(kv)
-
- def append(self ,cmd: Command)-> None:
- self.command_list.append(cmd)
-
- def entries_list(self)-> list[dict]:
- return [c.as_dictionary() for c in self.command_list]
-
- def as_dictionary(self)-> dict:
- return {
- "version_int": 1
- ,"meta_dict": dict(self.meta_dict)
- ,"entries_list": self.entries_list()
- }
-
- def print(self ,* ,index_start: int=1 ,file=None)-> None:
- """
- Given: optional starting index and optional file-like (defaults to stdout).
- Does: print each Command on a single line via Command.print(), numbered.
- Returns: None.
- """
- if file is None:
- import sys as _sys
- file = _sys.stdout
-
- if not self.command_list:
- print("(plan is empty)" ,file=file)
- return
-
- for i ,cmd in enumerate(self.command_list ,start=index_start):
- cmd.print(index=i ,file=file)
-
-# ===== Runner-provided provenance =====
-
-class PlanProvenance:
- """
- Runner-provided, read-only provenance for a single config script.
- """
- __slots__ = (
- "stage_root_dpath"
- ,"config_abs_fpath"
- ,"config_rel_fpath"
- ,"read_dir_dpath"
- ,"read_fname"
- ,"process_user"
- ,"cwd_dpath"
- )
-
- def __init__(self ,* ,stage_root: Path ,config_path: Path):
- self.stage_root_dpath = stage_root.resolve()
- self.config_abs_fpath = config_path.resolve()
- try:
- self.config_rel_fpath = self.config_abs_fpath.relative_to(self.stage_root_dpath)
- except Exception:
- self.config_rel_fpath = Path(self.config_abs_fpath.name)
-
- self.read_dir_dpath = self.config_abs_fpath.parent
-
- name = self.config_abs_fpath.name
- if name.endswith(".stage.py"):
- self.read_fname = name[:-len(".stage.py")]
- elif name.endswith(".py"):
- self.read_fname = name[:-3]
- else:
- self.read_fname = name
-
- self.process_user = getpass.getuser()
- self.cwd_dpath = Path.cwd().resolve()
-
- def print(self ,* ,file=None)-> None:
- if file is None:
- import sys as _sys
- file = _sys.stdout
- print(f"Stage root: {self.stage_root_dpath}" ,file=file)
- print(f"Config (rel): {self.config_rel_fpath.as_posix()}" ,file=file)
- print(f"Config (abs): {self.config_abs_fpath}" ,file=file)
- print(f"Read dir: {self.read_dir_dpath}" ,file=file)
- print(f"Read fname: {self.read_fname}" ,file=file)
- print(f"Process user: {self.process_user}" ,file=file)
-
-# ===== Admin-facing defaults carrier =====
-
-class WriteFileMeta:
- """
- WriteFileMeta — per-call or planner-default write-file attributes.
-
- Given dpath (str/Path, may be relative) ,fname (bare name or None) ,owner (str)
- ,mode (int|'0644') ,content (bytes|str|None).
- Does normalize into fields (may remain None if absent/invalid).
- Returns object suitable for providing defaults to Planner methods.
- """
- __slots__ = (
- "dpath_str"
- ,"fname"
- ,"owner_name_str"
- ,"mode_int"
- ,"mode_octal_str"
- ,"content_bytes"
- )
-
- def __init__(self
- ,*
- ,dpath="/"
- ,fname=None
- ,owner="root"
- ,mode=0o444
- ,content=None
- ):
- self.dpath_str = norm_dpath_str(dpath)
- self.fname = norm_fname_or_none(fname)
- self.owner_name_str = norm_nonempty_owner(owner)
- self.mode_int ,self.mode_octal_str = parse_mode(mode)
- self.content_bytes = norm_content_bytes(content)
-
- def print(self ,* ,label: str|None=None ,file=None)-> None:
- if file is None:
- import sys as _sys
- file = _sys.stdout
- dpath = self.dpath_str or "?"
- fname = self.fname or "?"
- owner = self.owner_name_str or "?"
- mode_str = f"{self.mode_int:04o}" if isinstance(self.mode_int ,int) else (self.mode_octal_str or "?")
- size = len(self.content_bytes) if isinstance(self.content_bytes ,(bytes ,bytearray)) else 0
- prefix = (label + ": ") if label else ""
- print(f"{prefix}dpath={dpath} fname={fname} owner={owner} mode={mode_str} bytes={size}" ,file=file)
-
-# ===== Planner =====
-
-class Planner:
- """
- Planner — constructs a Journal of Commands from config scripts.
-
- Given provenance (PlanProvenance) and optional default WriteFileMeta.
- Does resolve command parameters by precedence: kwarg > per-call WriteFileMeta > planner default,
- with a final filename fallback to provenance basename if still missing.
- On any argument error, returns the Command with errors and DOES NOT append it to Journal.
- Returns live Journal via journal().
- """
- __slots__ = (
- "_prov"
- ,"_defaults"
- ,"_journal"
- )
-
- def __init__(self ,provenance: PlanProvenance ,defaults: WriteFileMeta|None=None)-> None:
- self._prov = provenance
- self._defaults = defaults if defaults is not None else WriteFileMeta(
- dpath="/"
- ,fname=provenance.read_fname
- ,owner="root"
- ,mode=0o444
- ,content=None
- )
- self._journal = Journal()
- self._journal.set_meta(
- stage_root_dpath_str=str(self._prov.stage_root_dpath)
- ,config_rel_fpath_str=self._prov.config_rel_fpath.as_posix()
- )
-
- # --- provenance/defaults/journal access ---
-
- def set_provenance(self ,prov: PlanProvenance)-> None:
- self._prov = prov
-
- def set_defaults(self ,defaults: WriteFileMeta)-> None:
- self._defaults = defaults
-
- def defaults(self)-> WriteFileMeta:
- return self._defaults
-
- def journal(self)-> Journal:
- return self._journal
-
- # --- resolution helpers ---
-
- def _pick(self ,kw ,meta_attr ,default_attr):
- "Pick first non-None among kw ,meta_attr ,default_attr."
- return kw if kw is not None else (meta_attr if meta_attr is not None else default_attr)
-
- def _resolve_write_file(self ,wfm ,dpath ,fname)-> tuple[str|None ,str|None]:
- # normalize explicit kwargs
- dpath_str = norm_dpath_str(dpath) if dpath is not None else None
- fname_str = norm_fname_or_none(fname) if fname is not None else None
-
- # precedence: kwarg > per-call meta > planner default
- dpath_val = self._pick(dpath_str ,(wfm.dpath_str if wfm else None) ,self._defaults.dpath_str)
- fname_val = self._pick(fname_str ,(wfm.fname if wfm else None) ,self._defaults.fname)
-
- # final fallback for filename: derive from config name
- if fname_val is None:
- fname_val = self._prov.read_fname
-
- # anchor/normalize dpath
- if dpath_val is not None:
- p = Path(dpath_val)
- if not p.is_absolute():
- p = (self._prov.cwd_dpath/p)
- dpath_val = p.resolve().as_posix()
-
- return dpath_val ,fname_val
-
- def _resolve_owner_mode_content(self
- ,wfm: WriteFileMeta|None
- ,owner: str|None
- ,mode: int|str|None
- ,content: bytes|str|None
- )-> tuple[str|None ,tuple[int|None ,str|None] ,bytes|None]:
- owner_norm = norm_nonempty_owner(owner) if owner is not None else None
- mode_norm = parse_mode(mode) if mode is not None else (None ,None)
- content_b = norm_content_bytes(content) if content is not None else None
-
- owner_v = self._pick(owner_norm ,(wfm.owner_name_str if wfm else None) ,self._defaults.owner_name_str)
- mode_v = (mode_norm if mode_norm != (None ,None) else
- ((wfm.mode_int ,wfm.mode_octal_str) if wfm else (self._defaults.mode_int ,self._defaults.mode_octal_str)))
- content_v = self._pick(content_b ,(wfm.content_bytes if wfm else None) ,self._defaults.content_bytes)
- return owner_v ,mode_v ,content_v
-
- # --- printing ---
-
- def print(self ,* ,show_journal: bool=True ,file=None)-> None:
- if file is None:
- import sys as _sys
- file = _sys.stdout
-
- print("== Provenance ==" ,file=file)
- self._prov.print(file=file)
-
- print("\n== Defaults ==" ,file=file)
- self._defaults.print(label="defaults" ,file=file)
-
- if show_journal:
- entries = getattr(self._journal ,"command_list" ,[])
- n_total = len(entries)
- n_copy = sum(1 for c in entries if getattr(c ,"name_str" ,None) == "copy")
- n_disp = sum(1 for c in entries if getattr(c ,"name_str" ,None) == "displace")
- n_del = sum(1 for c in entries if getattr(c ,"name_str" ,None) == "delete")
-
- print("\n== Journal ==" ,file=file)
- print(f"entries: {n_total} copy:{n_copy} displace:{n_disp} delete:{n_del}" ,file=file)
- if n_total:
- self._journal.print(index_start=1 ,file=file)
- else:
- print("(plan is empty)" ,file=file)
-
- # --- Command builders (first arg may be WriteFileMeta) ---
-
- def copy(self
- ,wfm: WriteFileMeta|None=None
- ,*
- ,write_file_dpath: str|Path|None=None
- ,write_file_fname: str|None=None
- ,owner: str|None=None
- ,mode: int|str|None=None
- ,content: bytes|str|None=None
- )-> Command:
- cmd = Command("copy")
- dpath ,fname = self._resolve_write_file(wfm ,write_file_dpath ,write_file_fname)
- owner_v ,(mode_int ,mode_oct) ,content_b = self._resolve_owner_mode_content(wfm ,owner ,mode ,content)
-
- # well-formed checks
- if not is_abs_dpath(dpath): cmd.add_error("write_file_dpath must be absolute")
- if norm_fname_or_none(fname) is None: cmd.add_error("write_file_fname must be a bare filename")
- if not owner_v: cmd.add_error("owner must be non-empty")
- if (mode_int ,mode_oct) == (None ,None):
- cmd.add_error("mode must be int <= 0o7777 or 3/4-digit octal string")
- if content_b is None:
- cmd.add_error("content is required for copy() (bytes or str)")
-
- cmd.arg_dict.update({
- "write_file_dpath_str": dpath
- ,"write_file_fname": fname
- ,"owner_name": owner_v
- ,"mode_int": mode_int
- ,"mode_octal_str": mode_oct
- ,"content_bytes": content_b
- ,"provenance_config_rel_fpath_str": self._prov.config_rel_fpath.as_posix()
- })
-
- if not cmd.errors_list:
- self._journal.append(cmd)
- return cmd
-
- def displace(self
- ,wfm: WriteFileMeta|None=None
- ,*
- ,write_file_dpath: str|Path|None=None
- ,write_file_fname: str|None=None
- )-> Command:
- cmd = Command("displace")
- dpath ,fname = self._resolve_write_file(wfm ,write_file_dpath ,write_file_fname)
- if not is_abs_dpath(dpath): cmd.add_error("write_file_dpath must be absolute")
- if norm_fname_or_none(fname) is None: cmd.add_error("write_file_fname must be a bare filename")
- cmd.arg_dict.update({
- "write_file_dpath_str": dpath
- ,"write_file_fname": fname
- })
- if not cmd.errors_list:
- self._journal.append(cmd)
- return cmd
-
- def delete(self
- ,wfm: WriteFileMeta|None=None
- ,*
- ,write_file_dpath: str|Path|None=None
- ,write_file_fname: str|None=None
- )-> Command:
- cmd = Command("delete")
- dpath ,fname = self._resolve_write_file(wfm ,write_file_dpath ,write_file_fname)
- if not is_abs_dpath(dpath): cmd.add_error("write_file_dpath must be absolute")
- if norm_fname_or_none(fname) is None: cmd.add_error("write_file_fname must be a bare filename")
- cmd.arg_dict.update({
- "write_file_dpath_str": dpath
- ,"write_file_fname": fname
- })
- if not cmd.errors_list:
- self._journal.append(cmd)
- return cmd
--- /dev/null
+{
+ "caller": [],
+ "principal_caller": [],
+ "file": []
+}
+++ /dev/null
-#!/usr/bin/env bash
-set -euo pipefail
-
-# --- tiny helpers -------------------------------------------------------------
-say(){ printf "%s\n" "$*"; }
-have(){ command -v "$1" >/dev/null 2>&1; }
-
-longest_mount_covering() {
- # Arg: absolute path
- # Output: "MOUNTPOINT FSTYPE OPTIONS" (best effort)
- local target="$1"
- # Parse /proc/self/mountinfo to find the longest mountpoint prefix
- # Fields: ... 5:mountpoint 6:opts ... " - " 9:fstype ...
- awk -v P="$target" '
- BEGIN{best=""; bestlen=-1}
- {
- # split into pre/post " - "
- line=$0
- split(line,parts," - ")
- pre=parts[1]; post=parts[2];
- n=split(pre,a," ")
- mp=a[5]; opts=a[6];
- nf=split(post,b," ")
- fstype=b[1];
-
- # mountpoints in mountinfo use escape sequences for spaces; safe for our use
- if (index(P, mp)==1) {
- l=length(mp)
- if (l>bestlen) { best=mp; bestlen=l; BO=opts; FT=fstype; }
- }
- }
- END{
- if (bestlen>=0) { print best " " FT " " BO; }
- }' /proc/self/mountinfo
-}
-
-# --- locate repo bits ---------------------------------------------------------
-script_afp="$(realpath "${BASH_SOURCE[0]}")"
-RELEASE_DIR="$(cd "$(dirname "$script_afp")/.." && pwd -P)"
-WRAP="$RELEASE_DIR/shell/Man_In_Grey"
-
-# Compute arch dir like the rest of the tools
-arch_raw="$(uname -m | tr '[:upper:]' '[:lower:]')"
-case "$arch_raw" in
- amd64|x64) arch="x86_64" ;;
- x86_64) arch="x86_64" ;;
- i386|i486|i586|i686) arch="i686" ;;
- arm64|aarch64) arch="aarch64" ;;
- armv7l) arch="armv7l" ;;
- armv6l) arch="armv6l" ;;
- riscv64) arch="riscv64" ;;
- ppc64le|powerpc64le) arch="ppc64le" ;;
- s390x) arch="s390x" ;;
- *) arch="$arch_raw" ;;
-esac
-GASKET="$RELEASE_DIR/$arch/man_in_grey_apply"
-
-# --- who/where ----------------------------------------------------------------
-u="$(id -un)"; uid="$(id -u)"
-groups="$(id -nG 2>/dev/null || true)"
-pwd="$(pwd -P)"
-say "== who/where =="
-say "user: $u (uid=$uid) groups: ${groups:-unknown}"
-say "pwd : $pwd"
-say
-say "== paths =="
-say "wrapper: $WRAP"
-say "gasket : $GASKET"
-say
-
-# --- wrapper sanity ------------------------------------------------------------
-say "== wrapper sanity =="
-if [[ -f "$WRAP" && -x "$WRAP" ]]; then
- read -r firstline <"$WRAP" || true
- say "shebang: ${firstline:-<empty>}"
- say "exec path: $WRAP"
-else
- say "!! wrapper missing/non-exec"
-fi
-say
-
-# --- gasket file sanity & mount flags -----------------------------------------
-say "== gasket file sanity =="
-if [[ -e "$GASKET" ]]; then
- ls -l "$GASKET"
- if [[ -u "$GASKET" ]]; then
- say "setuid: YES"
- else
- say "setuid: NO"
- fi
-
- # try to find findmnt robustly
- FINDMNT_BIN=""
- for c in "${FINDMNT_BIN:-}" "$(command -v findmnt 2>/dev/null || true)" /usr/bin/findmnt /bin/findmnt /usr/sbin/findmnt; do
- [[ -n "$c" && -x "$c" ]] && { FINDMNT_BIN="$c"; break; }
- done
-
- gasket_abs="$(realpath "$GASKET")"
- if [[ -n "$FINDMNT_BIN" ]]; then
- # prefer findmnt -T if supported
- if "$FINDMNT_BIN" -T "$gasket_abs" >/dev/null 2>&1; then
- out="$("$FINDMNT_BIN" -no TARGET,FSTYPE,OPTIONS -T "$gasket_abs" || true)"
- else
- # fallback: search by path in the tree
- out="$("$FINDMNT_BIN" -no TARGET,FSTYPE,OPTIONS "$gasket_abs" 2>/dev/null || true)"
- fi
- if [[ -n "${out:-}" ]]; then
- say "mount: $out"
- mp="$(awk '{print $1}' <<<"$out")"
- fstype="$(awk '{print $2}' <<<"$out")"
- opts="$(awk '{$1="";$2=""; sub(/^ */,""); print}' <<<"$out")"
- else
- say "mount: findmnt returned no match, falling back to /proc/self/mountinfo"
- fi
- else
- say "mount: findmnt not found, falling back to /proc/self/mountinfo"
- fi
-
- if [[ -z "${opts:-}" ]]; then
- parsed="$(longest_mount_covering "$gasket_abs" || true)"
- if [[ -n "$parsed" ]]; then
- mp="$(awk '{print $1}' <<<"$parsed")"
- fstype="$(awk '{print $2}' <<<"$parsed")"
- opts="$(awk '{$1="";$2=""; sub(/^ */,""); print}' <<<"$parsed")"
- say "mount: $mp $fstype $opts (from /proc/self/mountinfo)"
- else
- say "mount: could not determine mount options (even via mountinfo)"
- fi
- fi
-
- if [[ -n "${opts:-}" ]]; then
- if grep -qw nosuid <<<"$opts"; then
- say "‼ detected nosuid on mount → setuid will NOT take effect here"
- else
- say "✓ nosuid NOT present on mount"
- fi
- if ! grep -qw exec <<<"$opts"; then
- say "‼ mount appears noexec (or exec not set) → binary execution may fail"
- fi
- fi
-else
- say "!! gasket not found"
-fi
-say
-
-# --- gasket self-report --------------------------------------------------------
-say "== gasket self-report (--print-flags) =="
-if [[ -x "$GASKET" ]]; then
- "$GASKET" --print-flags || true
-else
- say "skip (no gasket)"
-fi
-say
-
-# --- python inner EUID test ----------------------------------------------------
-say "== python inner EUID test =="
-if [[ -x "$GASKET" ]]; then
- tmp_py="$(mktemp /tmp/mig_ids_XXXXXX.py)"
- cat >"$tmp_py" <<'PY'
-import os, pwd
-uid = os.getuid()
-euid = os.geteuid()
-name = pwd.getpwuid(uid).pw_name
-print(f"py.real_uid={uid} ({name})")
-print(f"py.effective_uid={euid}")
-print("py.groups=", sorted(os.getgroups()))
-PY
- say "running: $GASKET --inner $tmp_py"
- "$GASKET" --inner "$tmp_py" || true
- rm -f "$tmp_py"
-else
- say "skip (no gasket)"
-fi
-say
-
-# --- wrapper execution trace (dry) --------------------------------------------
-say "== wrapper execution trace (dry) =="
-if [[ -x "$WRAP" ]]; then
- set +e
- bash -x "$WRAP" --phase-2-sanity2-then-stop --stage dummy >/dev/null 2>&1 \
- && say "+ bash -x \"$WRAP\" --phase-2-sanity2-then-stop --stage dummy (trace only)" \
- || say "+ bash -x \"$WRAP\" --phase-2-sanity2-then-stop --stage dummy (trace only)"
- set -e
-else
- say "skip (no wrapper)"
-fi
-say
-
-# --- verdict -------------------------------------------------------------------
-say "== verdict (rules of thumb) =="
-say "- If --print-flags shows flag.this_process_privileged=0: setuid isn’t taking effect (check nosuid mount, bit not set, or wrong binary)."
-say "- If that flag is 1 and the Python test shows euid=0: privileges flow is OK; if you still get EPERM to /etc, the wrapper may bypass the gasket sometimes."
-say "- If the wrapper trace never execs the gasket: replace the wrapper so it always runs the gasket."
-say "- If root is refused for “not in sudo”, update gasket policy to allow UID 0 (you have that patch)."
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-"""
-executor_inner.py — Man_In_Gray phase-2 inner executor
-
-- Reads a CBOR plan file (--plan)
-- Decodes to Journal (via Planner.py model)
-- Optional checkpoints:
- wellformed → sanity-1 → validity → sanity-2 → execute
-- Default behavior (no stop flags): apply the journal
-"""
-
-from __future__ import annotations
-
-# no bytecode anywhere
-import sys ,os
-sys.dont_write_bytecode = True
-os.environ.setdefault("PYTHONDONTWRITEBYTECODE" ,"1")
-
-from pathlib import Path
-import argparse
-import pwd
-import stat as _stat
-
-# Journal model comes from the same directory's Planner.py
-from Planner import (
- Journal,
-)
-
-# -- helpers --
-
-def _realpath(p: str|Path)-> Path:
- "Resolve as much as possible without requiring target leaf to exist."
- return Path(os.path.realpath(str(p)))
-
-def _is_under(child: Path ,root: Path)-> bool:
- "True if child is the same as or within root (after realpath)."
- try:
- child_r = _realpath(child)
- root_r = _realpath(root)
- # Python <3.9 compat for is_relative_to:
- child_parts = child_r.as_posix().rstrip("/") + "/"
- root_parts = root_r.as_posix().rstrip("/") + "/"
- return child_parts.startswith(root_parts)
- except Exception:
- return False
-
-# --- CBOR load ---
-
-def _journal_from_cbor_bytes(data: bytes)-> Journal:
- try:
- import cbor2
- except Exception as e:
- raise RuntimeError(f"cbor2 is required: {e}")
- obj = cbor2.loads(data)
- if not isinstance(obj ,dict):
- raise ValueError("CBOR root must be a dict")
- return Journal(plan_dict=obj)
-
-# --- pretty helpers ---
-
-def _dst_from(ad: dict)-> str:
- d = ad.get("write_file_dpath_str") or "?"
- f = ad.get("write_file_fname") or "?"
- try:
- from pathlib import Path as _P
- if isinstance(d ,str) and isinstance(f ,str) and "/" not in f:
- return (_P(d)/f).as_posix()
- except Exception:
- pass
- return f"{d}/{f}"
-
-def _mode_from_entry(ad: dict)-> int:
- m = ad.get("mode_int")
- if isinstance(m ,int): return m
- s = ad.get("mode_octal_str")
- if isinstance(s ,str):
- try:
- return int(s ,8)
- except Exception:
- pass
- raise ValueError("invalid mode")
-
-# --- Phase: wellformed (schema/shape) ---
-
-def check_wellformed(journal: Journal)-> list[str]:
- errs: list[str] = []
- for i ,cmd in enumerate(journal.command_list ,start=1):
- op = getattr(cmd ,"name_str" ,None)
- ad = getattr(cmd ,"arg_dict" ,None)
- if op not in {"copy" ,"displace" ,"delete"}:
- errs.append(f"[{i}] unknown op: {op!r}")
- continue
- if not isinstance(ad ,dict):
- errs.append(f"[{i}] arg_dict missing")
- continue
- d = ad.get("write_file_dpath_str")
- f = ad.get("write_file_fname")
- if not (isinstance(d ,str) and d.startswith("/")):
- errs.append(f"[{i}] write_file_dpath_str must be absolute: {d!r}")
- if not (isinstance(f ,str) and "/" not in f and f not in {"." ,""}):
- errs.append(f"[{i}] write_file_fname must be a bare filename: {f!r}")
- if op == "copy":
- if "owner_name" not in ad:
- errs.append(f"[{i}] copy: owner_name missing")
- if "content_bytes" not in ad:
- errs.append(f"[{i}] copy: content_bytes missing")
- if "mode_int" not in ad and "mode_octal_str" not in ad:
- errs.append(f"[{i}] copy: mode missing")
- return errs
-
-# --- Phase: sanity-1 (cheap static sanity) ---
-
-def check_sanity_1(journal: Journal ,allowed_roots: list[Path])-> list[str]:
- """
- Scope fence: every destination directory must be under at least one allowed root.
- Default allowed roots = [/etc, cwd_of_inner].
- """
- errs: list[str] = []
- allowed_str = ", ".join(r.as_posix() for r in allowed_roots)
- for i ,cmd in enumerate(journal.command_list ,start=1):
- ad = cmd.arg_dict
- d = ad.get("write_file_dpath_str")
- if not isinstance(d ,str):
- # wellformed will report it; skip here
- continue
- d_real = _realpath(d)
- if not any(_is_under(d_real ,root) for root in allowed_roots):
- errs.append(f"[{i}] dst dir outside allowed roots: {d_real.as_posix()} (allowed: {allowed_str})")
- return errs
-
-# --- Phase: validity (system lookups) ---
-
-def check_validity(journal: Journal)-> list[str]:
- errs: list[str] = []
- for i ,cmd in enumerate(journal.command_list ,start=1):
- ad = cmd.arg_dict
- if cmd.name_str == "copy":
- owner = ad.get("owner_name")
- try:
- pwd.getpwnam(owner)
- except Exception:
- errs.append(f"[{i}] unknown owner_name: {owner!r} (dst={_dst_from(ad)})")
- try:
- _ = _mode_from_entry(ad)
- except Exception as e:
- errs.append(f"[{i}] bad mode: {e} (dst={_dst_from(ad)})")
- cb = ad.get("content_bytes")
- if not isinstance(cb ,(bytes ,bytearray)):
- errs.append(f"[{i}] content_bytes not bytes-like (dst={_dst_from(ad)})")
- return errs
-
-# --- Phase: sanity-2 (filesystem checks, no mutation) ---
-
-def _safe_open_dir(dpath: str)-> int:
- fd = os.open(dpath ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
- st = os.fstat(fd)
- if not _stat.S_ISDIR(st.st_mode):
- os.close(fd) ; raise OSError("not a directory")
- return fd
-
-
-
-
-def check_sanity_2(journal: Journal)-> list[str]:
- errs: list[str] = []
- opened: dict[str ,int] = {}
- try:
- # ensure destination directories are openable (and not symlinked dirs)
- for i ,cmd in enumerate(journal.command_list ,start=1):
- d = cmd.arg_dict.get("write_file_dpath_str")
- if not isinstance(d ,str): # already flagged in wellformed
- continue
- if d in opened:
- continue
- try:
- opened[d] = _safe_open_dir(d)
- except Exception as e:
- errs.append(f"[{i}] cannot open destination dir: {d} ({e})")
-
- # detect multiple writes to same target without a reset (displace/delete)
- last_action: dict[tuple[str, str], str] = {}
- for i, cmd in enumerate(journal.command_list, start=1):
- ad = cmd.arg_dict
- key = (ad.get("write_file_dpath_str"), ad.get("write_file_fname"))
- op = cmd.name_str
- if op == "copy":
- if last_action.get(key) == "copy":
- errs.append(f"[{i}] multiple writes to same target without prior displace/delete: {_dst_from(ad)}")
- last_action[key] = "copy"
- elif op in {"displace", "delete"}:
- last_action[key] = op
-
- finally:
- for fd in opened.values():
- try:
- os.close(fd)
- except Exception:
- pass
- return errs
-
-# --- Execute (mutation) ---
-
-def _fsync_dirfd(dirfd: int)-> None:
- try:
- os.fsync(dirfd)
- except Exception:
- pass
-
-def _exists_regular_nosymlink_at(dirfd: int ,fname: str)-> bool:
- try:
- st = os.lstat(fname ,dir_fd=dirfd)
- except FileNotFoundError:
- return False
- if _stat.S_ISLNK(st.st_mode): raise OSError("target is a symlink")
- if not _stat.S_ISREG(st.st_mode): raise OSError("target not a regular file")
- return True
-
-def _apply_displace(d: str ,f: str)-> None:
- dirfd = _safe_open_dir(d)
- try:
- if not _exists_regular_nosymlink_at(dirfd ,f):
- return
- import time as _time
- ts = _time.strftime("%Y%m%dT%H%M%SZ" ,_time.gmtime())
- bak = f"{f}.{ts}"
- os.rename(f ,bak ,src_dir_fd=dirfd ,dst_dir_fd=dirfd)
- _fsync_dirfd(dirfd)
- finally:
- os.close(dirfd)
-
-def _apply_copy(d: str ,f: str ,owner: str ,mode_int: int ,content: bytes)-> None:
- pw = pwd.getpwnam(owner)
- uid ,gid = pw.pw_uid ,pw.pw_gid
- dirfd = _safe_open_dir(d)
- try:
- tmp = f".{f}.mig.tmp.{os.getpid()}"
- tfd = os.open(tmp ,os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW ,0o600 ,dir_fd=dirfd)
- try:
- mv = memoryview(content)
- off = 0
- while off < len(mv):
- n = os.write(tfd ,mv[off:])
- if n <= 0: raise OSError("short write")
- off += n
- os.fsync(tfd)
- os.fchown(tfd ,uid ,gid)
- os.fchmod(tfd ,mode_int)
- os.fsync(tfd)
- finally:
- os.close(tfd)
- os.rename(tmp ,f ,src_dir_fd=dirfd ,dst_dir_fd=dirfd)
- _fsync_dirfd(dirfd)
- finally:
- os.close(dirfd)
-
-def _apply_delete(d: str ,f: str)-> None:
- dirfd = _safe_open_dir(d)
- try:
- if not _exists_regular_nosymlink_at(dirfd ,f):
- return
- os.unlink(f ,dir_fd=dirfd)
- _fsync_dirfd(dirfd)
- finally:
- os.close(dirfd)
-
-def apply_journal(journal: Journal)-> int:
- errs = 0
- for idx ,entry in enumerate(journal.command_list ,start=1):
- op = getattr(entry ,"name_str" ,"?")
- ad = getattr(entry ,"arg_dict" ,{}) or {}
- try:
- d = ad["write_file_dpath_str"]
- f = ad["write_file_fname"]
- if not (isinstance(d ,str) and d.startswith("/") and isinstance(f ,str) and "/" not in f):
- raise ValueError("bad path or filename")
- if op == "displace":
- _apply_displace(d ,f)
- elif op == "copy":
- owner = ad["owner_name"]
- mode = _mode_from_entry(ad)
- content = ad["content_bytes"]
- if not isinstance(content ,(bytes ,bytearray)): raise ValueError("content_bytes missing")
- _apply_copy(d ,f ,owner ,mode ,bytes(content))
- elif op == "delete":
- _apply_delete(d ,f)
- else:
- raise ValueError(f"unknown op: {op}")
- except Exception as e:
- errs += 1
- print(f"apply error [{idx} {op}] {_dst_from(ad)}: {e}" ,file=sys.stderr)
- return 0 if errs == 0 else 1
-
-# --- Orchestration ---
-
-def _phase_gate(name: str ,errors: list[str] ,then_stop: bool)-> bool:
- if errors:
- print(f"{name}: {len(errors)} issue(s)")
- for e in errors:
- print(f" ! {e}")
- return True
- if then_stop:
- print(f"{name}: OK")
- return True
- return False
-
-def executor_inner(
- journal: Journal
- ,*
- ,phase_2_print: bool=False
- ,phase_2_then_stop: bool=False
- ,phase_2_wellformed_then_stop: bool=False
- ,phase_2_sanity1_then_stop: bool=False
- ,phase_2_validity_then_stop: bool=False
- ,phase_2_sanity2_then_stop: bool=False
- ,allowed_roots: list[Path]|None=None
-)-> int:
- """
- Core pipeline for the inner executor. Returns a process-style exit code.
- """
- if phase_2_print:
- journal.print()
- if phase_2_then_stop:
- return 0
-
- roots = allowed_roots or [Path("/etc").resolve() ,Path.cwd().resolve()]
-
- wf = check_wellformed(journal)
- if _phase_gate("wellformed" ,wf ,phase_2_wellformed_then_stop):
- return 1 if wf else 0 if phase_2_wellformed_then_stop else 0
-
- s1 = check_sanity_1(journal ,roots)
- if _phase_gate("sanity-1" ,s1 ,phase_2_sanity1_then_stop):
- return 1 if s1 else 0 if phase_2_sanity1_then_stop else 0
-
- v = check_validity(journal)
- if _phase_gate("validity" ,v ,phase_2_validity_then_stop):
- return 1 if v else 0 if phase_2_validity_then_stop else 0
-
- s2 = check_sanity_2(journal)
- if _phase_gate("sanity-2" ,s2 ,phase_2_sanity2_then_stop):
- return 1 if s2 else 0 if phase_2_sanity2_then_stop else 0
-
- return apply_journal(journal)
-
-# --- CLI wrapper ---
-
-# --- new worker --------------------------------------------------------------
-
-def run_executor_inner(
- *
- ,plan_bytes: bytes
- ,phase2_print: bool
- ,phase2_then_stop: bool
- ,phase2_wellformed_then_stop: bool
- ,phase2_sanity1_then_stop: bool
- ,phase2_validity_then_stop: bool
- ,phase2_sanity2_then_stop: bool
-)-> int:
- try:
- journal = _journal_from_cbor_bytes(plan_bytes)
- except Exception as e:
- print(f"error: failed to decode CBOR: {e}" ,file=sys.stderr)
- return 2
-
- if phase2_print:
- journal.print()
- if phase2_then_stop:
- return 0
-
- allowed_roots = [Path("/etc").resolve() ,Path.cwd().resolve()]
-
- wf = check_wellformed(journal)
- if _phase_gate("wellformed" ,wf ,phase2_wellformed_then_stop): return 1 if wf else 0 if phase2_wellformed_then_stop else 0
-
- s1 = check_sanity_1(journal ,allowed_roots)
- if _phase_gate("sanity-1" ,s1 ,phase2_sanity1_then_stop): return 1 if s1 else 0 if phase2_sanity1_then_stop else 0
-
- v = check_validity(journal)
- if _phase_gate("validity" ,v ,phase2_validity_then_stop): return 1 if v else 0 if phase2_validity_then_stop else 0
-
- s2 = check_sanity_2(journal)
- if _phase_gate("sanity-2" ,s2 ,phase2_sanity2_then_stop): return 1 if s2 else 0 if phase2_sanity2_then_stop else 0
-
- return apply_journal(journal)
-
-# --- main stays a thin arg wrapper ------------------------------------------
-
-# --- plan input helpers -------------------------------------------------------
-
-def _read_fd_all(fd: int) -> bytes:
- "Read all bytes from an already-open file descriptor without closing it."
- chunks: list[bytes] = []
- while True:
- try:
- b = os.read(fd, 65536)
- except InterruptedError:
- continue
- if not b:
- break
- chunks.append(b)
- return b"".join(chunks)
-
-def _read_plan_bytes_from_args(args) -> bytes:
- """
- Input priority:
- 1) --plan-fd <n> (gasket path; do not close fd)
- 2) --plan - (stdin)
- 3) --plan <file> (read from filesystem)
- """
- if getattr(args, "plan_fd", -1) is not None and args.plan_fd >= 0:
- return _read_fd_all(args.plan_fd)
- if args.plan in ("", "-"):
- return sys.stdin.buffer.read()
- return Path(args.plan).read_bytes()
-
-def main(argv: list[str] | None = None) -> int:
- ap = argparse.ArgumentParser(
- prog="executor_inner.py",
- description="Man_In_Grey inner executor (decode → validate → apply)"
- )
-
- # Single --plan plus a hidden --plan-fd used by the gasket
- ap.add_argument(
- "--plan",
- default="-",
- help="path to CBOR plan file or '-' for stdin"
- )
- ap.add_argument(
- "--plan-fd",
- type=int,
- default=-1,
- help=argparse.SUPPRESS
- )
-
- # phase-2 gates (same semantics as before)
- ap.add_argument("--phase-2-print", action="store_true", help="print decoded journal")
- ap.add_argument("--phase-2-then-stop", action="store_true", help="stop after print (no apply)")
- ap.add_argument("--phase-2-wellformed-then-stop", action="store_true", help="stop after wellformed checks")
- ap.add_argument("--phase-2-sanity1-then-stop", action="store_true", help="stop after sanity-1 checks")
- ap.add_argument("--phase-2-validity-then-stop", action="store_true", help="stop after validity checks")
- ap.add_argument("--phase-2-sanity2-then-stop", action="store_true", help="stop after sanity-2 checks")
-
- args = ap.parse_args(argv)
-
- # Read plan bytes from fd/stdin/file
- try:
- data = _read_plan_bytes_from_args(args)
- except Exception as e:
- print(f"error: failed to read plan: {e}", file=sys.stderr)
- return 2
-
- # Decode CBOR → Journal
- try:
- journal = _journal_from_cbor_bytes(data)
- except Exception as e:
- print(f"error: failed to decode CBOR: {e}", file=sys.stderr)
- return 2
-
- # Run the pipeline
- return executor_inner(
- journal,
- phase_2_print=args.phase_2_print,
- phase_2_then_stop=args.phase_2_then_stop,
- phase_2_wellformed_then_stop=args.phase_2_wellformed_then_stop,
- phase_2_sanity1_then_stop=args.phase_2_sanity1_then_stop,
- phase_2_validity_then_stop=args.phase_2_validity_then_stop,
- phase_2_sanity2_then_stop=args.phase_2_sanity2_then_stop,
- )
-
-if __name__ == "__main__":
- sys.exit(main())
-
-
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-"""
-executor.py — StageHand outer/inner executor (MVP; UNPRIVILEGED for now)
-
-Phase 0 (bootstrap):
- - Ensure filter program exists (create default in CWD if --filter omitted)
- - Validate --stage exists
- - If --phase-0-then-stop: exit here (no scan, no execution)
-
-Phase 1 (outer):
- - Discover every file under --stage; acceptance filter decides which to include
- - Execute each config’s configure(prov ,planner ,WriteFileMeta) into ONE Planner
- - Optionally print the planner; optionally stop
-
-Phase 2 (inner shim in same program for now; no privilege yet):
- - Encode plan to CBOR and hand to inner path
- - Inner decodes to a Journal and can print it
-"""
-
-from __future__ import annotations
-
-# no bytecode anywhere
-import sys ,os
-sys.dont_write_bytecode = True
-os.environ.setdefault("PYTHONDONTWRITEBYTECODE" ,"1")
-
-from pathlib import Path
-import argparse
-import getpass
-import tempfile
-import runpy
-import subprocess
-import datetime as _dt
-import stat
-
-# Local module: Planner.py (same directory)
-from Planner import (
- Planner
- ,PlanProvenance
- ,WriteFileMeta
- ,Journal
- ,Command
-)
-
-# -------- default filter template (written to CWD when --input_acceptance not provided) --------
-
-DEFAULT_FILTER_FILENAME = "Man_In_Gray_input_acceptance.py"
-
-DEFAULT_FILTER_SOURCE = """# Man_In_Gray_input_acceptance (default template)
-# Return True to include a config file, False to skip it.
-# You receive a PlanProvenance object named `prov`.
-#
-# prov fields commonly used here:
-# prov.stage_root_dpath : Path → absolute path to the stage root
-# prov.config_abs_fpath : Path → absolute path to the candidate file
-# prov.config_rel_fpath : Path → path relative to the stage root
-# prov.read_dir_dpath : Path → directory of the candidate file
-# prov.read_fname : str → filename with trailing '.py' stripped (if present)
-#
-# Examples:
-#
-# 1) Accept everything (default behavior):
-# def accept(prov):
-# return True
-#
-# 2) Only accept configs in a 'dns/' namespace under the stage:
-# def accept(prov):
-# return prov.config_rel_fpath.as_posix().startswith("dns/")
-#
-# 3) Exclude editor backup files:
-# def accept(prov):
-# rel = prov.config_rel_fpath.as_posix()
-# return not (rel.endswith("~") or rel.endswith(".swp"))
-#
-# 4) Only accept Python files + a few non-Python names:
-# def accept(prov):
-# name = prov.config_abs_fpath.name
-# return name.endswith(".py") or name in {"hosts" ,"resolv.conf"}
-#
-# Choose ONE 'accept' definition. Below is the default:
-
-def accept(prov):
- return True
-"""
-
-# -------- utilities --------
-
-def iso_utc_now_str()-> str:
- return _dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
-
-def _ensure_filter_file(filter_arg: str|None)-> Path:
- """
- If --input_acceptance is provided, return that path (must exist).
- Otherwise, create ./stagehand_filter.py in the CWD if missing (writing a helpful template),
- and return its path.
- """
- if filter_arg:
- p = Path(filter_arg)
- if not p.is_file():
- raise RuntimeError(f"--input_acceptance file not found: {p}")
- return p
-
- p = Path.cwd()/DEFAULT_FILTER_FILENAME
- if not p.exists():
- try:
- p.write_text(DEFAULT_FILTER_SOURCE ,encoding="utf-8")
- print(f"(created default filter at {p})")
- except Exception as e:
- raise RuntimeError(f"failed to create default filter {p}: {e}")
- return p
-
-def _load_accept_func(filter_path: Path):
- env = runpy.run_path(str(filter_path))
- fn = env.get("accept")
- if not callable(fn):
- raise RuntimeError(f"{filter_path}: missing callable 'accept(prov)'")
- return fn
-
-def _walk_all_files(stage_root: Path):
- """
- Yield every file (regular or symlink) under stage_root recursively.
- We do not follow symlinked directories to avoid cycles.
- """
- root = stage_root.resolve()
- for dirpath ,dirnames ,filenames in os.walk(root ,followlinks=False):
- # prune symlinked dirs (files can still be symlinks)
- dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(dirpath ,d))]
- for fname in filenames:
- p = Path(dirpath ,fname)
- try:
- st = p.lstat()
- if stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode):
- yield p.resolve()
- except Exception:
- # unreadable/broken entries skipped
- continue
-
-def find_config_paths(stage_root: Path ,accept_func)-> list[Path]:
- """
- Return files accepted by the Python acceptance function: accept(prov) → True/False.
- Ordered breadth-first by depth, then lexicographically by relative path.
- """
- out: list[tuple[int ,str ,Path]] = []
- root = stage_root.resolve()
- for p in _walk_all_files(stage_root):
- prov = PlanProvenance(stage_root=stage_root ,config_path=p)
- try:
- if accept_func(prov):
- rel = p.resolve().relative_to(root)
- out.append((len(rel.parts) ,rel.as_posix() ,p.resolve()))
- except Exception as e:
- raise RuntimeError(f"accept() failed on {prov.config_rel_fpath.as_posix()}: {e}")
- out.sort(key=lambda t: (t[0] ,t[1])) # (depth ,name)
- return [t[2] for t in out]
-
-# --- run all configs into ONE planner ---
-
-def _run_all_configs_into_single_planner(stage_root: Path ,cfgs: list[Path])-> Planner:
- """
- Create a single Planner and execute each config's configure(prov, planner, WriteFileMeta)
- against it. Returns that single Planner containing the entire plan.
- """
- # seed with synthetic provenance; we overwrite per config before execution
- aggregate_prov = PlanProvenance(stage_root=stage_root ,config_path=stage_root/"(aggregate).py")
- planner = Planner(provenance=aggregate_prov)
-
- for cfg in cfgs:
- prov = PlanProvenance(stage_root=stage_root ,config_path=cfg)
- planner.set_provenance(prov)
-
- env = runpy.run_path(str(cfg))
- fn = env.get("configure")
- if not callable(fn):
- raise RuntimeError(f"{cfg}: missing callable configure(prov ,planner ,WriteFileMeta)")
-
- fn(prov ,planner ,WriteFileMeta)
-
- # annotate meta once, on the single planner's journal
- j = planner.journal()
- j.set_meta(
- generator_prog_str="executor.py"
- ,generated_at_utc_str=iso_utc_now_str()
- ,user_name_str=getpass.getuser()
- ,host_name_str=os.uname().nodename if hasattr(os ,"uname") else "unknown"
- ,stage_root_dpath_str=str(stage_root.resolve())
- ,configs_list=[str(p.resolve().relative_to(stage_root.resolve())) for p in cfgs]
- )
- return planner
-
-# ----- CBOR “matchbox” (simple wrapper kept local to executor) -----
-
-def _plan_to_cbor_bytes(planner: Planner)-> bytes:
- "Serialize a Planner's Journal to CBOR bytes."
- try:
- import cbor2
- except Exception as e:
- raise RuntimeError(f"cbor2 is required: {e}")
- plan_dict = planner.journal().as_dictionary()
- return cbor2.dumps(plan_dict ,canonical=True)
-
-def _journal_from_cbor_bytes(data: bytes)-> Journal:
- "Rebuild a Journal from CBOR bytes."
- try:
- import cbor2
- except Exception as e:
- raise RuntimeError(f"cbor2 is required: {e}")
- obj = cbor2.loads(data)
- if not isinstance(obj ,dict):
- raise ValueError("CBOR root must be a dict")
- return Journal(plan_dict=obj)
-
-# -------- inner executor (phase 2) --------
-
-def _inner_main(plan_path: Path ,phase2_print: bool ,phase2_then_stop: bool)-> int:
- "Inner executor path: decode CBOR → Journal; optionally print; (apply TBD)."
- try:
- data = Path(plan_path).read_bytes()
- except Exception as e:
- print(f"error: failed to read plan file: {e}" ,file=sys.stderr)
- return 2
-
- try:
- journal = _journal_from_cbor_bytes(data)
- except Exception as e:
- print(f"error: failed to decode CBOR: {e}" ,file=sys.stderr)
- return 2
-
- if phase2_print:
- journal.print()
-
- if phase2_then_stop:
- return 0
-
- # (Stage 3 apply would go here; omitted in MVP)
- return 0
-
-# -------- outer executor (phase 1 & handoff) --------
-
-def _outer_main(stage_root: Path ,accept_func ,args)-> int:
- if not stage_root.is_dir():
- print(f"error: --stage not a directory: {stage_root}" ,file=sys.stderr)
- return 2
-
- cfgs = find_config_paths(stage_root ,accept_func)
- if not cfgs:
- print("No configuration files found.")
- return 0
-
- try:
- master = _run_all_configs_into_single_planner(stage_root ,cfgs)
- except SystemExit:
- raise
- except Exception as e:
- print(f"error: executing configs: {e}" ,file=sys.stderr)
- return 2
-
- if args.phase_1_print:
- master.print()
-
- if args.phase_1_then_stop:
- return 0
-
- # Phase 2: encode CBOR and invoke inner path (same script, --inner)
- try:
- cbor_bytes = _plan_to_cbor_bytes(master)
- except Exception as e:
- print(f"error: CBOR encode failed: {e}" ,file=sys.stderr)
- return 2
-
- with tempfile.NamedTemporaryFile(prefix="stagehand_plan_" ,suffix=".cbor" ,delete=False) as tf:
- tf.write(cbor_bytes)
- plan_path = tf.name
-
- try:
- cmd = [
- sys.executable
- ,str(Path(__file__).resolve())
- ,"--inner"
- ,"--plan" ,plan_path
- ]
- if args.phase_2_print:
- cmd.append("--phase-2-print")
- if args.phase_2_then_stop:
- cmd.append("--phase-2-then-stop")
-
- proc = subprocess.run(cmd)
- return proc.returncode
- finally:
- try:
- os.unlink(plan_path)
- except Exception:
- pass
-
-# -------- CLI --------
-
-def main(argv: list[str]|None=None)-> int:
- ap = argparse.ArgumentParser(
- prog="executor.py"
- ,description="StageHand outer/inner executor (plan → CBOR → decode)."
- )
- ap.add_argument("--stage" ,default="stage"
- ,help="stage root directory (default: ./stage)")
- ap.add_argument(
- "--input_acceptance"
- ,default=""
- ,help=f"path to acceptance filter program exporting accept(prov) "
- f"(default: ./{DEFAULT_FILTER_FILENAME}; created if missing)"
- )
- ap.add_argument(
- "--phase-0-then-stop"
- ,action="store_true"
- ,help="stop after arg checks & filter bootstrap (no stage scan)"
- )
-
- # Phase-1 (outer) controls
- ap.add_argument("--phase-1-print" ,action="store_true" ,help="print master planner (phase 1)")
- ap.add_argument("--phase-1-then-stop" ,action="store_true" ,help="stop after phase 1")
-
- # Phase-2 (inner) controls (outer forwards these to inner)
- ap.add_argument("--phase-2-print" ,action="store_true" ,help="print decoded journal (phase 2)")
- ap.add_argument("--phase-2-then-stop" ,action="store_true" ,help="stop after phase 2 decode")
-
- # Inner-only flags (not for users)
- ap.add_argument("--inner" ,action="store_true" ,help=argparse.SUPPRESS)
- ap.add_argument("--plan" ,default=None ,help=argparse.SUPPRESS)
-
- args = ap.parse_args(argv)
-
- # Inner path
- if args.inner:
- if not args.plan:
- print("error: --inner requires --plan <file>" ,file=sys.stderr)
- return 2
- return _inner_main(Path(args.plan)
- ,phase2_print=args.phase_2_print
- ,phase2_then_stop=args.phase_2_then_stop)
-
- # Phase 0: bootstrap & stop (no scan)
- stage_root = Path(args.stage)
- try:
- filter_path = _ensure_filter_file(args.input_acceptance or None)
- except Exception as e:
- print(f"error: {e}" ,file=sys.stderr)
- return 2
-
- if not stage_root.exists():
- print(f"error: --stage not found: {stage_root}" ,file=sys.stderr)
- return 2
- if not stage_root.is_dir():
- print(f"error: --stage is not a directory: {stage_root}" ,file=sys.stderr)
- return 2
-
- if args.phase_0_then_stop:
- print(f"phase-0 OK: stage at {stage_root.resolve()} and filter at {filter_path}")
- return 0
-
- # Load acceptance function and proceed with outer
- try:
- accept_func = _load_accept_func(filter_path)
- except Exception as e:
- print(f"error: {e}" ,file=sys.stderr)
- return 2
-
- return _outer_main(stage_root ,accept_func ,args)
-
-# inner executor
-# --- secure apply helpers (inner path) ---
-
-import pwd ,errno ,stat as _stat
-
-def _safe_open_dir(dpath: str)-> int:
- "Open directory without following symlinks; return dirfd."
- fd = os.open(dpath ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
- st = os.fstat(fd)
- if not _stat.S_ISDIR(st.st_mode):
- os.close(fd) ; raise OSError("not a directory")
- return fd
-
-def _exists_regular_nosymlink_at(dirfd: int ,fname: str)-> bool:
- "True if a regular ,non-symlink file exists at dirfd/fname."
- try:
- st = os.lstat(fname ,dir_fd=dirfd)
- except FileNotFoundError:
- return False
- if _stat.S_ISLNK(st.st_mode): raise OSError("target is a symlink")
- if not _stat.S_ISREG(st.st_mode): raise OSError("target not a regular file")
- return True
-
-def _fsync_dirfd(dirfd: int)-> None:
- try:
- os.fsync(dirfd)
- except Exception:
- pass # some FS may not support; best effort
-
-def _apply_displace(d: str ,f: str)-> None:
- dirfd = _safe_open_dir(d)
- try:
- if not _exists_regular_nosymlink_at(dirfd ,f):
- return
- import time as _time
- ts = _time.strftime("%Y%m%dT%H%M%SZ" ,_time.gmtime())
- bak = f"{f}.{ts}"
- os.rename(f ,bak ,src_dir_fd=dirfd ,dst_dir_fd=dirfd)
- _fsync_dirfd(dirfd)
- finally:
- os.close(dirfd)
-
-def _apply_copy(d: str ,f: str ,owner: str ,mode_int: int ,content: bytes)-> None:
- pw = pwd.getpwnam(owner)
- uid ,gid = pw.pw_uid ,pw.pw_gid
- dirfd = _safe_open_dir(d)
- try:
- tmp = f".{f}.mig.tmp.{os.getpid()}"
- tfd = os.open(tmp ,os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW ,0o600 ,dir_fd=dirfd)
- try:
- # write all bytes
- mv = memoryview(content)
- off = 0
- while off < len(mv):
- n = os.write(tfd ,mv[off:])
- if n <= 0: raise OSError("short write")
- off += n
- os.fsync(tfd)
- os.fchown(tfd ,uid ,gid)
- os.fchmod(tfd ,mode_int)
- os.fsync(tfd)
- finally:
- os.close(tfd)
- os.rename(tmp ,f ,src_dir_fd=dirfd ,dst_dir_fd=dirfd)
- _fsync_dirfd(dirfd)
- finally:
- os.close(dirfd)
-
-def _apply_delete(d: str ,f: str)-> None:
- dirfd = _safe_open_dir(d)
- try:
- if not _exists_regular_nosymlink_at(dirfd ,f):
- return
- os.unlink(f ,dir_fd=dirfd)
- _fsync_dirfd(dirfd)
- finally:
- os.close(dirfd)
-
-def _mode_from_entry(ad: dict)-> int:
- m = ad.get("mode_int")
- if isinstance(m ,int): return m
- s = ad.get("mode_octal_str")
- if isinstance(s ,str):
- try:
- return int(s ,8)
- except Exception:
- pass
- raise ValueError("invalid mode")
-
-def apply_journal(journal: Journal)-> int:
- """
- Apply the decoded journal. Returns 0 on success ,1 if any hard errors occurred.
- """
- errs = 0
- for idx ,entry in enumerate(journal.command_list ,start=1):
- op = getattr(entry ,"name_str" ,"?")
- ad = getattr(entry ,"arg_dict" ,{}) or {}
- try:
- d = ad["write_file_dpath_str"]
- f = ad["write_file_fname"]
- if not (isinstance(d ,str) and d.startswith("/") and isinstance(f ,str) and "/" not in f):
- raise ValueError("bad path or filename")
- if op == "displace":
- _apply_displace(d ,f)
- elif op == "copy":
- owner = ad["owner_name"]
- mode = _mode_from_entry(ad)
- content = ad["content_bytes"]
- if not isinstance(content ,(bytes ,bytearray)): raise ValueError("content_bytes missing")
- _apply_copy(d ,f ,owner ,mode ,bytes(content))
- elif op == "delete":
- _apply_delete(d ,f)
- else:
- raise ValueError(f"unknown op: {op}")
- except Exception as e:
- errs += 1
- print(f"apply error [{idx} {op}]: {e}" ,file=sys.stderr)
- return 0 if errs == 0 else 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+journal_interpreter - the general journal interpreter of Man-In-Grey.
+
+A bespoke journal interpreter is a gate, a guard and this general
+interpreter. The bespoke interpreter chooses the command language and the
+constraints, and calls interpret(). interpret() decodes the journal, runs the
+language's checks in order, the guard among them, and executes the journal
+only when every check finds nothing wrong. A check that finds problems lists
+them all, and the journal is refused whole.
+
+This module is a library; it is not run by itself.
+"""
+
+import sys
+
+import Journal
+
+# the checks a run can stop after, in the order they run
+STOP = ("decode" ,"wellformed" ,"constraint" ,"validity" ,"filesystem")
+
+
+def interpret(journal_bytes ,language ,constraint ,caller ,stop_after=None ,print_journal=False ,out=None):
+ """
+ Given the encoded journal, the command language module, the constraints,
+ and the caller's account name. Returns a process exit status: 0 when the
+ journal was executed, or when the run stopped as asked after a check that
+ passed; 1 when a check refused the journal or execution failed; 2 when the
+ journal could not be read.
+ """
+ out = out or sys.stdout
+ try:
+ data = Journal.decode(journal_bytes)
+ except Journal.JournalError as error:
+ print(f"error: {error}" ,file=out)
+ return 2
+ if data["command_language"] != language.NAME:
+ print(f"error: the journal is in the command language {data['command_language']!r};"
+ f" this interpreter accepts {language.NAME!r}" ,file=out)
+ return 1
+
+ if print_journal:
+ for index ,command in enumerate(data["command_list"]):
+ print(Journal.command_line(index ,command) ,file=out)
+ if stop_after == "decode":
+ return 0
+
+ for name ,check in language.CHECK:
+ problem_list = check(data ,constraint ,caller)
+ if problem_list:
+ print(f"{name}: refused, {len(problem_list)} problem{'s' if len(problem_list) != 1 else ''}" ,file=out)
+ for problem in problem_list:
+ print(f" ! {problem}" ,file=out)
+ return 1
+ if stop_after == name:
+ print(f"{name}: OK" ,file=out)
+ return 0
+
+ return language.execute(data ,lambda line: print(f" {line}" ,file=out))
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+journal_interpreter_file
+
+The bespoke journal interpreter for the file command language, started by
+its gate, Man_In_Grey_file_gate, with the journal on standard input.
+
+Its constraints are the file constraints_file.json beside it. When it runs
+privileged, that file, this program, and the modules it loads are to be
+owned by root and writable by no one else, or it refuses to run. When it runs
+unprivileged, through a gate that was not blessed, the caller can give other
+constraints with --constraints, since nothing then runs with more than the
+caller's own rights.
+
+Usage: journal_interpreter_file [--constraints <file>] [--print] [--stop-after <check>]
+ --constraints <file> constraints to use in place of constraints_file.json; unprivileged only
+ --print print the journal as decoded
+ --stop-after <check> stop after decode, wellformed, constraint, validity or filesystem
+"""
+
+import sys ,os
+sys.dont_write_bytecode = True
+
+import json ,pwd ,stat
+from pathlib import Path
+
+HERE_DPATH = Path(__file__).resolve().parent
+CONSTRAINT_FILE = HERE_DPATH / "constraints_file.json"
+MODULE = ("Journal.py" ,"FileLanguage.py" ,"journal_interpreter.py" ,"journal_interpreter_file.py")
+
+
+def privileged():
+ return os.geteuid() != os.getuid() or os.getegid() != os.getgid()
+
+def owned_by_root_problem(path):
+ try:
+ st = os.lstat(path)
+ except OSError as error:
+ return f"{path}: {error.strerror}"
+ if st.st_uid != 0:
+ return f"{path} is not owned by root"
+ if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
+ return f"{path} is writable by an account other than root"
+ return None
+
+# Running privileged, every file this program loads is checked before any of
+# it is imported: a module runs code when it is imported.
+if privileged():
+ for each in [HERE_DPATH ,CONSTRAINT_FILE] + [HERE_DPATH / m for m in MODULE]:
+ problem = owned_by_root_problem(each)
+ if problem:
+ print(f"error: refusing to run privileged: {problem}" ,file=sys.stderr)
+ sys.exit(2)
+
+import journal_interpreter
+import FileLanguage
+
+
+def load_constraint(given):
+ if given is not None:
+ if privileged():
+ raise RuntimeError("--constraints is refused when running privileged")
+ path = Path(given)
+ else:
+ path = CONSTRAINT_FILE
+ with open(path ,encoding="utf-8") as f:
+ return json.load(f)
+
+def CLI(argv=None):
+ argv = sys.argv[1:] if argv is None else argv
+ given ,stop_after ,print_journal = None ,None ,False
+ i = 0
+ while i < len(argv):
+ word = argv[i]
+ if word == "--constraints" and i + 1 < len(argv):
+ given = argv[i + 1]; i += 2
+ elif word == "--stop-after" and i + 1 < len(argv) and argv[i + 1] in journal_interpreter.STOP:
+ stop_after = argv[i + 1]; i += 2
+ elif word == "--print":
+ print_journal = True; i += 1
+ else:
+ print(f"error: unknown argument {word!r}" ,file=sys.stderr)
+ return 2
+ try:
+ constraint = load_constraint(given)
+ except (OSError ,ValueError ,RuntimeError) as error:
+ print(f"error: constraints: {error}" ,file=sys.stderr)
+ return 2
+ caller = pwd.getpwuid(os.getuid()).pw_name
+ journal_bytes = sys.stdin.buffer.read()
+ return journal_interpreter.interpret(
+ journal_bytes ,FileLanguage ,constraint ,caller
+ ,stop_after=stop_after ,print_journal=print_journal
+ )
+
+
+if __name__ == "__main__":
+ sys.exit(CLI())
+++ /dev/null
-#define _GNU_SOURCE
-#include <errno.h>
-#include <grp.h>
-#include <libgen.h>
-#include <pwd.h>
-#include <stdarg.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/prctl.h>
-#include <sys/resource.h>
-#include <sys/stat.h>
-#include <sys/types.h>
-#include <unistd.h>
-
-static void die(const char *fmt ,...) {
- va_list ap; va_start(ap ,fmt);
- vfprintf(stderr ,fmt ,ap); va_end(ap);
- fputc('\n' ,stderr);
- _exit(2);
-}
-
-static void sanitize_env(void) {
- clearenv();
- setenv("PATH" ,"/usr/sbin:/usr/bin:/sbin:/bin" ,1);
- setenv("LANG" ,"C.UTF-8" ,1);
-}
-
-static bool in_group(const char *group_name) {
- if (!group_name || !*group_name) return false;
- struct group *gr = getgrnam(group_name);
- if (!gr) return false;
-
- uid_t ruid = getuid();
- struct passwd *pw = getpwuid(ruid);
- if (!pw) return false;
-
- int ng = 0;
- getgrouplist(pw->pw_name ,getgid() ,NULL ,&ng);
- if (ng <= 0) return false;
-
- gid_t *ids = malloc((size_t)ng * sizeof(gid_t));
- if (!ids) return false;
-
- if (getgrouplist(pw->pw_name ,getgid() ,ids ,&ng) < 0) { free(ids); return false; }
-
- bool ok = false;
- for (int i = 0; i < ng; ++i) {
- if (ids[i] == gr->gr_gid) { ok = true; break; }
- }
- free(ids);
- return ok;
-}
-
-static char *exe_dirname(void) {
- static char buf[4096];
- ssize_t n = readlink("/proc/self/exe" ,buf ,sizeof(buf)-1);
- if (n < 0) die("cannot resolve /proc/self/exe: %s" ,strerror(errno));
- buf[n] = '\0';
- char *copy = strdup(buf);
- if (!copy) die("oom");
- char *dir = dirname(copy);
- return dir; /* caller frees */
-}
-
-static void print_flags(bool privileged ,bool in_sudo ,uid_t ruid) {
- struct passwd *pw = getpwuid(ruid);
- const char *name = pw ? pw->pw_name : "unknown";
- printf("caller.uid=%ld\n" ,(long)ruid);
- printf("caller.name=%s\n" ,name);
- printf("flag.this_process_privileged=%d\n" ,privileged ? 1 : 0);
- printf("flag.uid_in_group_sudo=%d\n" ,in_sudo ? 1 : 0);
-}
-
-int main(int argc ,char **argv) {
- /* defaults */
- const char *python = "/usr/bin/python3";
- char *bindir = exe_dirname();
- char inner_default[4096];
- snprintf(inner_default ,sizeof inner_default ,"%s/../python3/executor_inner.py" ,bindir);
- free(bindir);
- const char *inner = inner_default;
-
- /* minimal arg parse: --inner, --plan, --print-flags */
- char **forward = calloc((size_t)argc + 1 ,sizeof(char*));
- if (!forward) die("oom");
- int fi = 0;
-
- const char *plan_arg = NULL;
- bool plan_is_stdin = false;
- bool want_print_flags = false;
-
- for (int i = 1; i < argc; ++i) {
- if (strcmp(argv[i] ,"--inner") == 0 && i+1 < argc) { inner = argv[++i]; continue; }
- if (strcmp(argv[i] ,"--plan") == 0 && i+1 < argc) {
- plan_arg = argv[++i];
- plan_is_stdin = (strcmp(plan_arg ,"-") == 0);
- forward[fi++] = (char*)"--plan";
- forward[fi++] = (char*)plan_arg;
- continue;
- }
- if (strcmp(argv[i] ,"--print-flags") == 0) { want_print_flags = true; continue; }
- /* pass through any phase-2-* etc. */
- forward[fi++] = argv[i];
- }
- forward[fi] = NULL;
-
- /* compute flags */
- bool privileged = (geteuid() == 0);
- bool in_sudo = in_group("sudo"); /* /etc/group is world-readable */
-
- /* test option */
- if (want_print_flags) {
- print_flags(privileged ,in_sudo ,getuid());
- return 0;
- }
-
- /* policy: if privileged but real user is neither root nor in sudo, abort */
- uid_t ruid = getuid();
- if (privileged && ruid != 0 && !in_sudo) {
- struct passwd *pw = getpwuid(ruid);
- const char *name = pw ? pw->pw_name : "unknown";
- fprintf(stderr,
- "refusing privileged apply: real user '%s' is not root and not in group 'sudo'\n",
- name
- );
- return 1;
- }
-
- /* harden & annotate environment */
- sanitize_env();
- umask(077);
- prctl(PR_SET_DUMPABLE ,0 ,0 ,0 ,0);
- chdir("/");
-
- {
- char uidb[32] ,gidb[32];
- snprintf(uidb ,sizeof uidb ,"%ld" ,(long)getuid());
- snprintf(gidb ,sizeof gidb ,"%ld" ,(long)getgid());
- struct passwd *pw = getpwuid(getuid());
- setenv("MIG_CALLER_UID" ,uidb ,1);
- setenv("MIG_CALLER_GID" ,gidb ,1);
- setenv("MIG_CALLER_NAME" ,pw ? pw->pw_name : "unknown" ,1);
- setenv("MIG_FLAG_THIS_PROCESS_PRIVILEGED" ,privileged ? "1" : "0" ,1);
- setenv("MIG_FLAG_UID_IN_GROUP_SUDO" ,in_sudo ? "1" : "0" ,1);
- }
-
- /* build argv for inner */
- char *argv3[1024];
- size_t k = 0;
- argv3[k++] = (char*)python;
- argv3[k++] = (char*)inner;
-
- if (plan_is_stdin) {
- if (dup2(STDIN_FILENO ,3) < 0) die("dup2(stdin→3) failed: %s" ,strerror(errno));
- argv3[k++] = (char*)"--plan-fd";
- argv3[k++] = (char*)"3";
- /* strip the original --plan - from forward list */
- for (int i = 0; forward[i]; ++i) {
- if (strcmp(forward[i] ,"--plan") == 0 && forward[i+1] && strcmp(forward[i+1] ,"-") == 0) { i++; continue; }
- argv3[k++] = forward[i];
- }
- } else {
- for (int i = 0; forward[i]; ++i) argv3[k++] = forward[i];
- }
- argv3[k] = NULL;
-
- /* inner exists? (don’t force root ownership for unpriv tests) */
- struct stat st;
- if (stat(inner ,&st) != 0) die("inner not found: %s" ,inner);
-
- execv(python ,argv3);
- die("execv(%s) failed: %s" ,python ,strerror(errno));
-}
--- /dev/null
+/*
+ Man_In_Grey_file_gate
+
+ The gate of the bespoke journal interpreter for the file command language.
+ Installed setuid root, it starts the journal interpreter with root's rights
+ while the real uid still names the caller. Unblessed, it starts the journal
+ interpreter with the caller's rights.
+
+ The journal interpreter is at a path fixed when the gate is compiled,
+ relative to the gate: ../Man_In_Grey/journal_interpreter_file.py. The caller
+ cannot name another. When running privileged, the gate refuses to start it
+ unless the interpreter, and every directory above it, is owned by root and
+ writable by no other account.
+
+ The journal arrives on standard input and is passed on unread. Every
+ argument is passed on to the journal interpreter, except --print-flags,
+ which reports the caller and whether the gate is running privileged.
+
+ Who is allowed to hand a journal to the interpreter is decided by the
+ interpreter's constraints, not here.
+*/
+
+#include <errno.h>
+#include <libgen.h>
+#include <limits.h>
+#include <pwd.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/prctl.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#define PYTHON "/usr/bin/python3"
+#define INTERPRETER_RELATIVE "../Man_In_Grey/journal_interpreter_file.py"
+#define ARGUMENT_MAX 64
+
+Local void die(const char *format ,...){
+ va_list ap; va_start(ap ,format);
+ fputs("Man_In_Grey_file_gate: " ,stderr);
+ vfprintf(stderr ,format ,ap); va_end(ap);
+ fputc('\n' ,stderr);
+ _exit(2);
+}
+
+Local bool privileged(void){
+ return geteuid() != getuid() || getegid() != getgid();
+}
+
+/* path and every directory above it: owned by root, writable by no other account */
+Local void check_owned_by_root(const char *path){
+ char prefix[PATH_MAX];
+ size_t length = strlen(path);
+ if(length >= sizeof prefix) die("path too long: %s" ,path);
+ for(size_t i = 1; i <= length; ++i){
+ if( i < length && path[i] != '/' ) continue;
+ memcpy(prefix ,path ,i); prefix[i] = '\0';
+ struct stat st;
+ if( lstat(prefix ,&st) != 0 ) die("cannot examine %s: %s" ,prefix ,strerror(errno));
+ if( st.st_uid != 0 ) die("refusing to run privileged: %s is not owned by root" ,prefix);
+ if( st.st_mode & (S_IWGRP | S_IWOTH) ) die("refusing to run privileged: %s is writable by an account other than root" ,prefix);
+ }
+ struct stat st;
+ if( lstat("/" ,&st) != 0 || st.st_uid != 0 ) die("refusing to run privileged: / is not owned by root");
+}
+
+Local void print_flags(void){
+ struct passwd *pw = getpwuid(getuid());
+ printf("caller.uid=%ld\n" ,(long)getuid());
+ printf("caller.name=%s\n" ,pw ? pw->pw_name : "unknown");
+ printf("effective.uid=%ld\n" ,(long)geteuid());
+ printf("privileged=%d\n" ,privileged() ? 1 : 0);
+}
+
+int main(int argc ,char **argv){
+ if( argc > 1 && strcmp(argv[1] ,"--print-flags") == 0 ){ print_flags(); return 0; }
+ if( argc > ARGUMENT_MAX ) die("too many arguments");
+
+ char exe[PATH_MAX];
+ ssize_t n = readlink("/proc/self/exe" ,exe ,sizeof exe - 1);
+ if( n < 0 ) die("cannot resolve /proc/self/exe: %s" ,strerror(errno));
+ exe[n] = '\0';
+
+ char candidate[PATH_MAX];
+ if( snprintf(candidate ,sizeof candidate ,"%s/%s" ,dirname(exe) ,INTERPRETER_RELATIVE) >= (int)sizeof candidate )
+ die("path too long");
+ char interpreter[PATH_MAX];
+ if( !realpath(candidate ,interpreter) ) die("journal interpreter not found: %s" ,candidate);
+
+ if( privileged() ) check_owned_by_root(interpreter);
+
+ /* harden the process before starting the interpreter */
+ uid_t caller_uid = getuid();
+ gid_t caller_gid = getgid();
+ struct passwd *pw = getpwuid(caller_uid);
+ char caller_name[256];
+ snprintf(caller_name ,sizeof caller_name ,"%s" ,pw ? pw->pw_name : "unknown");
+
+ clearenv();
+ setenv("PATH" ,"/usr/sbin:/usr/bin:/sbin:/bin" ,1);
+ setenv("LANG" ,"C.UTF-8" ,1);
+ umask(077);
+ prctl(PR_SET_DUMPABLE ,0 ,0 ,0 ,0);
+ if( chdir("/") != 0 ) die("cannot change directory to /");
+
+ char number[32];
+ snprintf(number ,sizeof number ,"%ld" ,(long)caller_uid); setenv("MIG_CALLER_UID" ,number ,1);
+ snprintf(number ,sizeof number ,"%ld" ,(long)caller_gid); setenv("MIG_CALLER_GID" ,number ,1);
+ setenv("MIG_CALLER_NAME" ,caller_name ,1);
+
+ /* python3 -E -s -B <interpreter> <arguments> */
+ char *argument[ARGUMENT_MAX + 6];
+ int k = 0;
+ argument[k++] = (char *)PYTHON;
+ argument[k++] = (char *)"-E";
+ argument[k++] = (char *)"-s";
+ argument[k++] = (char *)"-B";
+ argument[k++] = interpreter;
+ for(int i = 1; i < argc; ++i) argument[k++] = argv[i];
+ argument[k] = NULL;
+
+ execv(PYTHON ,argument);
+ die("cannot start %s: %s" ,PYTHON ,strerror(errno));
+}
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>Man_In_Grey: Design</title>
+ <script>
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
+ </script>
+ </head>
+ <body>
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+
+ <RT·title
+ title="Man_In_Grey: Design"
+ author="Thomas Walker Lynch"
+ date="2026-10-22"
+ copyright="2026 Reasoning Technology">
+ </RT·title>
+
+ <RT·TOC level="1-2"></RT·TOC>
+
+ <RT·section id="sec-purpose">
+ <RT·name>Purpose</RT·name>
+ <p>
+ The Man_In_Grey tools are used to facilitate the controlled generation and installation of system configuration files, such as the files under <RT·code>/etc</RT·code>. These tools can be used, for example, to complete unprivileged account builds that require system configuration file changes, and to support programs that run from unprivileged accounts but, as a normal part of their operation, require configuration file changes.
+ </p>
+ <p>
+ With the Man_In_Grey approach, each set of one or more configuration files is the work product of an unprivileged configuration <RT·term>composer</RT·term> program. When the composer program runs, it places commands into a <RT·term>journal</RT·term>. Later, a <RT·term>journal interpreter</RT·term> running in privileged mode reads the journal, approves it, and then executes its commands. The set of journal commands is purposely limited to make such verification possible.
+ </p>
+ <p>
+ Nothing in this arrangement is particular to configuration files. Man_In_Grey is a general means for an unprivileged program to compose changes that a privileged program checks and then makes. The command language a journal is written in is chosen by the application, not by Man_In_Grey. Configuration files are the first use, and the server of AccountTree is the first application.
+ </p>
+ </RT·section>
+
+ <RT·section id="sec-provide">
+ <RT·name>What Man_In_Grey provides</RT·name>
+ <ol>
+ <li>The <RT·term>journal interface</RT·term>, <RT·code>journal_iface</RT·code>, through which a composer makes a journal.</li>
+ <li>A structure for gating, checking and executing journals: a compiled <RT·term>gate</RT·term> that gives privilege, a <RT·term>guard</RT·term> that checks every command of a journal against <RT·term>constraints</RT·term>, and a general interpreter that executes the journal once the guard has found nothing wrong.</li>
+ </ol>
+ <p>
+ An application chooses its command language and its constraints. Man_In_Grey comes with one command language, a small file language used for testing and for configuration files.
+ </p>
+ </RT·section>
+
+ <RT·section id="sec-term">
+ <RT·name>Terms</RT·name>
+ <RT·dictionary key="Term" definition="Meaning">
+ <RT·entry key="composer">
+ A program that places commands into a journal. It runs unprivileged. A composer can be a small Python file, run by <RT·code>Man_In_Grey compose</RT·code>, or one of a directory of them, run by <RT·code>Man_In_Grey orchestrate</RT·code>, or any program that uses the journal interface as a library, as the AccountTree server is to.
+ </RT·entry>
+ <RT·entry key="journal">
+ The ordered commands a composer places, encoded as data. Nothing is executed until the whole journal has been checked.
+ </RT·entry>
+ <RT·entry key="command">
+ One entry of a journal: the command's name, its arguments, and the composer that placed it.
+ </RT·entry>
+ <RT·entry key="symbol">
+ What a composer holds in place of a value that a command produces only when it is executed, such as an open file. A later command names the symbol where it needs the value.
+ </RT·entry>
+ <RT·entry key="command language">
+ A set of commands, defined as a module with two halves: the journal interface a composer uses to place the commands, and the checks and execution a journal interpreter uses to carry them out.
+ </RT·entry>
+ <RT·entry key="constraints">
+ The declared rules of one bespoke journal interpreter: which accounts can hand it journals, and what its commands are allowed to do. For the file language: which files can be opened, in which modes, with which owner and permission.
+ </RT·entry>
+ <RT·entry key="guard">
+ The step that checks a journal against the constraints, before the general interpreter executes any of it.
+ </RT·entry>
+ <RT·entry key="gate">
+ The compiled program that starts a bespoke journal interpreter. Installed setuid root, it gives the interpreter root's rights. A gate that has not been blessed, and so changes no uid, is a <RT·term>trivial gate</RT·term>.
+ </RT·entry>
+ <RT·entry key="bespoke journal interpreter">
+ A gate, a guard with its constraints, and the general interpreter, made for one application and accepting one command language.
+ </RT·entry>
+ <RT·entry key="caller, principal">
+ The caller is the account that ran the gate, known by its real uid. The principal is the account on whose behalf the journal was composed. For a person running Man_In_Grey for himself, they are the same; for a server composing for the accounts it serves, the caller is the server's account.
+ </RT·entry>
+ </RT·dictionary>
+ </RT·section>
+
+ <RT·section id="sec-band">
+ <RT·name>Out-of-band signalling</RT·name>
+ <p>
+ A template mixes two kinds of thing in one stream: the content to be written, and marks that say where to substitute. That is <RT·term>in-band signalling</RT·term>. Some content will always look like a mark, so the template language needs an escape, and the escape is itself a mark. There is no tight way to design the aliasing out. Each level of nesting multiplies the escapes: an f-string that explains how to write f-strings escapes its braces, and a document about that f-string escapes them again.
+ </p>
+ <p>
+ Man_In_Grey signals out of band. A composer is a program, so the logic that forms the output is code, with loops, conditions and lookups, rather than marks embedded in the output. A command in the journal is a structure, and file content is carried in it as a byte string with its length. The journal interpreter never scans content for meaning, so content can hold anything, including text that looks like a journal, with no escaping. This is the same principle as an extent string, where the extent of the content travels beside it rather than being marked within it.
+ </p>
+ <p>
+ The journal is encoded in CBOR, whose strings are length-prefixed for the same reason. Decoding CBOR makes maps, lists, strings and bytes, and cannot run code, so a journal reaches the privileged side as data and nothing else. A composer remains free to use a template inside itself; that is the composer's choice, and the journal does not inherit its aliasing.
+ </p>
+ </RT·section>
+
+ <RT·section id="sec-symbol">
+ <RT·name>Symbols</RT·name>
+ <p>
+ A composer writes commands; it does not run them. So when a command produces a value, such as the file an <RT·code>open</RT·code> opens, the value does not exist while the composer runs. The journal interface returns a <RT·term>symbol</RT·term> in its place: a reference to the command that will produce the value. A later command names the symbol, and the journal interpreter, executing the commands in order, gives it the value the earlier command produced.
+ </p>
+ <RT·code>
+ file = journal_iface.open("/etc/unbound" ,"unbound-US.conf" ,"replace_keep" ,"root" ,0o644)
+ journal_iface.write(file ,"server:\n verbosity: 1\n")
+ </RT·code>
+ <p>
+ Here <RT·code>file</RT·code> is a symbol. In the journal it is the index of the <RT·code>open</RT·code> command, written <RT·code>{"symbol": 0}</RT·code>. A journal has no variables beyond this: a symbol is set once, by the command that produces it, and only later commands name it, which the journal interpreter checks before executing anything. The composer being a program does not let it use the value, since the value comes into being only when the journal is executed.
+ </p>
+ </RT·section>
+
+ <RT·section id="sec-constraint">
+ <RT·name>Constraints and the guard</RT·name>
+ <p>
+ Privilege exists to limit what accounts can do. Man_In_Grey keeps that limit while letting an unprivileged program ask for privileged work: the bespoke journal interpreter has the privilege, and its guard decides, by its constraints, which of the requested work is allowed. Because the guard runs before execution, it filters rather than audits after the fact.
+ </p>
+ <p>
+ The command language declares what can be done; the constraints declare where; the composer chooses among what the constraints allow. In the file language the composer names the file it opens, which keeps the composer readable, and the guard refuses any file the constraints do not list. Every later command about the file names its symbol rather than a path, so the constraints are checked once, at <RT·code>open</RT·code>.
+ </p>
+ <p>
+ Three rules make the guard dependable:
+ </p>
+ <ul>
+ <li><em>Check the whole journal, then execute.</em> Every command is checked before the first is executed, so a journal that breaks a constraint anywhere changes nothing.</li>
+ <li><em>Keep the command language small.</em> Each command is a narrow operation whose effect can be stated, so the guard can decide about it. A command such as "run this shell line" could not be checked, and belongs in no command language.</li>
+ <li><em>Check twice; the second check is the one that counts.</em> The journal interface checks each call's arguments when the composer makes it, and raises an error at once, naming the composer. The journal interpreter checks again, because a journal is data from an unprivileged program and can have been made by anything.</li>
+ </ul>
+ <p>
+ The guard can check what a command does from the command and the system: whether a file is one the constraints list, whether an account exists, whether a directory passes through a symbolic link. It cannot check who asked except through the caller; see <a href="#sec-caller"><RT·Counter·read snapshot="sec-caller" key="counter count"></RT·Counter·read></a>.
+ </p>
+ </RT·section>
+
+ <RT·section id="sec-gate">
+ <RT·name>Gates and privilege</RT·name>
+ <p>
+ A process has a real uid, naming who started it, and an effective uid, the one the kernel checks for permission. Usually they are the same. When a person runs a program file marked setuid, the kernel sets the effective uid to that of the file's owner and leaves the real uid as the person's. A setuid program owned by root therefore runs with root's rights while still knowing who ran it. That is how <RT·code>passwd</RT·code> lets a person change his own password in <RT·code>/etc/shadow</RT·code>.
+ </p>
+ <p>
+ The gate is compiled because Linux ignores the setuid bit on a script started with <RT·code>#!</RT·code>: between the kernel opening the script and the interpreter reading it, the script could be swapped. The gate is the first few lines of its bespoke journal interpreter. It:
+ </p>
+ <ol>
+ <li>finds the interpreter at a path fixed when the gate is compiled, relative to the gate itself, so a caller cannot name another;</li>
+ <li>when running privileged, refuses unless the interpreter, and every directory above it, is owned by root and writable by no other account;</li>
+ <li>clears the environment, setting only <RT·code>PATH</RT·code> and <RT·code>LANG</RT·code>, sets the file mode mask to <RT·code>077</RT·code>, marks the process not dumpable, and changes directory to <RT·code>/</RT·code>;</li>
+ <li>records the caller in <RT·code>MIG_CALLER_UID</RT·code>, <RT·code>MIG_CALLER_GID</RT·code> and <RT·code>MIG_CALLER_NAME</RT·code>;</li>
+ <li>starts <RT·code>/usr/bin/python3 -E -s -B</RT·code> on the interpreter, passing the journal on standard input.</li>
+ </ol>
+ <p>
+ The Python part of the bespoke interpreter, before it imports anything, checks that every module it will load and its constraints file are owned by root and writable by no one else. A module runs code when it is imported, so the check comes first.
+ </p>
+ <p>
+ Who is allowed to hand a journal to the interpreter is a constraint, not a rule of the gate's. The gate decides only whether the interpreter it would start can be trusted.
+ </p>
+ <p>
+ A trivial gate, one not blessed, changes no uid. Everything then runs with the caller's rights, and the guard still applies. The interpreter then accepts constraints given by the caller, with <RT·code>--constraints</RT·code>, since nothing runs with more than the caller's own rights. Running privileged it refuses them, and reads only its own root-owned constraints file. The trivial gate is the convenient way to use Man_In_Grey on files the person owns, and the way to prototype a command language before giving it privilege.
+ </p>
+ <RT·section>
+ <RT·name>Least privilege, planned</RT·name>
+ <p>
+ Version 1.0 gates are setuid root, which gives the interpreter every one of root's rights. Root's rights are a set of about forty separate capabilities, among them <RT·code>CAP_DAC_OVERRIDE</RT·code>, to ignore file permissions; <RT·code>CAP_CHOWN</RT·code>, to change owners; <RT·code>CAP_SYS_ADMIN</RT·code>, to mount; and <RT·code>CAP_SETUID</RT·code>, to become another uid. The design is to give each gate only the capabilities its command language needs.
+ </p>
+ <p>
+ <RT·code>setcap</RT·code> stores chosen capabilities in a program file's extended attributes. A process running that file holds them while its uid stays the caller's. Capabilities are normally lost when a program <RT·code>exec</RT·code>s one whose file holds none, as the gate does when it starts Python; the gate keeps them by raising them as ambient capabilities first, which survive the <RT·code>exec</RT·code>. A gate for the file language would then be able to write files and change owners, and nothing else. The gain is uneven: <RT·code>CAP_SYS_ADMIN</RT·code>, needed for mounting, is nearly root by itself.
+ </p>
+ </RT·section>
+ <RT·section>
+ <RT·name>Installation</RT·name>
+ <p>
+ A privileged gate and everything its interpreter loads are to be owned by root, in directories only root can write, and the gate checks this. A Harmony <RT·code>consumer/</RT·code> directory is owned by the person who promoted into it, so a gate blessed there refuses to run. Installing a bespoke journal interpreter is a step of its own, done by root.
+ </p>
+ </RT·section>
+ </RT·section>
+
+ <RT·section id="sec-language">
+ <RT·name>Command languages</RT·name>
+ <p>
+ A command language is defined once and used from both sides. Its composer half is its journal interface: one function per command, which checks the call's arguments, raises an error on a wrong one, and places the command. Its interpreter half is its checks, the constraint check among them, and the code that executes each command. Keeping both halves in one module keeps the two checks in step.
+ </p>
+ <p>
+ The command language is not specific to Man_In_Grey. An application that mounts home directories has a language of mount and unmount; one that manages accounts has a language for making and deleting them. A bespoke journal interpreter accepts one command language and refuses a journal written in another.
+ </p>
+
+ <RT·section id="sec-file-language">
+ <RT·name>The file language</RT·name>
+ <p>
+ Man_In_Grey's own command language is a constrained form of the POSIX file interface:
+ </p>
+ <RT·dictionary key="Command" definition="Meaning">
+ <RT·entry key="open(directory ,name ,mode ,owner ,permission)">
+ Produces a symbol for the file. A relative directory is made absolute from the composer's working directory. <RT·code>permission</RT·code> is an integer such as <RT·code>0o644</RT·code>, or three or four octal digits such as <RT·code>"0644"</RT·code>.
+ </RT·entry>
+ <RT·entry key="write(file ,content)">
+ Appends content, text or bytes, to the file an <RT·code>open</RT·code> produced. Text is encoded as UTF-8.
+ </RT·entry>
+ <RT·entry key="unlink(directory ,name)">
+ Removes a file. A file that is absent is not an error.
+ </RT·entry>
+ </RT·dictionary>
+ <RT·dictionary key="Mode of open" definition="Meaning">
+ <RT·entry key="create">
+ The file must not exist.
+ </RT·entry>
+ <RT·entry key="replace">
+ An existing file is discarded.
+ </RT·entry>
+ <RT·entry key="replace_keep">
+ An existing file is kept as <RT·code><name>.YYYYMMDDTHHMMSSZ</RT·code>, the time in UTC, with <RT·code>.1</RT·code>, <RT·code>.2</RT·code> added when that name is taken.
+ </RT·entry>
+ </RT·dictionary>
+ <p>
+ There is no <RT·code>close</RT·code>. Each opened file is written to a temporary file in its own directory, and after the journal's last command every opened file is given its owner and permission and put in place, each in one step: a rename for <RT·code>replace</RT·code>, a hard link that refuses an existing name for <RT·code>create</RT·code>, and for <RT·code>replace_keep</RT·code> a hard link of the old file to its backup name followed by the rename, so the file's name is never absent. Every directory is opened without following a symbolic link, and names are worked on inside the open directory.
+ </p>
+ <p>
+ Execution stops at the first error, and every temporary file not yet put in place is removed. An <RT·code>unlink</RT·code> earlier in the journal has already happened by then.
+ </p>
+ <p>
+ The interpreter checks a journal in four steps, each over every command, before executing any of it:
+ </p>
+ <RT·dictionary key="Check" definition="What it looks at">
+ <RT·entry key="wellformed">
+ Known commands with exactly their arguments, of the right kinds; absolute directories in normal form; bare file names; every <RT·code>write</RT·code> naming the symbol of an <RT·code>open</RT·code>.
+ </RT·entry>
+ <RT·entry key="constraint">
+ The guard: the caller is listed; a principal other than the caller is named only by a caller allowed to; every file opened or unlinked is listed, in that mode, with that owner and permission.
+ </RT·entry>
+ <RT·entry key="validity">
+ Every owner names an account.
+ </RT·entry>
+ <RT·entry key="filesystem">
+ Every directory exists, opens, and passes through no symbolic link; no file is named by two commands; a file opened in <RT·code>create</RT·code> mode does not exist; an existing file to be replaced or unlinked is a regular file.
+ </RT·entry>
+ </RT·dictionary>
+ <p>
+ The constraints of the file language are a JSON file:
+ </p>
+ <RT·code>
+ {
+ "caller": ["Thomas"],
+ "principal_caller": [],
+ "file": [
+ {"directory": "/etc/unbound", "name": "unbound-US.conf",
+ "mode": ["replace_keep"], "owner": "root", "permission": "0644"},
+ {"directory": "/etc/unbound", "name": "unbound-old.conf",
+ "mode": ["unlink"], "owner": "root", "permission": "0644"}
+ ]
+ }
+ </RT·code>
+ <p>
+ <RT·code>mode</RT·code> lists the open modes allowed for the file, and <RT·code>unlink</RT·code> when it can be removed. The constraints file installed with the interpreter lists no caller and no file, so it refuses everything until root writes its own.
+ </p>
+ </RT·section>
+ </RT·section>
+
+ <RT·section id="sec-caller">
+ <RT·name>Callers and principals</RT·name>
+ <p>
+ The interpreter knows the caller from the kernel, by real uid, and the caller cannot misstate it. When a person runs Man_In_Grey for himself, the caller is also the principal.
+ </p>
+ <p>
+ When a server composes journals on behalf of the accounts it serves, the caller is always the server's account, and the principal is carried in the journal. The interpreter cannot verify that claim; it can verify only that the claim came from a caller entitled to make it. So a journal naming a principal other than its caller is accepted only from a caller the constraints list as <RT·code>principal_caller</RT·code>. The server, for its part, learns the principal from the kernel, as the AccountTree server does through <RT·code>SO_PEERCRED</RT·code>, so the chain from the account that asked to the guard that decides rests on the kernel at both ends and on the listed server in between.
+ </p>
+ </RT·section>
+
+ <RT·section id="sec-journal">
+ <RT·name>The journal</RT·name>
+ <p>
+ A journal is a CBOR map:
+ </p>
+ <RT·dictionary key="Entry" definition="Meaning">
+ <RT·entry key="version">
+ 2.
+ </RT·entry>
+ <RT·entry key="command_language">
+ The name of the command language, such as <RT·code>file</RT·code>.
+ </RT·entry>
+ <RT·entry key="principal">
+ Present when the journal was composed on behalf of an account other than the caller.
+ </RT·entry>
+ <RT·entry key="meta">
+ When, where and by whom it was composed, and the composers that ran.
+ </RT·entry>
+ <RT·entry key="command_list">
+ The commands, each a map of <RT·code>command</RT·code>, <RT·code>argument</RT·code>, and <RT·code>composer</RT·code>. An argument that is a symbol is the map <RT·code>{"symbol": <index>}</RT·code>.
+ </RT·entry>
+ </RT·dictionary>
+ </RT·section>
+
+ <RT·section id="sec-transport">
+ <RT·name>Transports</RT·name>
+ <p>
+ A journal reaches its interpreter by starting the interpreter's gate with the journal on standard input. Since a journal is data, it can travel other ways: over a Unix domain socket to a running interpreter, which learns its caller through <RT·code>SO_PEERCRED</RT·code>, or over a network, where the caller is known only through authentication. Such an interpreter serves many callers, and chooses constraints, and perhaps the command language, by caller. Man_In_Grey stays at the lower level, as the mechanism servers are built with; a server that receives journals is an application of it.
+ </p>
+ </RT·section>
+
+ <RT·section id="sec-use">
+ <RT·name>Using Man_In_Grey</RT·name>
+
+ <RT·section>
+ <RT·name>Building</RT·name>
+ <p>
+ Man_In_Grey is a Harmony project with two namespaces: <RT·code>Man_In_Grey.copy</RT·code>, the Python programs, and <RT·code>Man_In_Grey_file_gate.make</RT·code>, the gate of the file language's interpreter. In the developer role, <RT·code>build "*"</RT·code> and <RT·code>promote write</RT·code> place them in the layout every part finds its partners by:
+ </p>
+ <RT·code>
+ consumer/Man_In_Grey/Man_In_Grey the command a person runs
+ consumer/Man_In_Grey/Man_In_Grey.py compose and orchestrate: run composers, make the journal
+ consumer/Man_In_Grey/Journal.py symbols, commands, the journal, CBOR
+ consumer/Man_In_Grey/FileLanguage.py the file language, both halves
+ consumer/Man_In_Grey/journal_interpreter.py the general interpreter
+ consumer/Man_In_Grey/journal_interpreter_file.py the file language's bespoke interpreter
+ consumer/Man_In_Grey/constraints_file.json its constraints, refusing everything
+ consumer/Man_In_Grey_file_gate/Man_In_Grey_file_gate the gate
+ </RT·code>
+ <p>
+ The same layout is staged in <RT·code>developer/scratchpad/made/</RT·code>, so a developer's experiment runs from there. Both sides need the Python package <RT·code>cbor2</RT·code>, each in its own Python. <RT·code>Man_In_Grey</RT·code>, and any program using the journal interface, runs on the first <RT·code>python3</RT·code> on <RT·code>PATH</RT·code>, which in a Harmony role is the project's linked Python when there is one; install the package into it with <RT·code>python3 -m pip install cbor2</RT·code>. The gate always starts <RT·code>/usr/bin/python3</RT·code>, so the interpreter never uses a person's Python, and the system Python needs the package from the distribution; on openSUSE, <RT·code>python3-cbor2</RT·code>.
+ </p>
+ </RT·section>
+
+ <RT·section>
+ <RT·name>Writing composers</RT·name>
+ <p>
+ Composers are kept in a directory named, by Harmony's convention, <RT·code>composer</RT·code>, since every file in it is one. Each is a Python file defining <RT·code>compose(prov ,journal_iface)</RT·code>. Its place in the directory carries no meaning; where its files go is said by its <RT·code>open</RT·code> and <RT·code>unlink</RT·code> commands.
+ </p>
+ <RT·code>
+ # composer/unbound-US.conf.py
+
+ def compose(prov ,journal_iface):
+ file = journal_iface.open("/etc/unbound" ,prov.implied_fname ,"replace_keep" ,"root" ,0o644)
+ journal_iface.write(file ,"server:\n verbosity: 1\n")
+ </RT·code>
+ <RT·dictionary key="Field of prov" definition="Meaning">
+ <RT·entry key="implied_fname">
+ The composer's own file name less <RT·code>.py</RT·code>, for a composer named for the file it writes.
+ </RT·entry>
+ <RT·entry key="process_user">
+ The name of the person who ran Man_In_Grey.
+ </RT·entry>
+ <RT·entry key="composer_root_dpath, composer_abs_fpath, composer_rel_fpath, composer_dir_dpath, cwd_dpath">
+ The composer directory, the composer's path absolute and relative to it, the directory holding the composer, and the working directory of the run.
+ </RT·entry>
+ </RT·dictionary>
+ <p>
+ <RT·code>Man_In_Grey compose <file></RT·code> runs one composer. <RT·code>Man_In_Grey orchestrate <directory></RT·code> runs every <RT·code>.py</RT·code> file in the directory whose name does not begin with a dot, breadth first and then by name; <RT·code>--input_acceptance</RT·code> names a Python file defining <RT·code>accept(prov)</RT·code> to choose otherwise. Nothing chosen when Man_In_Grey is run reaches <RT·code>compose</RT·code>; settings are written into the composer.
+ </p>
+ </RT·section>
+
+ <RT·section>
+ <RT·name>Running</RT·name>
+ <p>
+ A run that changes files is best preceded by two that do not. The first shows the journal while nothing has left the person's account; the second has the interpreter check the journal and stop before executing it:
+ </p>
+ <RT·code>
+ Man_In_Grey orchestrate composer --phase-1-print --phase-1-then-stop
+ Man_In_Grey orchestrate composer --phase-2-filesystem-then-stop
+ Man_In_Grey orchestrate composer
+ </RT·code>
+ <RT·dictionary key="Command or option" definition="Meaning">
+ <RT·entry key="compose <file>">
+ Run the one composer in the file.
+ </RT·entry>
+ <RT·entry key="orchestrate <directory>">
+ Run every composer in the directory.
+ </RT·entry>
+ <RT·entry key="--input_acceptance <file>">
+ The acceptance filter.
+ </RT·entry>
+ <RT·entry key="--gate <file>">
+ The gate to hand the journal to. Default: <RT·code>../Man_In_Grey_file_gate/Man_In_Grey_file_gate</RT·code> from <RT·code>Man_In_Grey.py</RT·code>.
+ </RT·entry>
+ <RT·entry key="--constraints <file>">
+ Constraints for an unprivileged run; refused by a privileged interpreter.
+ </RT·entry>
+ <RT·entry key="--phase-0-then-stop">
+ Stop after checking the composer directory and the filter.
+ </RT·entry>
+ <RT·entry key="--phase-1-print, --phase-1-then-stop">
+ Print the journal after composing; stop after composing.
+ </RT·entry>
+ <RT·entry key="--phase-2-print, --phase-2-then-stop">
+ Print the journal as the interpreter decoded it; stop after decoding.
+ </RT·entry>
+ <RT·entry key="--phase-2-wellformed-then-stop, --phase-2-constraint-then-stop, --phase-2-validity-then-stop, --phase-2-filesystem-then-stop">
+ Stop after the named check, reporting OK when it passed.
+ </RT·entry>
+ </RT·dictionary>
+ <p>
+ The command and its path come first, and the options follow; given no arguments, <RT·code>Man_In_Grey</RT·code> prints its help. The exit status is 0 when the run did what it was asked, 1 when a check refused the journal or a command failed, and 2 when the run could not start or a composer failed, a wrong argument to the journal interface included. To undo a <RT·code>replace_keep</RT·code>, rename the backup back.
+ </p>
+ </RT·section>
+
+ <RT·section>
+ <RT·name>Installing a privileged interpreter</RT·name>
+ <p>
+ As root, copy <RT·code>consumer/Man_In_Grey/</RT·code> and <RT·code>consumer/Man_In_Grey_file_gate/</RT·code> side by side into a directory owned by root and writable by no other account, make every file owned by root and not group- or other-writable, write the constraints into <RT·code>constraints_file.json</RT·code>, and bless the gate:
+ </p>
+ <RT·code>
+ chown root:root Man_In_Grey_file_gate/Man_In_Grey_file_gate
+ chmod 4755 Man_In_Grey_file_gate/Man_In_Grey_file_gate
+ Man_In_Grey_file_gate/Man_In_Grey_file_gate --print-flags
+ </RT·code>
+ <p>
+ <RT·code>4755</RT·code> is the mode <RT·code>755</RT·code> with the setuid bit, <RT·code>4000</RT·code>, added. <RT·code>--print-flags</RT·code> reports the caller, the effective uid, and whether the gate is running privileged. Promotion replaces files and does not bless, so the installation is redone after each promotion that changes the interpreter. The installation step is a candidate for Man_In_Grey itself, once there is a language for installing programs.
+ </p>
+ </RT·section>
+
+ <RT·section>
+ <RT·name>Testing</RT·name>
+ <p>
+ The tester's test <RT·code>tester/authored/write_file</RT·code> runs the promoted Man_In_Grey through an unblessed gate, with constraints it writes into its scratchpad directory, and checks nineteen things. Two runs of composers using every mode and <RT·code>unlink</RT·code> are compared with <RT·code>expected/run_1</RT·code> and <RT·code>expected/run_2</RT·code>, with backup times replaced by the letters <RT·code>YYYYMMDDTHHMMSSZ</RT·code>. A file opened in <RT·code>create</RT·code> mode is written once and refused the second time. A journal with one allowed file and one the constraints do not list is refused whole. A composer giving a wrong mode fails before any journal reaches the gate. Each refusal leaves every file as it was. In the tester role:
+ </p>
+ <RT·code>
+ authored/write_file/run test
+ authored/write_file/run clean
+ </RT·code>
+ </RT·section>
+ </RT·section>
+
+ <RT·section id="sec-problem">
+ <RT·name>Known problems</RT·name>
+ <RT·dictionary key="Problem" definition="What happens">
+ <RT·entry key="The gate is built without hardening">
+ The old compile script built the gate with <RT·code>-z relro</RT·code>, <RT·code>-z now</RT·code> and <RT·code>-fstack-protector-strong</RT·code>. Harmony's build has no flags for one namespace, so the gate is built without them.
+ </RT·entry>
+ <RT·entry key="An unlink is not undone">
+ Execution stops at the first error and removes the temporary files of files not yet put in place, but an <RT·code>unlink</RT·code> that ran earlier in the journal stays done.
+ </RT·entry>
+ <RT·entry key="Checks and execution are separate moments">
+ The filesystem check and execution are separate, so a file can appear or change between them. Execution then fails on that command rather than acting on the change: a <RT·code>create</RT·code> whose file has appeared is refused by the hard link.
+ </RT·entry>
+ <RT·entry key="No principal from the command line">
+ The journal and the guard carry a principal, but <RT·code>Man_In_Grey</RT·code> has no option to name one. A server using the journal interface as a library sets it directly.
+ </RT·entry>
+ </RT·dictionary>
+ </RT·section>
+
+ <RT·section id="sec-road">
+ <RT·name>Road map</RT·name>
+ <ol>
+ <li>The journal interface used as a library by the AccountTree server, and AccountTree's command languages and their bespoke interpreters.</li>
+ <li>Gates holding only the capabilities their command language needs, in place of setuid root.</li>
+ <li>A language for installing programs, so that installing a bespoke interpreter is itself done through Man_In_Grey.</li>
+ <li>Transports other than starting the gate.</li>
+ <li>Later, interpreters in a compiled language in place of Python.</li>
+ </ol>
+ </RT·section>
+
+ <RT·section id="sec-open">
+ <RT·name>Open questions</RT·name>
+ <ul>
+ <li>Whether composers are given settings chosen when Man_In_Grey is run, such as a settings file every composer receives.</li>
+ <li>Whether constraints name files only exactly, as now, or also by pattern.</li>
+ <li>Where installed bespoke interpreters live, and how <RT·code>Man_In_Grey</RT·code> is told which gate a journal goes to.</li>
+ </ul>
+ </RT·section>
+
+ <RT·section id="sec-history">
+ <RT·name>Changes from the first version</RT·name>
+ <p>
+ The first version had configuration programs calling a planner, a plan, a fixed set of operations, copy, displace and delete, an inner executor, and one gate, <RT·code>man_in_grey_apply</RT·code>, that accepted <RT·code>--inner <file></RT·code> from its caller. That option let any account in the group <RT·code>sudo</RT·code> run a program of its choosing with root's rights, without a password. The present design fixes the interpreter's path in its gate, moves the decision about callers into the constraints, raises an error in the composer on a wrong argument where the first version dropped the command silently, replaces a scope fence that the gate's change of directory made ineffective with constraints naming each file, removes temporary files after a failure, and gives a second backup within one second a name of its own.
+ </p>
+ </RT·section>
+
+ </RT·article>
+ </body>
+</html>
--- /dev/null
+project/Python-3.12.3
\ No newline at end of file
echo "Harmony v3.5 2026-10-14 04:51:11 Z"
+echo "Man-in-Grey v1.0 2026-10-22 10:28:37 Z"
+++ /dev/null
-# Man_In_Grey acceptance filter (default template)
-# Return True to include a config file ,False to skip it.
-# You receive a PlanProvenance object named `prov`.
-#
-# Common fields:
-# prov.stage_root_dpath : Path
-# prov.config_abs_fpath : Path
-# prov.config_rel_fpath : Path
-# prov.read_dir_dpath : Path
-# prov.read_fname : str
-#
-# 1) Accept everything (default):
-# def accept(prov):
-# return True
-#
-# 2) Only a namespace:
-# def accept(prov):
-# return prov.config_rel_fpath.as_posix().startswith("dns/")
-#
-# 3) Exclude editor junk:
-# def accept(prov):
-# r = prov.config_rel_fpath.as_posix()
-# return not (r.endswith("~") or r.endswith(".swp"))
-#
-def accept(prov):
- return True
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
-set -euo pipefail
-set -x
-
-find "$1" -type f -exec cat_named {} \;
-
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
-set -euo pipefail
-set -x
-
-find stage_test_0_out -type f -exec rm \-f {} \;
-
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
-set -euo pipefail
-set -x
-
-find "$1" -type f -print
-
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
-set -euo pipefail
-set -x
-
-$RELEASE_SHELL/Man_In_Grey --stage stage_test_0
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
-set -euo pipefail
-set -x
-
-./clean_out
-
-echo "first run"
-./run
-diff -qr stage_test_0_out stage_test_0_out_expected_0
-
-echo "second run"
-./run
-diff -qr stage_test_0_out stage_test_0_out_expected_1
+++ /dev/null
-# due to file dates, the second check will find differences in file date pairs
-#
-# pass 1
-# Yes—this is a clean PASS ✅
-# started clean,
-# ran Man_In_Grey once → all three outputs appeared with expected content,
-# snapshotted as stage_test_0_out_expected_0.
-#
-# pass 2
-# Yes—this is a clean PASS ✅
-# web/nginx.conf and unbound_conf were displaced on the 2nd run → timestamped backups created, new files written.
-#
-# net/unbound.conf shows no backup, which is correct because that config does delete then copy (so overwrite without keeping a prior version). If you want a backup there too, switch that entry from delete→displace.
-
-
-2025-09-20 16:12:28Z [Man_In_Grey:tester] Thomas-developer@StanleyPark
-§/home/Thomas/subu_data/developer/project/active/Man_In_Grey/tester/test_0§
-> ./run_check
-+ ./clean_out
-+ find stage_test_0_out -type f -exec rm -f '{}' ';'
-+ echo 'first run'
-first run
-+ ./run
-+ /home/Thomas/subu_data/developer/project/Linux/Man_In_Grey/release/shell/Man_In_Grey --stage stage_test_0
-+ diff -qr stage_test_0_out stage_test_0_out_expected_0
-+ echo 'second run'
-second run
-+ ./run
-+ /home/Thomas/subu_data/developer/project/Linux/Man_In_Grey/release/shell/Man_In_Grey --stage stage_test_0
-+ diff -qr stage_test_0_out stage_test_0_out_expected_1
-Only in stage_test_0_out_expected_1: unbound_conf.20250920T160648Z
-Only in stage_test_0_out: unbound_conf.20250920T161232Z
-Only in stage_test_0_out_expected_1/web: nginx.conf.20250920T160648Z
-Only in stage_test_0_out/web: nginx.conf.20250920T161233Z
-
-2025-09-20 16:12:33Z [Man_In_Grey:tester] Thomas-developer@StanleyPark
-§/home/Thomas/subu_data/developer/project/active/Man_In_Grey/tester/test_0§
->
+++ /dev/null
-# stage_test_0/DNS/unbound_conf.py
-
-def configure(prov, planner, WriteFileMeta):
- # dpath is relative; it will be anchored to prov.read_dir_dpath,
- # so this lands in .../stage_test_0/stage_test_0_out/dns
- wfm = WriteFileMeta(
- dpath="stage_test_0_out/net",
- fname=prov.read_fname, # "unbound_conf"
- owner=prov.process_user, # current process user
- mode=0o444
- )
- planner.delete(wfm)
- planner.copy(wfm, content="server:\n verbosity: 1\n")
+++ /dev/null
-# unbound.conf (example)
-
-def configure(prov, planner, WriteFileMeta):
- wfm = WriteFileMeta(
- dpath="stage_test_0_out"
- ,fname=prov.read_fname # write file name same as read file name
- ,owner=prov.process_user
- )
- planner.displace(wfm)
- planner.copy(wfm, content="server:\n do-ip6: no\n")
+++ /dev/null
-# stage_test_0/web/site_conf.py
-
-def configure(prov, planner, WriteFileMeta):
- # This writes a faux web config to .../stage_test_0/stage_test_0_out/web/nginx.conf
- wfm = WriteFileMeta(
- dpath="stage_test_0_out/web",
- fname="nginx.conf", # explicit override (not from prov)
- owner=prov.process_user,
- mode="0644"
- )
- planner.displace(wfm)
- planner.copy(wfm, content="events {}\nhttp { server { listen 8080; } }\n")
+++ /dev/null
-server:
- verbosity: 1
+++ /dev/null
-server:
- do-ip6: no
+++ /dev/null
-server:
- do-ip6: no
+++ /dev/null
-events {}
-http { server { listen 8080; } }
+++ /dev/null
-events {}
-http { server { listen 8080; } }
+++ /dev/null
-server:
- verbosity: 1
+++ /dev/null
-server:
- do-ip6: no
+++ /dev/null
-events {}
-http { server { listen 8080; } }
+++ /dev/null
-server:
- verbosity: 1
+++ /dev/null
-server:
- do-ip6: no
+++ /dev/null
-server:
- do-ip6: no
+++ /dev/null
-events {}
-http { server { listen 8080; } }
+++ /dev/null
-events {}
-http { server { listen 8080; } }
--- /dev/null
+server:
+ verbosity: 1
--- /dev/null
+server:
+ do-ip6: no
--- /dev/null
+events {}
+http { server { listen 8080; } }
--- /dev/null
+server:
+ verbosity: 1
--- /dev/null
+server:
+ do-ip6: no
--- /dev/null
+server:
+ do-ip6: no
--- /dev/null
+events {}
+http { server { listen 8080; } }
--- /dev/null
+events {}
+http { server { listen 8080; } }
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+run
+
+The write_file test of Man-In-Grey's file command language. It runs the
+promoted Man_In_Grey, from $REPO_HOME/consumer, through an unblessed gate,
+with constraints it writes for the files it expects, and checks:
+
+ run_1 the stage beside this file: each composer's file written, and
+ out/obsolete.conf, made by the test beforehand, unlinked; the
+ files are compared with expected/run_1
+ run_2 the same stage again: replace_keep files kept as backups named
+ <file>.YYYYMMDDTHHMMSSZ, the replace file overwritten with no
+ backup; compared with expected/run_2
+ create stage_create: a file opened in create mode is written once, and
+ a second run is refused by the filesystem check
+ refused stage_refused: a journal opening one allowed file and one the
+ constraints do not allow is refused whole by the constraint check
+ argument stage_argument: a composer giving a wrong mode fails when it
+ places the command, and no journal reaches the gate
+
+A refused run is to leave every file as it was. Backup names carry the time
+of the run, so names are compared with the time replaced by the letters
+YYYYMMDDTHHMMSSZ. Content, mode and owner are compared too.
+
+The test reads consumer/ and writes only in its scratchpad directory,
+$REPO_HOME/tester/scratchpad/write_file, where it runs Man_In_Grey.
+
+Usage: run <command>
+ help | -h | --help print this message (also printed when given no arguments)
+ test run the test; the exit status is 0 only when every check passes
+ clean delete the test's scratchpad directory
+"""
+
+import sys ,os ,re ,stat ,json ,shutil ,subprocess ,getpass
+from pathlib import Path
+
+TEST_NAME = "write_file"
+TEST_DPATH = Path(__file__).resolve().parent
+
+OUTPUT_DIR = ("out" ,"out/net" ,"out/web")
+
+# the files the constraints allow: (directory under the scratchpad, name, modes, permission)
+ALLOWED = (
+ ("out" ,"unbound_conf" ,["replace_keep"] ,"0444")
+ ,("out/net" ,"unbound.conf" ,["replace"] ,"0444")
+ ,("out/web" ,"nginx.conf" ,["replace_keep"] ,"0644")
+ ,("out" ,"obsolete.conf" ,["unlink"] ,"0644")
+ ,("out" ,"new.conf" ,["create"] ,"0600")
+)
+
+MODE = {
+ "run_1": {
+ "out/unbound_conf": 0o444
+ ,"out/net/unbound.conf": 0o444
+ ,"out/web/nginx.conf": 0o644
+ },
+ "run_2": {
+ "out/unbound_conf": 0o444
+ ,"out/unbound_conf.YYYYMMDDTHHMMSSZ": 0o444
+ ,"out/net/unbound.conf": 0o444
+ ,"out/web/nginx.conf": 0o644
+ ,"out/web/nginx.conf.YYYYMMDDTHHMMSSZ": 0o644
+ },
+}
+
+TIME_SUFFIX = re.compile(r"\.\d{8}T\d{6}Z(\.\d+)?$")
+TIME_PLACEHOLDER = ".YYYYMMDDTHHMMSSZ"
+
+
+# --------------------------------------------------------------------------------
+# the log
+
+TAG_WIDTH = 10
+
+class Log:
+ def __init__(self):
+ self.count = {"passed": 0 ,"failed": 0}
+
+ def check(self ,passed ,what ,detail=None):
+ tag = "passed" if passed else "failed"
+ self.count[tag] += 1
+ print(f" {tag:<{TAG_WIDTH}}{what}" + (f" ({detail})" if detail else ""))
+ return passed
+
+ def note(self ,text):
+ for line in text.splitlines():
+ print(f" {'':<{TAG_WIDTH}}{line}")
+
+ def summary(self):
+ print("summary:")
+ print(f" {self.count['passed']:>4} checks passed")
+ print(f" {self.count['failed']:>4} checks failed")
+ return 0 if self.count["failed"] == 0 else 1
+
+
+# --------------------------------------------------------------------------------
+# the work
+
+def tree(root):
+ """
+ The files under root as {name: path}, the name relative to root with any
+ backup time replaced by the placeholder; a name met twice is listed with
+ a count, so two backups of one file are seen.
+ """
+ result = {}
+ for path in sorted(p for p in root.rglob("*") if p.is_file() or p.is_symlink()):
+ name = TIME_SUFFIX.sub(TIME_PLACEHOLDER ,path.relative_to(root).as_posix())
+ if name in result:
+ n = 2
+ while f"{name} (copy {n})" in result: n += 1
+ name = f"{name} (copy {n})"
+ result[name] = path
+ return result
+
+def snapshot(root):
+ "every file under root, by its exact name, with its content, mode and owner"
+ result = {}
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
+ st = path.lstat()
+ result[path.relative_to(root).as_posix()] = (path.read_bytes() ,stat.S_IMODE(st.st_mode) ,st.st_uid)
+ return result
+
+def compare(log ,run_name ,work_dpath):
+ expected = tree(TEST_DPATH / "expected" / run_name)
+ found = {n: p for n ,p in tree(work_dpath).items() if n.startswith("out/")}
+ for name in sorted(set(expected) | set(found)):
+ what = f"{run_name} {name}"
+ if name not in found:
+ log.check(False ,what ,"expected, not written"); continue
+ if name not in expected:
+ log.check(False ,what ,"written, not expected"); continue
+ path = found[name]
+ problem = []
+ if path.read_bytes() != expected[name].read_bytes():
+ problem.append(f"content differs from expected/{run_name}/{name}")
+ mode = stat.S_IMODE(path.lstat().st_mode)
+ want = MODE[run_name].get(name)
+ if want is not None and mode != want:
+ problem.append(f"mode {mode:04o}, expected {want:04o}")
+ if path.lstat().st_uid != os.getuid():
+ problem.append(f"owner uid {path.lstat().st_uid}, expected {os.getuid()}")
+ log.check(not problem ,what ,"; ".join(problem) or None)
+
+def run_Man_In_Grey(program ,stage ,work_dpath ,constraint_fpath):
+ command = [str(program) ,"--stage" ,str(TEST_DPATH / stage) ,"--constraints" ,str(constraint_fpath)]
+ result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True)
+ return result.returncode ,(result.stdout + result.stderr).strip()
+
+def check_status(log ,what ,status ,want ,output ,want_text=None):
+ passed = status == want and (want_text is None or want_text in output)
+ detail = None
+ if status != want:
+ detail = f"exit status {status}, expected {want}"
+ elif not passed:
+ detail = f"the output does not say {want_text!r}"
+ log.check(passed ,what ,detail)
+ if not passed and output:
+ log.note(output)
+ return passed
+
+def write_constraint(work_dpath):
+ person = getpass.getuser()
+ constraint = {
+ "caller": [person]
+ ,"principal_caller": []
+ ,"file": [
+ {"directory": str(work_dpath / d) ,"name": n ,"mode": m ,"owner": person ,"permission": p}
+ for d ,n ,m ,p in ALLOWED
+ ]
+ }
+ path = work_dpath / "constraints.json"
+ path.write_text(json.dumps(constraint ,indent=2) + "\n" ,encoding="utf-8")
+ return path
+
+def test(repo_home):
+ program = repo_home / "consumer" / "Man_In_Grey" / "Man_In_Grey"
+ work_dpath = (repo_home / "tester" / "scratchpad" / TEST_NAME).resolve()
+ print(f"run test in {TEST_DPATH}")
+ print(f" program {program}")
+ print(f" writes {work_dpath}")
+ log = Log()
+
+ if not log.check(os.access(program ,os.X_OK) ,"Man_In_Grey is promoted"
+ ,None if os.access(program ,os.X_OK) else "the developer promotes Man_In_Grey and Man_In_Grey_file_gate first"):
+ return log.summary()
+
+ if work_dpath.exists():
+ shutil.rmtree(work_dpath)
+ for rel in OUTPUT_DIR:
+ (work_dpath / rel).mkdir(parents=True)
+ constraint_fpath = write_constraint(work_dpath)
+ (work_dpath / "out" / "obsolete.conf").write_text("to be unlinked\n")
+ out = work_dpath / "out"
+
+ # run_1 and run_2
+ for run_name in ("run_1" ,"run_2"):
+ status ,output = run_Man_In_Grey(program ,"stage" ,work_dpath ,constraint_fpath)
+ if check_status(log ,f"{run_name} Man_In_Grey exits 0" ,status ,0 ,output):
+ compare(log ,run_name ,work_dpath)
+ else:
+ log.note(f"{run_name}: the files were not compared, since Man_In_Grey failed")
+ return log.summary()
+
+ # create: once, then refused
+ status ,output = run_Man_In_Grey(program ,"stage_create" ,work_dpath ,constraint_fpath)
+ check_status(log ,"create Man_In_Grey exits 0" ,status ,0 ,output)
+ new = out / "new.conf"
+ log.check(new.is_file() and new.read_text() == "made once\n" and stat.S_IMODE(new.stat().st_mode) == 0o600
+ ,"create out/new.conf" ,None if new.is_file() else "not written")
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,"stage_create" ,work_dpath ,constraint_fpath)
+ check_status(log ,"create again is refused by the filesystem check" ,status ,1 ,output ,"filesystem: refused")
+ log.check(snapshot(out) == before ,"create again changes nothing")
+
+ # a journal the constraints refuse
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,"stage_refused" ,work_dpath ,constraint_fpath)
+ check_status(log ,"refused is refused by the constraint check" ,status ,1 ,output ,"constraint: refused")
+ log.check(snapshot(out) == before ,"refused changes nothing, the allowed file included")
+
+ # a composer with a wrong argument
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,"stage_argument" ,work_dpath ,constraint_fpath)
+ check_status(log ,"argument fails in the composer" ,status ,2 ,output ,"wrong_mode.py: open: mode 'overwrite'")
+ log.check(snapshot(out) == before ,"argument changes nothing")
+
+ return log.summary()
+
+def clean(repo_home):
+ work_dpath = repo_home / "tester" / "scratchpad" / TEST_NAME
+ print(f"run clean in {TEST_DPATH}")
+ if work_dpath.exists():
+ shutil.rmtree(work_dpath)
+ print(f" deleted {work_dpath}")
+ else:
+ print(f" kept {work_dpath} (there is nothing to delete)")
+ return 0
+
+
+# --------------------------------------------------------------------------------
+# command line
+
+def print_help():
+ print(__doc__.strip())
+ return 0
+
+def CLI(argv=None):
+ if argv is None: argv = sys.argv[1:]
+ if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+ command ,rest = argv[0] ,argv[1:]
+ if command not in ("test" ,"clean"):
+ print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+ print_help()
+ return 1
+ if rest:
+ print(f"Error: {command} is given no arguments." ,file=sys.stderr)
+ return 1
+ if not os.environ.get("REPO_HOME"):
+ print("Error: REPO_HOME is not set; enter the tester role with ./setup tester." ,file=sys.stderr)
+ return 1
+ repo_home = Path(os.environ["REPO_HOME"]).resolve()
+ return test(repo_home) if command == "test" else clean(repo_home)
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(CLI())
+ except BrokenPipeError:
+ os.dup2(os.open(os.devnull ,os.O_WRONLY) ,sys.stdout.fileno())
+ sys.exit(1)
--- /dev/null
+# replace: the second run discards the first run's file, keeping no backup.
+# The content is written in two parts.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out/net" ,prov.implied_fname ,"replace" ,prov.process_user ,"0444")
+ journal_iface.write(file ,"server:\n")
+ journal_iface.write(file ," verbosity: 1\n")
--- /dev/null
+# unlink: the first run removes out/obsolete.conf; the second finds nothing to remove.
+
+def compose(prov ,journal_iface):
+ journal_iface.unlink("out" ,"obsolete.conf")
--- /dev/null
+# replace_keep: the second run keeps the first run's file as a timestamped backup.
+# The file name is the one this composer implies: its own name less .py.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out" ,prov.implied_fname ,"replace_keep" ,prov.process_user ,0o444)
+ journal_iface.write(file ,"server:\n do-ip6: no\n")
--- /dev/null
+# replace_keep, under a file name given here rather than implied.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out/web" ,"nginx.conf" ,"replace_keep" ,prov.process_user ,"0644")
+ journal_iface.write(file ,"events {}\nhttp { server { listen 8080; } }\n")
--- /dev/null
+# A wrong argument: the journal interface raises an error, and no journal reaches the gate.
+
+def compose(prov ,journal_iface):
+ journal_iface.open("out" ,"unbound_conf" ,"overwrite" ,prov.process_user ,0o444)
--- /dev/null
+# create: allowed once; a second run is refused, as the file then exists.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out" ,"new.conf" ,"create" ,prov.process_user ,0o600)
+ journal_iface.write(file ,"made once\n")
--- /dev/null
+# One file the constraints allow, and one they do not. The whole journal is refused.
+
+def compose(prov ,journal_iface):
+ allowed = journal_iface.open("out" ,"unbound_conf" ,"replace_keep" ,prov.process_user ,0o444)
+ journal_iface.write(allowed ,"this is not to be written\n")
+ forbidden = journal_iface.open("out" ,"forbidden.conf" ,"replace" ,prov.process_user ,0o644)
+ journal_iface.write(forbidden ,"nor this\n")