From: Thomas Walker Lynch Date: Fri, 6 Nov 2026 06:35:45 +0000 (+0000) Subject: . X-Git-Url: https://git.reasoningtechnology.com/%27%20%20%20window.RT.dirpr_library%20%20%20%27/%27%20%2Chtml.escape%28urllib.parse.quote%28picture_name%29%20%2Cquote=True%29%20%2C%27?a=commitdiff_plain;h=d5d648dd5f14749670d6aad78fe51cc7f54aac88;p=Man_In_Grey . --- diff --git a/developer/authored/Man_In_Grey.copy/FileLanguage.py b/developer/authored/Man_In_Grey.copy/FileLanguage.py index 0a7bab1..3f102cf 100644 --- a/developer/authored/Man_In_Grey.copy/FileLanguage.py +++ b/developer/authored/Man_In_Grey.copy/FileLanguage.py @@ -19,23 +19,22 @@ 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. +made, and raises JournalError on a wrong one. The interpreter half holds the +checks the guard makes of a journal received as data, and the execution the +interpreter carries out. 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 + gate_invoking_account account names allowed to invoke the gate with a journal + 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 +from Journal import JournalError ,Symbol ,has_control ,clean NAME = "file" @@ -52,15 +51,24 @@ ARGUMENT = { # argument checks, shared by both halves def directory_problem(value): - if not isinstance(value ,str) or not value.startswith("/") or "\x00" in value: + if not isinstance(value ,str) or not value.startswith("/"): return "directory is to be an absolute path" + if has_control(value): + return f"directory {clean(value)} holds a control character" 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" + if not isinstance(value ,str) or not value or "/" in value or value in ("." ,".."): + return f"name {clean(repr(value))} is not a bare file name" + if has_control(value): + return f"name {clean(value)} holds a control character" + return None + +def owner_problem(value): + if not isinstance(value ,str) or not value or has_control(value): + return "owner is to be an account name" return None def permission_value(value): @@ -94,7 +102,10 @@ class JournalInterface: path = Path(directory) if not path.is_absolute(): path = self._working_dpath / path - return os.path.normpath(str(path)) + value = os.path.normpath(str(path)) + problem = directory_problem(value) + if problem: raise JournalError(problem) + return value def open(self ,directory ,name ,mode ,owner ,permission): directory = self._directory(directory) @@ -102,8 +113,8 @@ class JournalInterface: 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") + problem = owner_problem(owner) + if problem: raise JournalError(f"open: {problem}") 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") @@ -135,19 +146,19 @@ def check_wellformed(data): 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 + problem_list.append(f"@{index}: unknown command {clean(repr(name))}"); continue if set(argument) != set(ARGUMENT[name]): - problem_list.append(f"@{index} {name}: arguments {sorted(argument)}, expected {sorted(ARGUMENT[name])}"); continue + problem_list.append(f"@{index} {name}: arguments {clean(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: unknown mode {clean(repr(argument['mode']))}") + if owner_problem(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") + problem_list.append(f"@{index} open: permission {clean(repr(argument['permission']))} is not valid") if name == "write": file = argument["file"] if not Symbol.is_data(file) or data["command_list"][file["symbol"]]["command"] != "open": @@ -156,13 +167,10 @@ def check_wellformed(data): problem_list.append(f"@{index} write: content is not bytes") return problem_list -def check_constraint(data ,constraint ,caller): +def check_constraint(data ,constraint ,gate_invoking_account): 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}") + if gate_invoking_account not in constraint.get("gate_invoking_account" ,()): + return [f"gate invoking account {gate_invoking_account!r} is not allowed to give journals to this gate"] allowed = {} for entry in constraint.get("file" ,()): allowed[(entry["directory"] ,entry["name"])] = entry @@ -173,7 +181,7 @@ def check_constraint(data ,constraint ,caller): 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 + problem_list.append(f"@{index} {name}: {where} is not a file the constraints allow"); continue if name == "unlink": if "unlink" not in entry.get("mode" ,()): problem_list.append(f"@{index} unlink: {where} is not allowed to be unlinked") @@ -194,7 +202,7 @@ def check_validity(data): try: pwd.getpwnam(owner) except KeyError: - problem_list.append(f"@{index} open: no account is named {owner!r}") + problem_list.append(f"@{index} open: no account is named {clean(owner)}") return problem_list def _regular_or_absent(dirfd ,name): @@ -236,10 +244,10 @@ def check_filesystem(data): return problem_list CHECK = ( - ("wellformed" ,lambda data ,constraint ,caller: check_wellformed(data)) + ("wellformed" ,lambda data ,constraint ,gate_invoking_account: check_wellformed(data)) ,("constraint" ,check_constraint) - ,("validity" ,lambda data ,constraint ,caller: check_validity(data)) - ,("filesystem" ,lambda data ,constraint ,caller: check_filesystem(data)) + ,("validity" ,lambda data ,constraint ,gate_invoking_account: check_validity(data)) + ,("filesystem" ,lambda data ,constraint ,gate_invoking_account: check_filesystem(data)) ) @@ -260,7 +268,8 @@ 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. + log is given a tag and a message for each thing done. Returns 0, or 1 on + an error. """ opened = {} # symbol index -> {dirfd ,fd ,tmp ,argument} try: @@ -281,7 +290,7 @@ def execute(data ,log): 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']}") + log("unlinked" ,f"{argument['directory']}/{argument['name']}") finally: os.close(dirfd) @@ -306,19 +315,19 @@ def execute(data ,log): 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}") + log("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 "")) + log("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}") + log("execution_failed" ,str(error)) return 1 finally: for record in opened.values(): diff --git a/developer/authored/Man_In_Grey.copy/Guard.py b/developer/authored/Man_In_Grey.copy/Guard.py new file mode 100644 index 0000000..7d89de8 --- /dev/null +++ b/developer/authored/Man_In_Grey.copy/Guard.py @@ -0,0 +1,41 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +Guard - the general guard of Man_In_Grey. + +The guard decides whether a journal can be executed. A command language +supplies its checks, in order, as CHECK; check() decodes the journal, runs +the checks, and stops at the first that finds problems. Every finding is +given to report as a tag and a message: _broken for each problem, +check_passed for each check that found none. + +This module is a library. A command language's guard program calls check() +and writes the verdict; see guard_file.py. +""" + +import Journal + + +def check(journal_bytes ,language ,constraint ,gate_invoking_account ,report): + """ + Given the encoded journal, the command language module, the constraints, + the gate invoking account's name, and report(tag ,message). Returns 0 when + every check passed, 1 when a check found problems, 2 when the journal + could not be read. + """ + try: + data = Journal.decode(journal_bytes) + except Journal.JournalError as error: + report("journal_unreadable" ,str(error)) + return 2 + if data["command_language"] != language.NAME: + report("wrong_language" ,f"the journal is in {data['command_language']!r}; this guard checks {language.NAME!r}") + return 1 + report("journal" ,f"{len(data['command_list'])} commands") + for name ,function in language.CHECK: + problem_list = function(data ,constraint ,gate_invoking_account) + for problem in problem_list: + report(f"{name}_broken" ,problem) + if problem_list: + return 1 + report("check_passed" ,name) + return 0 diff --git a/developer/authored/Man_In_Grey.copy/Interpreter.py b/developer/authored/Man_In_Grey.copy/Interpreter.py new file mode 100644 index 0000000..2cc9250 --- /dev/null +++ b/developer/authored/Man_In_Grey.copy/Interpreter.py @@ -0,0 +1,36 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +Interpreter - the general interpreter of Man_In_Grey. + +The interpreter executes a journal the guard has passed. It carries out +commands and does not judge them, with one exception: it checks again that +the journal is well formed, since it decodes the journal anyway, so that a +malformed journal never reaches execution. + +This module is a library. A command language's interpreter program calls +interpret(); see interpreter_file.py. +""" + +import Journal + + +def interpret(journal_bytes ,language ,log): + """ + Given the encoded journal, the command language module, and log(tag + ,message). Returns 0 when the journal was executed, 1 when execution + failed, 2 when the journal could not be read or is not well formed. + """ + try: + data = Journal.decode(journal_bytes) + except Journal.JournalError as error: + log("journal_unreadable" ,str(error)) + return 2 + if data["command_language"] != language.NAME: + log("wrong_language" ,f"the journal is in {data['command_language']!r}; this interpreter executes {language.NAME!r}") + return 2 + problem_list = language.check_wellformed(data) + for problem in problem_list: + log("wellformed_broken" ,problem) + if problem_list: + return 2 + return language.execute(data ,log) diff --git a/developer/authored/Man_In_Grey.copy/Journal.py b/developer/authored/Man_In_Grey.copy/Journal.py index c5c45d9..de83e7f 100644 --- a/developer/authored/Man_In_Grey.copy/Journal.py +++ b/developer/authored/Man_In_Grey.copy/Journal.py @@ -22,6 +22,19 @@ import cbor2 JOURNAL_VERSION = 2 +def is_control(character): + "C0 controls, DEL and C1 controls: the characters a gate refuses in what the guard and interpreter write" + code = ord(character) + return code < 0x20 or code == 0x7F or 0x80 <= code <= 0x9F + +def has_control(text): + return any(is_control(c) for c in text) + +def clean(text): + "text with each control character written as \\u{XXXX}, for a message that quotes a journal" + return "".join(f"\\u{{{ord(c):04X}}}" if is_control(c) else c for c in str(text)) + + class JournalError(Exception): """A journal interface call was given a wrong argument, or a journal is malformed.""" @@ -66,14 +79,11 @@ class Command: 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. + The commands of one run, in order, for one command language. """ - def __init__(self ,command_language ,principal=None): + def __init__(self ,command_language): self.command_language = command_language - self.principal = principal self.meta = {} self.command_list = [] self.composer = None @@ -90,8 +100,6 @@ class Journal: ,"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): @@ -121,8 +129,6 @@ def decode(journal_bytes): 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") diff --git a/developer/authored/Man_In_Grey.copy/Man_In_Grey.py b/developer/authored/Man_In_Grey.copy/Man_In_Grey.py index 2764b9d..85fce20 100755 --- a/developer/authored/Man_In_Grey.copy/Man_In_Grey.py +++ b/developer/authored/Man_In_Grey.copy/Man_In_Grey.py @@ -3,68 +3,60 @@ """ Man_In_Grey -Runs composers, 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. +The orchestrator. It runs composers, which place commands into one journal, +and invokes a gate with the journal on its standard input. The gate starts +the guard, which checks the journal against its constraints, and, when the +guard passes the journal, the interpreter, which executes it. A composer is a Python file 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 [option ...] - help | -h | --help print this message (also printed when given no arguments) - compose run the one composer in - orchestrate run every composer in , breadth first, then - by name; the layout of the directory carries no meaning + help | -h print this message (also printed when given no arguments) + orchestrate is a composer file, or a composer directory; every + file in a composer directory is a composer, and they run + depth first, in order of name at each level Options: - --input_acceptance orchestrate only: a Python file defining accept(prov), - true for each file that is a composer; by default every - file ending in .py whose name does not begin with a dot - --gate the gate to hand the journal to; by default - ../Man_In_Grey_file_gate/Man_In_Grey_file_gate - --constraints constraints for an unprivileged run, given to the - journal interpreter - --phase-0-then-stop stop after checking the path 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. + gate the gate to invoke; by default + ../Man_In_Grey_gate/Man_In_Grey_file_gate + constraints constraints for a run through a gate that is not setuid, + given to the guard + print print the journal after composing + compose_only stop after composing; nothing reaches the gate + check_only have the gate run the guard and not the interpreter + +Every composer, and every directory from / down to it, is to be owned by the +gate invoking account or by root, and writable by no other account. Symbolic +links in a composer directory are refused. + +Exit status: 0 when the run did what it was asked; 1 when the guard refused +the journal or a command failed to execute; 2 when the run could not start, +a composer failed, or the gate, guard or interpreter could not run. """ import sys ,os sys.dont_write_bytecode = True -import datetime ,getpass ,runpy ,stat ,subprocess ,traceback +import datetime ,pwd ,runpy ,stat ,subprocess ,traceback from pathlib import Path import Journal import FileLanguage -# The promoted layout: this file in Man_In_Grey/, beside Man_In_Grey_file_gate/. +# The promoted layout: this file in Man_In_Grey/, beside Man_In_Grey_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" +DEFAULT_GATE = HERE_DPATH.parent / "Man_In_Grey_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 = {"--input_acceptance" ,"--gate" ,"--constraints"} -COMMAND = ("compose" ,"orchestrate") +FLAG = ("print" ,"compose_only" ,"check_only") +VALUE = ("gate" ,"constraints") +COMMAND = ("orchestrate" ,) + + +def gate_invoking_account_name(): + return pwd.getpwuid(os.getuid()).pw_name # -------------------------------------------------------------------------------- @@ -72,7 +64,7 @@ COMMAND = ("compose" ,"orchestrate") class Provenance: """ - Where a composer is. Given to the acceptance filter and to compose(). + Where a composer is. Given to compose(). """ __slots__ = ( "composer_root_dpath" @@ -85,48 +77,77 @@ class Provenance: ) def __init__(self ,composer_root ,composer_path): - self.composer_root_dpath = composer_root.resolve() - self.composer_abs_fpath = composer_path.resolve() - self.composer_rel_fpath = self.composer_abs_fpath.relative_to(self.composer_root_dpath) - self.composer_dir_dpath = self.composer_abs_fpath.parent - name = self.composer_abs_fpath.name + self.composer_root_dpath = composer_root + self.composer_abs_fpath = composer_path + self.composer_rel_fpath = composer_path.relative_to(composer_root) + self.composer_dir_dpath = composer_path.parent + name = composer_path.name if name.endswith(".py"): name = name[:-3] self.implied_fname = name - self.process_user = getpass.getuser() + self.process_user = gate_invoking_account_name() 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)) - function = env.get("accept") - if not callable(function): - raise RuntimeError(f"{filter_path} defines no accept(prov)") - return function - -def composer_list(composer_root ,accept): - """The accepted files of the composer directory, breadth first, then by name. Symbolic links to directories are not followed.""" +# finding the composers, and the ownership check + +def trusted_owner_problem(path): + "None when path is owned by the gate invoking account or root and writable by no other account" + try: + st = os.lstat(path) + except OSError as error: + return f"{path}: {error.strerror}" + if st.st_uid not in (0 ,os.getuid()): + return f"{path} is owned by uid {st.st_uid}, neither root nor the gate invoking account" + if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + return f"{path} is writable by group or other accounts" + return None + +def chain_problem(path): + "the ownership check over path and every directory above it" + for each in [path] + list(path.parents): + problem = trusted_owner_problem(each) + if problem: + return problem + return None + +def composer_list(path): + """ + Given a composer file or a composer directory, absolute. Returns the + composers in the order they run, each as a Provenance. Raises RuntimeError + on a symbolic link, a file that is not regular, or a failed ownership check. + """ + problem = chain_problem(path) + if problem: + raise RuntimeError(f"refusing to run composers: {problem}") + if path.is_symlink(): + raise RuntimeError(f"{path} is a symbolic link") + if stat.S_ISREG(path.lstat().st_mode): + return [Provenance(path.parent ,path)] + if not path.is_dir(): + raise RuntimeError(f"{path} is neither a composer file nor a composer directory") found = [] - for dirpath ,dirnames ,filenames in os.walk(composer_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 - prov = Provenance(composer_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 walk(directory): + for entry in sorted(os.scandir(directory) ,key=lambda e: e.name): + each = Path(entry.path) + if entry.is_symlink(): + raise RuntimeError(f"{each} is a symbolic link; a composer directory holds composers and directories") + problem = trusted_owner_problem(each) + if problem: + raise RuntimeError(f"refusing to run composers: {problem}") + if entry.is_dir(follow_symlinks=False): + walk(each) + elif entry.is_file(follow_symlinks=False): + found.append(Provenance(path ,each)) + else: + raise RuntimeError(f"{each} is neither a file nor a directory") + walk(path) + return found + + +# -------------------------------------------------------------------------------- +# composing, and invoking the gate def compose_all(composer_root ,prov_list): """Run each composer into one journal. Raises RuntimeError naming the composer that failed.""" @@ -150,62 +171,36 @@ def compose_all(composer_root ,prov_list): journal.composer = None journal.meta = { "composed_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") - ,"process_user": getpass.getuser() + ,"process_user": gate_invoking_account_name() ,"host": os.uname().nodename - ,"composer_root": str(composer_root.resolve()) + ,"composer_root": str(composer_root) ,"composer_list": [p.composer_rel_fpath.as_posix() for p in prov_list] } return journal -def run(command ,path ,input_acceptance ,gate ,constraints ,flag ,stop_after): - path = Path(path) - if command == "compose": - if input_acceptance: - print("error: --input_acceptance is for orchestrate; compose runs the one file it is given" ,file=sys.stderr) - return 2 - if not path.is_file(): - print(f"error: the composer {path} is not a file" ,file=sys.stderr) - return 2 - composer_root = path.resolve().parent - else: - if not path.is_dir(): - print(f"error: {path} is not a directory" ,file=sys.stderr) - return 2 - composer_root = path +def orchestrate(path ,gate ,constraints ,flag): + path = Path(path).absolute() try: - 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 - if "--phase-0-then-stop" in flag: - print(f"phase 0: OK, {command} {path.resolve()}") - return 0 - - try: - if command == "compose": - prov_list = [Provenance(composer_root ,path)] - else: - prov_list = composer_list(composer_root ,accept) + prov_list = composer_list(path) + composer_root = path if path.is_dir() else path.parent journal = compose_all(composer_root ,prov_list) - except Exception as error: + except (OSError ,RuntimeError) as error: print(f"error: {error}" ,file=sys.stderr) return 2 - if "--phase-1-print" in flag: + if "print" in flag: journal.print() - if "--phase-1-then-stop" in flag: + if "compose_only" in flag: return 0 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 - command = [str(gate_path)] + command = [str(gate_path) ,"admit"] 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] + command += ["constraints" ,str(Path(constraints).resolve())] + if "check_only" in flag: + command.append("check_only") return subprocess.run(command ,input=journal.encode()).returncode @@ -224,8 +219,8 @@ def CLI(argv=None): print(f"Error: unknown command '{command}'." ,file=sys.stderr) print_help() return 2 - if not rest or rest[0].startswith("--"): - print(f"Error: {command} is given a {'file' if command == 'compose' else 'directory'} first." ,file=sys.stderr) + if not rest: + print(f"Error: {command} is given a composer file or a composer directory." ,file=sys.stderr) return 2 path ,rest = rest[0] ,rest[1:] value ,flag = {} ,set() @@ -239,14 +234,7 @@ def CLI(argv=None): else: print(f"Error: unknown or incomplete option '{word}'." ,file=sys.stderr) return 2 - 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 - return run( - command ,path ,value.get("--input_acceptance") ,value.get("--gate") ,value.get("--constraints") - ,flag ,stop[0] if stop else None - ) + return orchestrate(path ,value.get("gate") ,value.get("constraints") ,flag) if __name__ == "__main__": diff --git a/developer/authored/Man_In_Grey.copy/constraints_file.json b/developer/authored/Man_In_Grey.copy/constraints_file.json index ff967c3..134b3c9 100644 --- a/developer/authored/Man_In_Grey.copy/constraints_file.json +++ b/developer/authored/Man_In_Grey.copy/constraints_file.json @@ -1,5 +1,4 @@ { - "caller": [], - "principal_caller": [], + "gate_invoking_account": [], "file": [] } diff --git a/developer/authored/Man_In_Grey.copy/guard_file.py b/developer/authored/Man_In_Grey.copy/guard_file.py new file mode 100644 index 0000000..a40096a --- /dev/null +++ b/developer/authored/Man_In_Grey.copy/guard_file.py @@ -0,0 +1,149 @@ +#!/usr/bin/env -S python3 -B +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +guard_file - the guard of the file command language. + +Started by the gate Man_In_Grey_file_gate, under the interpreter account, +with the journal on standard input. It checks the journal against the +constraints, and says what it found on standard error, one line per +finding, as : ; the first two lines are name: and version:. + +When every check passes, it writes one line to standard output, + + guard file check passed + +and exits 0. That line and that status together are the only verdict the +gate admits a journal on. + +Its constraints are constraints_file.json beside it. Through a gate that +is not setuid, other constraints can be given with constraints , +since nothing then runs with more than the gate invoking account's own rights. + +Before it imports anything, it checks that its modules, its constraints +file and their directory are owned by root or by the interpreter account, +and writable by no other account. A module runs code when it is imported. + +Usage: guard_file [option ...] + help | -h print this message (also printed when given no arguments) + check check the journal on standard input +Options of check: + constraints constraints to use in place of constraints_file.json; refused + when the gate changed the account or the interpreter account is root + +Exit status: 0 when every check passed, 1 when a check refused the journal, +2 when the journal could not be checked. +""" + +import sys ,os +sys.dont_write_bytecode = True + +import json ,pwd ,stat +from pathlib import Path + +GUARD_NAME = "file" +VERSION = "1" + +HERE_DPATH = Path(__file__).resolve().parent +CONSTRAINT_FILE = HERE_DPATH / "constraints_file.json" +MODULE = ("Journal.py" ,"FileLanguage.py" ,"Guard.py" ,"guard_file.py") + + +def account_changed(): + return os.geteuid() != os.getuid() or os.getegid() != os.getgid() + +def trusted_owner_problem(path): + "None when path is owned by root or the interpreter account and writable by no other account" + try: + st = os.lstat(path) + except OSError as error: + return f"{path}: {error.strerror}" + if st.st_uid not in (0 ,os.geteuid()): + return f"{path} is owned by uid {st.st_uid}, neither root nor the interpreter account" + if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + return f"{path} is writable by group or other accounts" + return None + +def say(tag ,message): + print(f"{tag}: {message}" ,file=sys.stderr) + +if __name__ == "__main__" and sys.argv[1:2] == ["check"]: + say("name" ,GUARD_NAME) + say("version" ,VERSION) + +for each in [HERE_DPATH ,CONSTRAINT_FILE] + [HERE_DPATH / m for m in MODULE]: + problem = trusted_owner_problem(each) + if problem: + say("refused_to_start" ,problem) + sys.exit(2) + +try: + import Journal + import FileLanguage + import Guard +except ImportError as error: + say("cannot_start" ,str(error)) + sys.exit(2) + + +def report(tag ,message): + say(tag ,Journal.clean(message)) + +def load_constraint(given): + if given is not None: + if account_changed() or os.geteuid() == 0: + raise RuntimeError("constraints are refused when the gate changed the account or the interpreter account is root") + path = Path(given) + problem = trusted_owner_problem(path) + if problem: + raise RuntimeError(problem) + else: + path = CONSTRAINT_FILE + with open(path ,encoding="utf-8") as f: + constraint = json.load(f) + report("constraints" ,str(path)) + return constraint + +def check(given): + try: + constraint = load_constraint(given) + except (OSError ,ValueError ,RuntimeError) as error: + report("constraints_unusable" ,str(error)) + return 2 + gate_invoking_account = pwd.getpwuid(os.getuid()).pw_name + status = Guard.check(sys.stdin.buffer.read() ,FileLanguage ,constraint ,gate_invoking_account ,report) + if status == 0: + sys.stdout.write(f"guard {GUARD_NAME} check passed\n") + return status + + +# -------------------------------------------------------------------------------- +# 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() + command ,rest = argv[0] ,argv[1:] + if command != "check": + say("usage" ,f"unknown command {Journal.clean(repr(command))}; run guard_file with no arguments for its usage") + return 2 + given = None + i = 0 + while i < len(rest): + if rest[i] == "constraints" and i + 1 < len(rest): + given = rest[i + 1]; i += 2 + else: + say("usage" ,f"unknown or incomplete option {Journal.clean(repr(rest[i]))}") + return 2 + return check(given) + + +if __name__ == "__main__": + try: + sys.exit(CLI()) + except Exception as error: + say("failed" ,Journal.clean(f"{type(error).__name__}: {error}")) + sys.exit(2) diff --git a/developer/authored/Man_In_Grey.copy/interpreter_file.py b/developer/authored/Man_In_Grey.copy/interpreter_file.py new file mode 100644 index 0000000..b6586f4 --- /dev/null +++ b/developer/authored/Man_In_Grey.copy/interpreter_file.py @@ -0,0 +1,95 @@ +#!/usr/bin/env -S python3 -B +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +interpreter_file - the interpreter of the file command language. + +Started by the gate Man_In_Grey_file_gate, under the interpreter account, +with the journal on standard input, once the guard has passed the journal. +It executes the journal's commands and writes one line to standard output +for each thing done, as : . + +It checks again that the journal is well formed before executing it. It +does not check the constraints; that is the guard's work, and the gate +starts the interpreter only on the guard's verdict. + +Before it imports anything, it checks that its modules and their directory +are owned by root or by the interpreter account, and writable by no other +account. + +Usage: interpreter_file + help | -h print this message (also printed when given no arguments) + execute execute the journal on standard input + +Exit status: 0 when the journal was executed, 1 when execution failed, +2 when the journal could not be read or is not well formed. +""" + +import sys ,os +sys.dont_write_bytecode = True + +import stat +from pathlib import Path + +HERE_DPATH = Path(__file__).resolve().parent +MODULE = ("Journal.py" ,"FileLanguage.py" ,"Interpreter.py" ,"interpreter_file.py") + + +def trusted_owner_problem(path): + "None when path is owned by root or the interpreter account and writable by no other account" + try: + st = os.lstat(path) + except OSError as error: + return f"{path}: {error.strerror}" + if st.st_uid not in (0 ,os.geteuid()): + return f"{path} is owned by uid {st.st_uid}, neither root nor the interpreter account" + if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + return f"{path} is writable by group or other accounts" + return None + +def say(tag ,message): + print(f"{tag}: {message}" ,file=sys.stderr) + +for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]: + problem = trusted_owner_problem(each) + if problem: + say("refused_to_start" ,problem) + sys.exit(2) + +try: + import Journal + import FileLanguage + import Interpreter +except ImportError as error: + say("cannot_start" ,str(error)) + sys.exit(2) + + +def log(tag ,message): + print(f"{tag}: {Journal.clean(message)}" ,flush=True) + +def execute(): + return Interpreter.interpret(sys.stdin.buffer.read() ,FileLanguage ,log) + + +# -------------------------------------------------------------------------------- +# 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() + if argv != ["execute"]: + say("usage" ,f"unknown command {Journal.clean(repr(' '.join(argv)))}; run interpreter_file with no arguments for its usage") + return 2 + return execute() + + +if __name__ == "__main__": + try: + sys.exit(CLI()) + except Exception as error: + say("failed" ,Journal.clean(f"{type(error).__name__}: {error}")) + sys.exit(2) diff --git a/developer/authored/Man_In_Grey.copy/journal_interpreter.py b/developer/authored/Man_In_Grey.copy/journal_interpreter.py deleted file mode 100644 index cdca012..0000000 --- a/developer/authored/Man_In_Grey.copy/journal_interpreter.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- 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)) diff --git a/developer/authored/Man_In_Grey.copy/journal_interpreter_file.py b/developer/authored/Man_In_Grey.copy/journal_interpreter_file.py deleted file mode 100755 index 56109d4..0000000 --- a/developer/authored/Man_In_Grey.copy/journal_interpreter_file.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/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. On every run, -that file, this program, the modules it loads, and the directory holding -them are to be owned by root or by the interpreter account, the account -this program runs as, and writable by no other account, or it refuses to -run. 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; that file is checked the same way. - -Usage: journal_interpreter_file [--constraints ] [--print] [--stop-after ] - --constraints constraints to use in place of constraints_file.json; refused when the - gate changed the account or the interpreter account is root - --print print the journal as decoded - --stop-after 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 trusted_owner_problem(path): - "None when path is owned by root or the interpreter account and writable by no other account" - try: - st = os.lstat(path) - except OSError as error: - return f"{path}: {error.strerror}" - if st.st_uid not in (0 ,os.geteuid()): - return f"{path} is owned by uid {st.st_uid}, neither root nor the interpreter account" - if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH): - return f"{path} is writable by group or other accounts" - return None - -# Every file this program loads is checked before any of it is imported, on -# every run: a module runs code when it is imported. -for each in [HERE_DPATH ,CONSTRAINT_FILE] + [HERE_DPATH / m for m in MODULE]: - problem = trusted_owner_problem(each) - if problem: - print(f"error: refusing to run: {problem}" ,file=sys.stderr) - sys.exit(2) - -import journal_interpreter -import FileLanguage - - -def load_constraint(given): - if given is not None: - if privileged() or os.geteuid() == 0: - raise RuntimeError("--constraints is refused when the gate changed the account or the interpreter account is root") - path = Path(given) - problem = trusted_owner_problem(path) - if problem: - raise RuntimeError(f"refusing the constraints: {problem}") - 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()) diff --git a/developer/authored/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c b/developer/authored/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c deleted file mode 100644 index 7dd35c2..0000000 --- a/developer/authored/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c +++ /dev/null @@ -1,134 +0,0 @@ -/* - 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. The gate refuses to start it unless the interpreter, - and every directory above it, is owned by root or by the interpreter - account, and writable by no other account. The check is made on every run, - blessed or not. - - 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 -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#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 or by the interpreter - account, the effective uid, and writable by no other account -*/ -Local void check_trusted_owner(const char *path){ - uid_t interpreter_uid = geteuid(); - 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 && st.st_uid != interpreter_uid ) - die("refusing to run: %s is owned by uid %ld, neither root nor the interpreter account" ,prefix ,(long)st.st_uid); - if( st.st_mode & (S_IWGRP | S_IWOTH) ) - die("refusing to run: %s is writable by group or other accounts" ,prefix); - } - struct stat st; - if( lstat("/" ,&st) != 0 || st.st_uid != 0 ) die("refusing to run: / 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); - - check_trusted_owner(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 */ - 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)); -} diff --git a/developer/authored/Man_In_Grey_gate.make/Gate.lib.c b/developer/authored/Man_In_Grey_gate.make/Gate.lib.c new file mode 100644 index 0000000..7a47fbb --- /dev/null +++ b/developer/authored/Man_In_Grey_gate.make/Gate.lib.c @@ -0,0 +1,571 @@ +/* + Man_In_Grey_gate·Gate - the gate of a Man_In_Grey command language. + + A gate is a program of its own, made from this module by a three-line + *.CLI.c whose main calls Man_In_Grey_gate·Gate·run with three things fixed + at compile time: the guard's name, the guard's path and the interpreter's + path, the two paths relative to the gate itself. The gate invoking account can + name neither program; who invokes a gate cannot choose what it runs. + + Setuid and owned by the interpreter account, account B, a gate runs the + guard and the interpreter under account B while the real uid still names + the gate invoking account, account A. Not setuid, it runs them under + account A. + + admit reads the journal on standard input, then: + + 0. settles what the invoking program handed it: opens /dev/null on any + of descriptors 0, 1 and 2 left closed, closes every descriptor above + 2, and raises each resource limit that could stop the guard or the + interpreter partway, refusing when one stays too low; + 1. refuses unless the guard and the interpreter, and every directory + above them, are owned by root or by the interpreter account and + writable by no other account; + 2. clears the environment, sets the file mode mask to 077, marks the + process not dumpable, changes directory to /, and records the + gate invoking account in MIG_GATE_INVOKING_UID, MIG_GATE_INVOKING_GID and + MIG_GATE_INVOKING_NAME; + 3. starts the guard with the journal on its standard input, and admits + the journal only when the guard exits 0 having written exactly one + line to standard output: guard check passed; + 4. starts the interpreter with the same journal. + + Every line the guard and the interpreter write is copied to the person and + to the system log. A line that is not UTF-8, or holds a control character, + or is too long, or one line too many, breaks the gate's rules: the gate + stops the program and refuses. + + Exit status: 0 when the journal was admitted and executed, or admitted + with check_only; 1 when the guard refused it or execution failed; 2 when + the gate, guard or interpreter could not run, or broke the gate's rules. +*/ + +#ifndef Man_In_Grey_gate·Gate·ONCE +#define Man_In_Grey_gate·Gate·ONCE + +int Man_In_Grey_gate·Gate·run( + const char *guard_name + ,const char *guard_relative + ,const char *interpreter_relative + ,int argc + ,char **argv +); + +#ifdef Man_In_Grey_gate·Gate + + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + #include + + #define PYTHON "/usr/bin/python3" + #define JOURNAL_MAX ((size_t)16 << 20) + #define OUTPUT_LINE_MAX 4096 + #define OUTPUT_LINE_COUNT_MAX 2000 + #define VERDICT_MAX 512 + + Local const char *Guard_name = ""; + Local bool Logging = false; + + // ------------------------------------------------------------------------------ + // saying things: to the person on standard error, and to the system log + + Local void say(const char *tag ,const char *format ,...){ + char message[OUTPUT_LINE_MAX + 128]; + va_list ap; va_start(ap ,format); + vsnprintf(message ,sizeof message ,format ,ap); + va_end(ap); + fprintf(stderr ,"gate: %s: %s\n" ,tag ,message); + if( Logging ) syslog(LOG_NOTICE ,"%s gate: %s: %s" ,Guard_name ,tag ,message); + } + + Local void die(const char *format ,...){ + char message[OUTPUT_LINE_MAX]; + va_list ap; va_start(ap ,format); + vsnprintf(message ,sizeof message ,format ,ap); + va_end(ap); + say("cannot_run" ,"%s" ,message); + _exit(2); + } + + // ------------------------------------------------------------------------------ + // the ownership check + + Local bool account_changed(void){ + return geteuid() != getuid() || getegid() != getgid(); + } + + /* path and every directory above it: owned by root or by the interpreter + account, the effective uid, and writable by no other account */ + Local void check_trusted_owner(const char *path){ + uid_t interpreter_uid = geteuid(); + char prefix[PATH_MAX]; + size_t length = strlen(path); + if( length >= sizeof prefix ) die("path too long: %s" ,path); + struct stat st; + if( lstat("/" ,&st) != 0 || st.st_uid != 0 ) die("/ is not owned by root"); + for(size_t i = 1; i <= length; ++i){ + if( i < length && path[i] != '/' ) continue; + memcpy(prefix ,path ,i); prefix[i] = '\0'; + if( lstat(prefix ,&st) != 0 ) die("cannot examine %s: %s" ,prefix ,strerror(errno)); + if( st.st_uid != 0 && st.st_uid != interpreter_uid ) + die("%s is owned by uid %ld, neither root nor the interpreter account" ,prefix ,(long)st.st_uid); + if( st.st_mode & (S_IWGRP | S_IWOTH) ) + die("%s is writable by group or other accounts" ,prefix); + } + } + + // ------------------------------------------------------------------------------ + // what the invoking program hands the gate + + /* Descriptors 0, 1 and 2 open, on /dev/null when the invoking program left + one closed, so a pipe the gate makes cannot land on one of them; every + other descriptor closed, so nothing the invoking program opened reaches + the guard or the interpreter. */ + Local void settle_descriptors(void){ + for(int fd = 0; fd <= 2; ++fd){ + if( fcntl(fd ,F_GETFD) >= 0 ) continue; + int null = open("/dev/null" ,O_RDWR); + if( null < 0 ) die("cannot open /dev/null: %s" ,strerror(errno)); + if( null != fd ){ + if( dup2(null ,fd) < 0 ) die("cannot open descriptor %d: %s" ,fd ,strerror(errno)); + close(null); + } + } + DIR *dir = opendir("/proc/self/fd"); + if( !dir ) die("cannot list /proc/self/fd: %s" ,strerror(errno)); + int own = dirfd(dir); + size_t count = 0 ,size = 64; + int *list = malloc(size * sizeof *list); + if( !list ) die("out of memory"); + struct dirent *entry; + while( (entry = readdir(dir)) ){ + char *end; + long fd = strtol(entry->d_name ,&end ,10); + if( *end || end == entry->d_name || fd <= 2 || fd == own ) continue; + if( count == size ){ + size *= 2; + int *larger = realloc(list ,size * sizeof *list); + if( !larger ) die("out of memory"); + list = larger; + } + list[count++] = (int)fd; + } + closedir(dir); + for(size_t i = 0; i < count; ++i) close(list[i]); + free(list); + } + + typedef struct{ + int resource; + const char *name; + rlim_t need; // RLIM_INFINITY when the limit is to be unlimited + } Limit; + + Local void say_limit(char *text ,size_t size ,rlim_t value){ + if( value == RLIM_INFINITY ) snprintf(text ,size ,"unlimited"); + else snprintf(text ,size ,"%llu" ,(unsigned long long)value); + } + + /* The invoking program sets the resource limits the gate starts with. A + small file size or CPU time limit would stop the interpreter partway + through a journal. Each limit is raised to what the gate needs, as far + as the hard limit allows, or past it when account B is root; the gate + refuses when a limit stays too low. */ + Local void settle_limits(void){ + const Limit limit_list[] = { + {RLIMIT_FSIZE ,"file size (bytes)" ,RLIM_INFINITY} + ,{RLIMIT_CPU ,"CPU time (seconds)" ,RLIM_INFINITY} + ,{RLIMIT_AS ,"address space (bytes)" ,(rlim_t)1 << 30} + ,{RLIMIT_DATA ,"data size (bytes)" ,(rlim_t)1 << 30} + ,{RLIMIT_STACK ,"stack size (bytes)" ,(rlim_t)8 << 20} + ,{RLIMIT_NOFILE ,"open files" ,64} + }; + for(size_t i = 0; i < sizeof limit_list / sizeof limit_list[0]; ++i){ + const Limit *limit = &limit_list[i]; + struct rlimit r; + if( getrlimit(limit->resource ,&r) != 0 ) die("cannot read the %s limit: %s" ,limit->name ,strerror(errno)); + if( r.rlim_cur >= limit->need ) continue; + if( geteuid() == 0 && r.rlim_max < limit->need ) r.rlim_max = limit->need; + r.rlim_cur = r.rlim_max < limit->need ? r.rlim_max : limit->need; + setrlimit(limit->resource ,&r); + if( getrlimit(limit->resource ,&r) != 0 ) die("cannot read the %s limit: %s" ,limit->name ,strerror(errno)); + if( r.rlim_cur < limit->need ){ + char have[32] ,want[32]; + say_limit(have ,sizeof have ,r.rlim_cur); + say_limit(want ,sizeof want ,limit->need); + die("the %s limit is %s; the gate needs %s" ,limit->name ,have ,want); + } + } + } + + // the program at a path relative to the gate's own directory, resolved + Local bool resolve(const char *relative ,char *resolved){ + char exe[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe" ,exe ,sizeof exe - 1); + if( n < 0 ) return false; + exe[n] = '\0'; + char candidate[PATH_MAX]; + if( snprintf(candidate ,sizeof candidate ,"%s/%s" ,dirname(exe) ,relative) >= (int)sizeof candidate ) return false; + return realpath(candidate ,resolved) != NULL; + } + + // a guard name: not empty, no space, no control character + Local bool name_is_valid(const char *name){ + if( !*name ) return false; + for(const unsigned char *p = (const unsigned char *)name; *p; ++p) + if( *p <= 0x20 || *p == 0x7F ) return false; + return true; + } + + // ------------------------------------------------------------------------------ + // the rules for what the guard and the interpreter write + + /* true when the line is UTF-8 holding no control character: none of C0, + DEL or C1, no overlong form, no surrogate */ + Local bool line_is_clean(const unsigned char *s ,size_t n){ + size_t i = 0; + while( i < n ){ + unsigned c = s[i]; + if( c < 0x80 ){ + if( c < 0x20 || c == 0x7F ) return false; + ++i; continue; + } + unsigned code; size_t length; + if( (c & 0xE0) == 0xC0 ){ code = c & 0x1F; length = 2; } + else if( (c & 0xF0) == 0xE0 ){ code = c & 0x0F; length = 3; } + else if( (c & 0xF8) == 0xF0 ){ code = c & 0x07; length = 4; } + else return false; + if( i + length > n ) return false; + for(size_t k = 1; k < length; ++k){ + unsigned d = s[i + k]; + if( (d & 0xC0) != 0x80 ) return false; + code = (code << 6) | (d & 0x3F); + } + if( (length == 2 && code < 0x80) || (length == 3 && code < 0x800) || (length == 4 && code < 0x10000) ) return false; + if( code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF) ) return false; + if( code >= 0x80 && code <= 0x9F ) return false; + i += length; + } + return true; + } + + typedef struct{ + int fd; + FILE *echo; // where its lines are copied for the person + const char *stream; // "stdout" or "stderr", for the log + char line[OUTPUT_LINE_MAX]; + size_t length; + } Stream; + + typedef struct{ + const char *source; // "guard" or "interpreter" + bool stdout_is_verdict; // the guard's standard output is kept, not shown + char verdict[VERDICT_MAX]; + size_t verdict_length; + size_t line_count; + const char *broken; // the rule broken, or NULL + } Child; + + Local void finish_line(Child *child ,Stream *stream){ + if( child->broken ) return; + if( !line_is_clean((unsigned char *)stream->line ,stream->length) ){ + child->broken = "a line that is not UTF-8 or holds a control character"; + return; + } + if( ++child->line_count > OUTPUT_LINE_COUNT_MAX ){ + child->broken = "more lines than the gate accepts"; + return; + } + stream->line[stream->length] = '\0'; + fprintf(stream->echo ,"%s: %s\n" ,child->source ,stream->line); + fflush(stream->echo); + syslog(LOG_NOTICE ,"%s %s: %s" ,Guard_name ,child->source ,stream->line); + stream->length = 0; + } + + Local void take(Child *child ,Stream *stream ,const char *bytes ,size_t n){ + for(size_t i = 0; i < n && !child->broken; ++i){ + if( bytes[i] == '\n' ){ finish_line(child ,stream); continue; } + if( stream->length == OUTPUT_LINE_MAX - 1 ){ child->broken = "a line longer than the gate accepts"; return; } + stream->line[stream->length++] = bytes[i]; + } + } + + Local void take_verdict(Child *child ,const char *bytes ,size_t n){ + if( child->verdict_length + n > VERDICT_MAX - 1 ){ child->broken = "more on standard output than a verdict"; return; } + memcpy(child->verdict + child->verdict_length ,bytes ,n); + child->verdict_length += n; + child->verdict[child->verdict_length] = '\0'; + } + + Local void close_on_exec(int fd){ + int flags = fcntl(fd ,F_GETFD); + if( flags < 0 || fcntl(fd ,F_SETFD ,flags | FD_CLOEXEC) < 0 ) die("cannot set close-on-exec: %s" ,strerror(errno)); + } + + /* Start PYTHON with the arguments, give it the input on its standard input, + and read its standard output and standard error by the gate's rules. + Returns the wait status. */ + Local int run_child(Child *child ,char *const argument[] ,const unsigned char *input ,size_t input_length){ + int in[2] ,out[2] ,err[2]; + if( pipe(in) || pipe(out) || pipe(err) ) die("cannot make a pipe: %s" ,strerror(errno)); + for(int k = 0; k < 2; ++k){ close_on_exec(in[k]); close_on_exec(out[k]); close_on_exec(err[k]); } + + pid_t pid = fork(); + if( pid < 0 ) die("cannot start the %s: %s" ,child->source ,strerror(errno)); + if( pid == 0 ){ + if( dup2(in[0] ,0) < 0 || dup2(out[1] ,1) < 0 || dup2(err[1] ,2) < 0 ) _exit(127); + execv(PYTHON ,argument); + dprintf(2 ,"cannot_start: %s: %s\n" ,PYTHON ,strerror(errno)); + _exit(127); + } + close(in[0]); close(out[1]); close(err[1]); + fcntl(in[1] ,F_SETFL ,fcntl(in[1] ,F_GETFL) | O_NONBLOCK); + + Stream out_stream = {.fd = out[0] ,.echo = stdout ,.stream = "stdout"}; + Stream err_stream = {.fd = err[0] ,.echo = stderr ,.stream = "stderr"}; + int in_fd = in[1]; + size_t written = 0; + if( input_length == 0 ){ close(in_fd); in_fd = -1; } + + while( (out_stream.fd >= 0 || err_stream.fd >= 0) && !child->broken ){ + struct pollfd poll_list[3]; + int count = 0 ,in_at = -1 ,out_at = -1 ,err_at = -1; + if( in_fd >= 0 ){ poll_list[count] = (struct pollfd){.fd = in_fd ,.events = POLLOUT}; in_at = count++; } + if( out_stream.fd >= 0 ){ poll_list[count] = (struct pollfd){.fd = out_stream.fd ,.events = POLLIN}; out_at = count++; } + if( err_stream.fd >= 0 ){ poll_list[count] = (struct pollfd){.fd = err_stream.fd ,.events = POLLIN}; err_at = count++; } + if( poll(poll_list ,count ,-1) < 0 ){ + if( errno == EINTR ) continue; + die("poll: %s" ,strerror(errno)); + } + if( in_at >= 0 && poll_list[in_at].revents ){ + ssize_t n = write(in_fd ,input + written ,input_length - written); + if( n > 0 ) written += (size_t)n; + if( (n < 0 && errno != EAGAIN && errno != EINTR) || written == input_length ){ close(in_fd); in_fd = -1; } + } + Stream *stream_list[2] = {&out_stream ,&err_stream}; + int at_list[2] = {out_at ,err_at}; + for(int k = 0; k < 2; ++k){ + Stream *stream = stream_list[k]; + if( at_list[k] < 0 || !poll_list[at_list[k]].revents ) continue; + char buffer[4096]; + ssize_t n = read(stream->fd ,buffer ,sizeof buffer); + if( n < 0 && (errno == EAGAIN || errno == EINTR) ) continue; + if( n <= 0 ){ + if( stream->length > 0 && !(stream == &out_stream && child->stdout_is_verdict) ) finish_line(child ,stream); + close(stream->fd); stream->fd = -1; + continue; + } + if( stream == &out_stream && child->stdout_is_verdict ) take_verdict(child ,buffer ,(size_t)n); + else take(child ,stream ,buffer ,(size_t)n); + } + } + + if( child->broken ) kill(pid ,SIGKILL); + if( in_fd >= 0 ) close(in_fd); + if( out_stream.fd >= 0 ) close(out_stream.fd); + if( err_stream.fd >= 0 ) close(err_stream.fd); + int status = 0; + while( waitpid(pid ,&status ,0) < 0 ){ + if( errno != EINTR ) die("waitpid: %s" ,strerror(errno)); + } + return status; + } + + // ------------------------------------------------------------------------------ + // the commands + + Local void print_usage(FILE *file ,const char *program){ + fprintf(file , + "Usage: %s [option ...]\n" + " help | -h print this message (also printed when given no arguments)\n" + " flags print the gate invoking account, the interpreter account, the guard\n" + " and the interpreter, and whether the gate changes the account\n" + " admit [option ...] read a journal on standard input, run the guard, and when the\n" + " guard passes the journal, run the interpreter\n" + "Options of admit:\n" + " constraints given to the guard\n" + " check_only run the guard and not the interpreter\n" + ,program + ); + } + + Local void print_flags(const char *guard_relative ,const char *interpreter_relative){ + struct passwd *pw = getpwuid(getuid()); + printf("gate_invoking.uid=%ld\n" ,(long)getuid()); + printf("gate_invoking.name=%s\n" ,pw ? pw->pw_name : "unknown"); + pw = getpwuid(geteuid()); + printf("interpreter.uid=%ld\n" ,(long)geteuid()); + printf("interpreter.name=%s\n" ,pw ? pw->pw_name : "unknown"); + printf("account_changed=%d\n" ,account_changed() ? 1 : 0); + printf("guard.name=%s\n" ,Guard_name); + char resolved[PATH_MAX]; + printf("guard.path=%s\n" ,resolve(guard_relative ,resolved) ? resolved : "(missing)"); + printf("interpreter.path=%s\n" ,resolve(interpreter_relative ,resolved) ? resolved : "(missing)"); + } + + Local unsigned char *read_journal(size_t *length){ + size_t size = 1 << 16; + unsigned char *journal = malloc(size); + if( !journal ) die("out of memory"); + *length = 0; + for(;;){ + if( *length == size ){ + if( size >= JOURNAL_MAX ) die("the journal is larger than %zu bytes" ,(size_t)JOURNAL_MAX); + size *= 2; + unsigned char *larger = realloc(journal ,size); + if( !larger ) die("out of memory"); + journal = larger; + } + ssize_t n = read(0 ,journal + *length ,size - *length); + if( n < 0 ){ + if( errno == EINTR ) continue; + die("cannot read the journal: %s" ,strerror(errno)); + } + if( n == 0 ) break; + *length += (size_t)n; + } + return journal; + } + + int Man_In_Grey_gate·Gate·run( + const char *guard_name + ,const char *guard_relative + ,const char *interpreter_relative + ,int argc + ,char **argv + ){ + Guard_name = guard_name; + const char *program = argc > 0 ? argv[0] : "gate"; + if( argc < 2 || !strcmp(argv[1] ,"help") || !strcmp(argv[1] ,"-h") || !strcmp(argv[1] ,"--help") ){ + print_usage(stdout ,program); + return 0; + } + if( !strcmp(argv[1] ,"flags") && argc == 2 ){ + print_flags(guard_relative ,interpreter_relative); + return 0; + } + if( strcmp(argv[1] ,"admit") ){ + fprintf(stderr ,"gate: usage: unknown command '%s'\n" ,argv[1]); + print_usage(stderr ,program); + return 2; + } + const char *constraints = NULL; + bool check_only = false; + for(int i = 2; i < argc; ++i){ + if( !strcmp(argv[i] ,"constraints") && i + 1 < argc ){ constraints = argv[++i]; continue; } + if( !strcmp(argv[i] ,"check_only") ){ check_only = true; continue; } + fprintf(stderr ,"gate: usage: unknown or incomplete option '%s'\n" ,argv[i]); + return 2; + } + + settle_descriptors(); + settle_limits(); + + if( !name_is_valid(guard_name) ) die("the guard name compiled into this gate is empty or holds a space or control character"); + char guard_path[PATH_MAX] ,interpreter_path[PATH_MAX]; + if( !resolve(guard_relative ,guard_path) ) die("the guard is missing: %s" ,guard_relative); + if( !resolve(interpreter_relative ,interpreter_path) ) die("the interpreter is missing: %s" ,interpreter_relative); + check_trusted_owner(guard_path); + check_trusted_owner(interpreter_path); + + uid_t gate_invoking_uid = getuid(); + gid_t gate_invoking_gid = getgid(); + struct passwd *pw = getpwuid(gate_invoking_uid); + char gate_invoking_name[256]; + snprintf(gate_invoking_name ,sizeof gate_invoking_name ,"%s" ,pw ? pw->pw_name : "unknown"); + + clearenv(); + setenv("PATH" ,"/usr/sbin:/usr/bin:/sbin:/bin" ,1); + setenv("LANG" ,"C.UTF-8" ,1); + char number[32]; + snprintf(number ,sizeof number ,"%ld" ,(long)gate_invoking_uid); setenv("MIG_GATE_INVOKING_UID" ,number ,1); + snprintf(number ,sizeof number ,"%ld" ,(long)gate_invoking_gid); setenv("MIG_GATE_INVOKING_GID" ,number ,1); + setenv("MIG_GATE_INVOKING_NAME" ,gate_invoking_name ,1); + umask(077); + prctl(PR_SET_DUMPABLE ,0 ,0 ,0 ,0); + if( chdir("/") != 0 ) die("cannot change directory to /"); + signal(SIGPIPE ,SIG_IGN); + + openlog("Man_In_Grey_gate" ,LOG_PID ,LOG_AUTHPRIV); + Logging = true; + + size_t journal_length; + unsigned char *journal = read_journal(&journal_length); + say("asked" ,"gate invoking account %s (uid %ld), journal of %zu bytes%s" + ,gate_invoking_name ,(long)gate_invoking_uid ,journal_length ,check_only ? ", check only" : ""); + + // the guard + char *guard_argument[] = { + (char *)PYTHON ,(char *)"-E" ,(char *)"-s" ,(char *)"-B" ,guard_path ,(char *)"check" + ,constraints ? (char *)"constraints" : NULL ,(char *)constraints ,NULL + }; + Child guard = {.source = "guard" ,.stdout_is_verdict = true}; + int status = run_child(&guard ,guard_argument ,journal ,journal_length); + if( guard.broken ){ + say("refused" ,"the guard broke the gate's rules: %s" ,guard.broken); + return 2; + } + char expected[VERDICT_MAX]; + snprintf(expected ,sizeof expected ,"guard %s check passed\n" ,guard_name); + if( !WIFEXITED(status) ){ + say("refused" ,"the guard was stopped by signal %d" ,WIFSIGNALED(status) ? WTERMSIG(status) : 0); + return 2; + } + if( WEXITSTATUS(status) == 1 ){ + say("refused" ,"by the guard"); + return 1; + } + if( WEXITSTATUS(status) != 0 ){ + say("refused" ,"the guard could not check the journal (exit status %d)" ,WEXITSTATUS(status)); + return 2; + } + if( strcmp(guard.verdict ,expected) ){ + say("refused" ,"the guard exited 0 without writing the one line 'guard %s check passed'" ,guard_name); + return 2; + } + say("admitted" ,"guard %s check passed" ,guard_name); + if( check_only ){ + say("not_executed" ,"check_only was asked"); + return 0; + } + + // the interpreter + char *interpreter_argument[] = { + (char *)PYTHON ,(char *)"-E" ,(char *)"-s" ,(char *)"-B" ,interpreter_path ,(char *)"execute" ,NULL + }; + Child interpreter = {.source = "interpreter" ,.stdout_is_verdict = false}; + status = run_child(&interpreter ,interpreter_argument ,journal ,journal_length); + if( interpreter.broken ){ + say("failed" ,"the interpreter broke the gate's rules: %s; what it had done stays done" ,interpreter.broken); + return 2; + } + if( !WIFEXITED(status) ){ + say("failed" ,"the interpreter was stopped by signal %d" ,WIFSIGNALED(status) ? WTERMSIG(status) : 0); + return 2; + } + switch( WEXITSTATUS(status) ){ + case 0: say("executed" ,"the journal was executed"); return 0; + case 1: say("failed" ,"execution failed"); return 1; + default: say("failed" ,"the interpreter could not run (exit status %d)" ,WEXITSTATUS(status)); return 2; + } + } + +#endif // Man_In_Grey_gate·Gate +#endif // Man_In_Grey_gate·Gate·ONCE diff --git a/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_file_gate.CLI.c b/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_file_gate.CLI.c new file mode 100644 index 0000000..31e62c6 --- /dev/null +++ b/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_file_gate.CLI.c @@ -0,0 +1,9 @@ +/* + Man_In_Grey_file_gate - the gate of the file command language: the general + gate with its guard's name and the guard's and interpreter's paths fixed. +*/ +#include "Gate.lib.c" + +int main(int argc ,char **argv){ + return Man_In_Grey_gate·Gate·run("file" ,"../Man_In_Grey/guard_file.py" ,"../Man_In_Grey/interpreter_file.py" ,argc ,argv); +} diff --git a/document/Man_In_Grey.html b/document/Man_In_Grey.html new file mode 100644 index 0000000..caeb436 --- /dev/null +++ b/document/Man_In_Grey.html @@ -0,0 +1,591 @@ + + + + + Man_In_Grey + + + + + + + + + + + + + + Purpose +

+ The Man_In_Grey tools were created to facilitate the controlled generation and installation of system configuration files, such as the files under /etc. 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. +

+

+ With the Man_In_Grey approach, an application running with the real and effective uid of account A composes a journal, and that journal is given to a program called the gate. The gate is owned by account B and setuid, so that when invoked it runs with the effective uid of account B. Internally the gate first invokes a program called the guard to approve the journal. If approved the gate invokes another program called the interpreter, which plays the journal. +

+

+ An application making use of Man_In_Grey can provide its own composer, guard, and interpreter. Consequently, each application that makes use of Man_In_Grey can have a different instruction language for the journal. Man_In_Grey merely provides the scaffolding. +

+

+ This document is both the design of Man_In_Grey and the user manual, as Man_In_Grey is a programmer's tool. +

+
+ + + Term + + + An application, as referred to in this manual, is any program that uses the Man_In_Grey tool. + + + A Python file defining compose(prov ,journal_iface), a function the orchestrator calls to place instructions into the journal. A composer usually stands for one configuration file, in place of a copy of it to install. + + + A directory containing composers. + + + The program Man_In_Grey orchestrate. It is given a composer file or a composer directory, runs the composers, all of them placing instructions into one journal, and invokes a gate with that journal on its standard input. An application can act as its own orchestrator, placing instructions through the journal interface and invoking the gate itself. + + + The ordered instructions the composers place, encoded as binary data in CBOR. + + + A journal entry. It consists of an instruction name followed by arguments. + + + In the sense of this manual, a symbol is a placeholder for an instruction result. Currently it is merely an index into the journal's instruction list. + + + A grammar that describes the available instructions, and for each instruction specifies the number of arguments it takes. + + + A compiled program that is invoked with a journal on its standard input, runs the guard on it, and when the guard approves the journal, runs the interpreter on it. + + + A program, started by the gate. It is given a journal, and either rejects it, or approves it by writing the one line guard <name> check passed and exiting 0. + + + Any semantic that participates in the guard code's approval process. + + + The program, started by the gate once the guard has approved the journal, that executes the journal's instructions in order. + + + The two user ids a process carries. The effective uid determines what the process is permitted to do, and the real uid names the account the process was started for. See . + + + For each program, the account of its effective uid. The gate has two: account A, the gate invoking account, of its real uid; and account B, of its effective uid, which is the gate owner account when the gate is setuid. See . + + + + + + Out-of-band signaling +

+ Man_In_Grey provides an alternative to templates. In template systems an output file, such as a configuration file, is generated by a blank filling program. With Man_In_Grey the output file is written by interpreting a journal, and the journal is written by a composer. +

+

+ There are problems with the method of filling in templates, for one it mixes data and control together: the content to be written, and the marks that say where to substitute. Thus templating is a form of in-band signaling. With in-band signaling, a content string can be identical to a control string, so data and control can alias, and there is no tight way to design the aliasing out. The typical workaround is to disallow control strings in the data, and to write each disallowed character as an escape sequence, where the escape is then itself control. Each level of nesting multiplies the escapes: an f-string that explains how to write f-strings escapes its braces has twice the number of escapes as the base f-string, and a document about this f-string escapes them again, to have 4 times the number, etc. +

+

+ 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. An instruction 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. (A developer is free to create a composer that fills in templates if that is what he really wants; but there is no inherent need to use this approach.) +

+

+ The journal is encoded in CBOR, whose strings are length-prefixed. Decoding CBOR makes maps, lists, strings and bytes, and cannot run code, so a journal reaches account B as data and nothing else. +

+
+ + + Symbol +

+ A composer writes instructions, but it does not run them. When an instruction would produce a result when run, as for example the file open instruction, which returns a file handle, the result does not exist yet at the time the composer runs. Hence, when an instruction is entered into the journal via the journal interface, the interface returns a symbol placeholder. The symbol is then used as an argument by the composer when it enters instructions that are given said result as an argument. In the current implementation a symbol is simply the index into the journal that locates an instruction. Thus, with the current implementation each instruction can have at most one result. +

+ + file = journal_iface.open("/etc/unbound" ,"unbound-US.conf" + ,"replace_keep" ,"root" ,0o644) + journal_iface.write(file ,"server:\n verbosity: 1\n") + +

+ In this example file is a result symbol. The value of file will be 0, i.e. the index into the journal of the open instruction. +

+
+ + + Account and uid +

+ Each account has a number, its user id, or uid. A file records the uid of the account that owns it, and a process carries two uids, its real uid and its effective uid. When a person logs in, the login program starts his shell with both uids set to that of his account, and every process started from the shell inherits them. In everyday speech such processes are said to be owned by the person, or to run under his account. As the two uids of a process can differ, this manual names the one it means, as in "the guard runs with the effective uid of account B". +

+

+ The effective uid determines a process's permissions. The kernel compares it with a file's owner and permission bits when the process opens the file, and only a process whose effective uid is 0, root's, can do what is reserved to root. The real uid names the account the process was started for. Among its other uses, the kernel lets a process send a signal to another process whose real uid equals its own real or effective uid. +

+

+ A process inherits both uids from the process that invoked it, unless the program file it runs has its setuid permission bit set. The kernel then takes the effective uid from the file's owner instead, and the real uid is inherited as before. A process therefore gets its effective uid in one of two ways: from the process that invoked it, or from the owner of a setuid program file. Groups work the same way, with a real gid, an effective gid and a setgid bit, beside the supplementary groups, which are inherited unchanged. +

+

+ By convention, the gate is the only program of Man_In_Grey ever given the setuid bit. For each of the other programs, the account of its effective uid is named after the program: the orchestrator account, the composer account, the guard account and the interpreter account. The gate has two accounts. Account A, the gate invoking account, is the account of the gate's real uid, which the gate inherits from the program that invoked it. It is that program's real uid, and usually its effective uid too. Account B is the account of the gate's effective uid. When the gate is setuid, account B is the gate owner account, the account that owns the gate's file. +

+

+ When the gate is not setuid, it inherits its effective uid from the invoking program as well, so accounts A and B are the same whenever that program's two uids are. They are also the same when the gate is setuid and owned by account A, as when a gate's owner invokes it. The gate starts the guard and the interpreter, and neither of their files is setuid, so both keep the gate's uids: the real uid of account A and the effective uid of account B. The guard account and the interpreter account are therefore account B. +

+

+ The guard knows accounts A and B for certain, as the kernel gives them to it as its real and effective uid. However, apart from knowing that account A provided the journal, it has no way of externally verifying the journal's provenance. Note the further discussion of this topic in . +

+ + + With an orchestrator +

+ Man_In_Grey's orchestrator calls each composer as a function in its own process, so the composers run with the orchestrator's uids, and the composer account is the orchestrator account. The orchestrator then invokes the gate, so account A is the account of the orchestrator's real uid. When the orchestrator is started from a person's shell, both of its uids are those of his account, and the orchestrator account, the composer account and account A are all his account. +

+
+ + + Without an orchestrator +

+ Man_In_Grey's orchestrator is one program that can invoke the gate, and any program can: an application acting as its own orchestrator, a shell pipeline, a service, or a command that sends a saved journal. All the gate knows is that a program whose real uid is account A's invoked it. +

+

+ Account A therefore answers for every journal it gives the gate. The guard cannot tell a journal from a careful composer from one typed by hand, copied from elsewhere, or made by code another account slipped into one of account A's programs. What bounds account A is everything its constraints allow, not what its composers happen to produce, so the constraints for each account are best written as narrowly as that account needs. +

+

+ Likewise, anything that can run code with account A's uid has account A's use of the gate. Keeping the composers to account A, as describes, is one part of keeping that code to account A; the other programs account A runs are the rest. +

+

+ The gate's own protections hold whatever program invokes it, since none of them depends on the caller. The invoking program sets more of the gate's starting state than its arguments, standard input and environment, though: it can leave file descriptors open, lower resource limits, and send signals. The gate settles the first two before anything else; signals it cannot stop. See and . +

+
+
+ + + Gate +

+ A setuid program owned by root runs with root's effective uid, while its real uid still names the account that invoked it. That is how passwd lets a person change his own password in /etc/shadow, and how it knows whose password to change. A setuid gate works the same way: owned by account B, it runs with the effective uid of account B and the real uid of account A. +

+

+ The gate is compiled because Linux ignores the setuid bit on a script started with #!: between the kernel opening the script and the interpreter reading it, the script could be swapped. Given no arguments, the gate prints its usage. Its command flags reports account A, account B, whether they differ, and the guard's name and the paths of the guard and the interpreter. Its command admit takes the journal on standard input, with the options constraints <file> and check_only. When the gate is asked to admit a journal, it: +

+
    +
  1. opens /dev/null on any of descriptors 0, 1 and 2 the invoking program left closed, and closes every descriptor above 2, so nothing the invoking program opened reaches the guard or the interpreter;
  2. +
  3. raises each resource limit that could stop the guard or the interpreter partway, file size and CPU time to unlimited, address space and data size to 1 GiB, stack size to 8 MiB, and open files to 64, as far as the hard limit allows, or past it when account B is root; and refuses when one stays too low;
  4. +
  5. finds the guard and the interpreter at paths fixed when the gate is compiled, relative to the gate itself, so account A cannot name others;
  6. +
  7. refuses unless the guard and the interpreter, and every directory above them, are owned by root or by account B, and writable by no other account;
  8. +
  9. clears the environment, setting only PATH and LANG, sets the file mode mask to 077, marks the process not dumpable, and changes directory to /;
  10. +
  11. records account A in MIG_GATE_INVOKING_UID, MIG_GATE_INVOKING_GID and MIG_GATE_INVOKING_NAME;
  12. +
  13. reads the journal from its standard input, up to 16 MiB;
  14. +
  15. starts /usr/bin/python3 -E -s -B on the guard, with the journal on its standard input, and goes on only when the guard approves the journal;
  16. +
  17. starts the interpreter the same way, with the same journal.
  18. +
+

+ The guard and the interpreter, before they import anything, each check that every module they load, and the guard its constraints file, are owned by root or by account B, and writable by no other account. A module runs code when it is imported, so the check comes first. +

+

+ These checks are made on every run, whether or not the gate is setuid, and whoever account A is. A check that can be got around by other means still catches mistakes, such as a group-writable directory or an interpreter run from the wrong tree. When accounts A and B are the same, account B is the person's own account, so his own files pass. +

+

+ The constraints, not the gate, decide which accounts can give a journal. The gate decides only whether the guard and the interpreter it would start can be trusted, and whether the guard approved the journal. +

+

+ A gate that is not setuid keeps the uids of the program that invoked it, so accounts A and B are normally the same, and the guard still applies. When they are the same, the guard accepts constraints given with constraints <file>, checked as its own files are, since nothing runs with more than account A's own rights. When they differ, or account B is root, the guard refuses them and reads only its own constraints file. A gate that is not setuid is the convenient way to use Man_In_Grey on files the person owns, and the way to prototype an instruction language before making a gate setuid for it. +

+ + One gate per instruction language +

+ Man_In_Grey's general gate is the library module Gate.lib.c, whose function Man_In_Grey_gate·Gate·run takes the guard's name, the guard's path and the interpreter's path, and the command line. An instruction language's gate is a *.CLI.c of three lines that calls it with the three fixed. The gate of the file language: +

+ + #include "Gate.lib.c" + + int main(int argc ,char **argv){ + return Man_In_Grey_gate·Gate·run("file" ,"../Man_In_Grey/guard_file.py" + ,"../Man_In_Grey/interpreter_file.py" ,argc ,argv); + } + +

+ The general gate is curried, with its first three arguments supplied when the gate is compiled. Whoever invokes a gate can choose neither program it runs; if he could, he could give it a guard that approves everything. The guard's name holds no space and no control character, and the gate refuses to run when it does. +

+
+ + Output rule +

+ Every line the guard and the interpreter write is copied to the person, marked guard: or interpreter:, and to the system log under the name Man_In_Grey_gate, beside the gate's own lines, marked gate:, saying who asked and what the gate decided. A line breaks the gate's rules when it is not UTF-8, or holds a control character (C0, DEL or C1), or is longer than 4096 bytes, or is one line more than 2000; the gate then stops the program and refuses the journal. Unicode is otherwise welcome, file names in Chinese included. The guard writes any control character it quotes as \u{XXXX}, and the wellformed check refuses a directory, file name or owner that holds one. +

+
+ + Least privilege, planned +

+ A setuid gate owned by root gives the interpreter every one of root's rights. Root's rights are a set of about forty separate capabilities, among them CAP_DAC_OVERRIDE, to ignore file permissions; CAP_CHOWN, to change owners; CAP_SYS_ADMIN, to mount; and CAP_SETUID, to become another uid. The design is to give each such gate only the capabilities its instruction language needs. +

+

+ setcap stores chosen capabilities in a program file's extended attributes. A process running that file holds them while its real and effective uid stay account A's. Capabilities are normally lost when a program execs 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 exec. A gate for the file language would then be able to write files and change owners, and nothing else. The gain is uneven: CAP_SYS_ADMIN, needed for mounting, is nearly root by itself. +

+
+ + Installation +

+ A setuid gate and everything its guard and interpreter load are to be owned by root or by account B, in directories no other account can write, and the gate and the interpreter check this. A Harmony consumer/ directory is owned by the person who promoted into it, so a setuid gate there refuses to run, and so does root running the gate there. Installing an instruction language's gate, guard and interpreter is a step of its own, done by root, or, when account B is not root, by root or by account B. The steps are in . +

+
+
+ + + Guard +

+ Accounts exist to limit what programs can do. The guard decides, before anything runs with the effective uid of account B, whether the work account A asks for is allowed: it reviews the journal and checks it against a set of constraints, and approves the journal only when every constraint is met. +

+

+ The kernel tells the gate which account invoked it, as the real uid, and the gate passes it on, so the guard knows it also. The kernel does not say which program invoked the gate. As the guard invocation is compiled into the gate, the guard can know the provenance of the journal from the point it was given to the gate, but not before that. +

+

+ Two checks are needed by nearly every guard, whatever its instruction language. The first is that the journal is well formed: that it decodes, names only instructions of its language, and gives each instruction the arguments it takes, of the right kinds. This check carries knowledge of the instruction syntax, so it belongs to the instruction language, and the interpreter makes it again before executing. The second is who account A is. The gate's real uid is account A's, and its real gid and supplementary groups are those it inherited from the invoking program, since the setuid and setgid bits change only the effective ids. All three pass to the guard, which reads them from the kernel, so account A cannot misstate them. A guard can therefore allow a journal from accounts its constraints list, or from the members of a group they name. The file language's guard checks account A by name; a check by group is not yet made. +

+

+ The other checks a guard makes belong to its instruction language. The file language's four checks are in . +

+ + Verdict +

+ A guard reads the journal on its standard input. Its standard error is for the person: one line per finding, as <tag>: <message>, the first two name: and version:, then such lines as check_passed: wellformed or constraint_broken: <what is wrong>. Its standard output is for the verdict alone. A guard approves a journal by writing exactly one line there, guard <name> check passed, with the name the gate has compiled in, and exiting 0. The gate takes the journal as approved on that line and that status together, and on nothing else, and only then admits it to the interpreter; a guard that refuses exits 1, and one that cannot check exits 2. +

+

+ The verdict travels out of band from the findings. A finding quotes the journal, a file name for instance, and goes to standard error, so nothing in a journal can put a line on standard output. A crash cannot approve a journal either: an uncaught Python exception exits 1, and a guard that dies leaves no verdict. +

+
+
+ + + Interpreter +

+ The gate starts the interpreter only after the guard has approved the journal, with the effective uid of account B and the same journal on its standard input. The interpreter does not check the constraints; that is the guard's work. It decodes the journal, checks once more that the journal is well formed, and then executes the instructions in order. +

+

+ The interpreter writes one line to standard output for each thing it does, as <tag>: <message>, and the gate copies each line to the person and to the system log by the output rule. Its exit status is 0 when the journal was executed, 1 when execution failed, and 2 when the journal could not be read or is not well formed. +

+

+ Execution stops at the first error. What the interpreter did before then stays done, except what its instruction language undoes; for the file language, see . +

+
+ + + Instruction language +

+ An instruction language is defined once and used from both sides. Its composer half is its journal interface: one function per instruction, which checks the call's arguments, raises an error on a wrong one, and places the instruction. Its other half is the checks the guard makes, the constraint check among them, and the code the interpreter runs for each instruction. Keeping both halves in one module keeps the two checks in step. +

+

+ The instruction 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. Each gate starts the guard and the interpreter of one instruction language, and they refuse a journal written in another. +

+ + + The file language +

+ Man_In_Grey's own instruction language is a constrained form of the POSIX file interface: +

+ + + Produces a symbol for the file. A relative directory is made absolute from the composer's working directory. permission is an integer such as 0o644, or three or four octal digits such as "0644". + + + Appends content, text or bytes, to the file an open produced. Text is encoded as UTF-8. + + + Removes a file. A file that is absent is not an error. + + + + + The file must not exist. + + + An existing file is discarded. + + + An existing file is kept as <name>.YYYYMMDDTHHMMSSZ, the time in UTC, with .1, .2 added when that name is taken. + + +

+ There is no close. Each opened file is written to a temporary file in its own directory, and after the journal's last instruction every opened file is given its owner and permission and put in place, each in one step: a rename for replace, a hard link that refuses an existing name for create, and for replace_keep 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. +

+

+ Execution stops at the first error, and every temporary file not yet put in place is removed. An unlink earlier in the journal has already happened by then. The interpreter writes a line for each thing it does, such as replaced: /etc/unbound/unbound-US.conf (kept as unbound-US.conf.20261105T083807Z). +

+

+ The guard checks a journal in four steps, each over every instruction, and stops at the first step that finds a problem. It reports each problem as <step>_broken and each step that passes as check_passed: +

+ + + Known instructions with exactly their arguments, of the right kinds; absolute directories in normal form; bare file names; no control character in a directory, file name or owner; every write naming the symbol of an open. + + + Account A is listed in gate_invoking_account; every file opened or unlinked is listed, in that mode, with that owner and permission. + + + Every owner names an account. + + + Every directory exists, opens, and passes through no symbolic link; no file is named by two instructions; a file opened in create mode does not exist; an existing file to be replaced or unlinked is a regular file. + + +

+ The constraints of the file language are a JSON file: +

+ + { + "gate_invoking_account": ["Thomas"], + "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"} + ] + } + +

+ mode lists the open modes allowed for the file, and unlink when it can be removed. gate_invoking_account lists the accounts allowed to be account A, giving journals to the gate. The constraints file installed with the interpreter lists no account and no file, so it refuses everything until it is replaced by the constraints the application developer wrote. +

+
+
+ + + Journal +

+ A journal is a CBOR map: +

+ + + 3. + + + The name of the instruction language, such as file. + + + When, where and by whom it was composed, and the composers that ran. + + + The instructions, each a map of name, argument, and composer. An argument that is a symbol is the map {"symbol": <index>}. + + +
+ + + Transport +

+ A journal reaches its interpreter by starting the interpreter's gate with the journal on standard input. Since a journal is data, it could travel other ways, such as over a Unix domain socket to an interpreter already running, which would learn account A through SO_PEERCRED. Man_In_Grey does not do this. It is the mechanism a program that receives requests is built with, not such a program itself. +

+
+ + + Using Man_In_Grey + + + Building +

+ Man_In_Grey is a Harmony project with two namespaces: Man_In_Grey.copy, the Python programs, and Man_In_Grey_gate.make, the general gate and the file language's gate. In the developer role, from its directory developer/, build "*" and promote write place them in the following layout, which is how each part finds the others: +

+ + consumer/Man_In_Grey/Man_In_Grey the command a person runs + consumer/Man_In_Grey/Man_In_Grey.py the orchestrator + consumer/Man_In_Grey/Journal.py symbols, instructions, the journal, CBOR + consumer/Man_In_Grey/FileLanguage.py the file language: interface, checks, execution + consumer/Man_In_Grey/Guard.py the general guard + consumer/Man_In_Grey/Interpreter.py the general interpreter + consumer/Man_In_Grey/guard_file.py the file language's guard + consumer/Man_In_Grey/interpreter_file.py the file language's interpreter + consumer/Man_In_Grey/constraints_file.json its constraints, refusing everything + consumer/Man_In_Grey_gate/Man_In_Grey_file_gate the file language's gate + consumer/Man_In_Grey_gate/libMan_In_Grey.a the general gate, for other gates + +

+ The same layout is staged in developer/scratchpad/made/, so an experiment in the developer role of the Man_In_Grey project runs from there. Both sides need the Python package cbor2, each in its own Python. Man_In_Grey, and any program using the journal interface, runs on the first python3 on PATH, which in a Harmony role is the project's linked Python when there is one; install the package into it with python3 -m pip install cbor2. The gate always starts /usr/bin/python3 -E -s, so the guard and the interpreter use neither a person's Python nor a copy installed with pip install --user into ~/.local. The system Python needs the package from the distribution, installed by root: python-cbor2 on Arch, python3-cbor2 on Debian, and on openSUSE, which names packages for the Python they are built for, python313-cbor2 for Python 3.13. To see what the interpreter will see: /usr/bin/python3 -E -s -c 'import cbor2; print(cbor2.__file__)'. +

+
+ + + Writing a composer +

+ Composers are kept in a composer directory, named composer in the examples here. Every file in it is a composer, a Python file defining compose(prov ,journal_iface); notes such as a README belong a directory up. Its subdirectories are namespaces for the author. They set the order the composers run in: depth first, in order of name at each level, files and subdirectories together, by Unicode code point, so capital letters come before small ones. A composer's place says nothing else; its open and unlink instructions say where its files go. +

+

+ A composer runs with the orchestrator's uids, which are normally account A's, and so with account A's rights. If another account could change a composer, or add one to a composer directory, that account's code would run with account A's rights, and its instructions would reach the gate in account A's name. Every composer, and every directory from / down to it, is therefore to be owned by account A or by root, and writable by no other account. +

+

+ This ownership protects account A, not account B, and the gate cannot enforce it. The gate is given a journal, never the program that made it, and anything it is told about the composers comes from account A. To the gate and the guard, every journal is account A's, whatever made it. Composer ownership is therefore a convention, kept by whoever installs an application's composers. Man_In_Grey's orchestrator keeps it for its own runs: before running any composer it makes the check, and it refuses a symbolic link in a composer directory. An application that acts as its own orchestrator keeps the convention itself. To run a composer someone else wrote, review it and copy it into your own composer directory. +

+ + # 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") + + + + The composer's own file name less .py, for a composer named for the file it writes. + + + The name of account A. + + + The composer directory, the composer's path absolute and relative to it, the directory holding the composer, and the working directory of the run. + + +

+ Man_In_Grey orchestrate <path> runs the one composer in a composer file, or every composer in a composer directory. No option given to Man_In_Grey reaches compose; a composer's settings are written in the composer itself. +

+
+ + + Running +

+ 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 guard check the journal, and the gate stop before the interpreter: +

+ + Man_In_Grey orchestrate composer print compose_only + Man_In_Grey orchestrate composer check_only + Man_In_Grey orchestrate composer + + + + Run the composer in a composer file, or every composer in a composer directory, and invoke the gate with the journal. + + + The gate to invoke. Default: ../Man_In_Grey_gate/Man_In_Grey_file_gate from Man_In_Grey.py. + + + Constraints for a run in which accounts A and B are the same, given to the guard; refused when they differ or account B is root. + + + Print the journal after composing. + + + Stop after composing; nothing reaches the gate. + + + Have the gate run the guard and not the interpreter. + + +

+ The command and its path come first, and the options follow, as plain words; given no arguments, Man_In_Grey prints its help, and so does the gate. The exit status is 0 when the run did what it was asked; 1 when the guard refused the journal or an instruction failed to execute; and 2 when the run could not start, a composer failed or was refused, or the gate, guard or interpreter could not run or broke the gate's rules. The guard's and interpreter's lines are also in the system log; on a system with journald, journalctl -t Man_In_Grey_gate shows them. To undo a replace_keep, rename the backup back. +

+
+ + + Installing a setuid gate +

+ As root, copy consumer/Man_In_Grey/ and consumer/Man_In_Grey_gate/ 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, put the application's constraints in constraints_file.json, and make the gate owned by account B and setuid. The kernel ignores the setuid and setgid bits on a file system mounted nosuid, as removable drives and some separately mounted /home directories are, so the directory is to be on a file system mounted without it. For root as account B: +

+ + chown root:root Man_In_Grey_gate/Man_In_Grey_file_gate + chmod 4755 Man_In_Grey_gate/Man_In_Grey_file_gate + Man_In_Grey_gate/Man_In_Grey_file_gate flags + +

+ 4755 is the mode 755 with the setuid bit, 4000, added. For an account B other than root, give the gate account B as its owner and account B's group as its group, and set the setgid bit, 2000, too: chmod 6755. Only root can give a file away, so an interpreter whose effective uid is that of an account B other than root can make files only account B owns, and its group has to be account B's group too. flags reports account A, account B, whether they differ, and the guard's name and the paths the gate finds the guard and the interpreter at. Promotion replaces files and sets neither the owner nor the setuid bit, 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. +

+
+ + + Testing +

+ The tester's test tester/authored/write_file runs the promoted Man_In_Grey through a gate that is not setuid, so accounts A and B are the same, with constraints it writes into its scratchpad directory, and checks twenty-five things. Two runs of composers using every mode and unlink are compared with expected/run_1 and expected/run_2, with backup times replaced by the letters YYYYMMDDTHHMMSSZ. A file opened in create 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. A composer directory holding a composer other accounts can write is refused by the orchestrator. A check_only run is approved by the guard and changes nothing. A run under a file size limit the gate cannot raise is refused before the guard starts. Each refusal leaves every file as it was. Since the gate checks ownership on every run, every directory from / down to the promoted interpreter is to be writable by no account but its owner. In the tester role, from its directory tester/: +

+ + authored/write_file/run test + authored/write_file/run clean + +
+
+ + + Known problem + + + The old compile script built the gate with -z relro, -z now and -fstack-protector-strong. Harmony's build has no flags for one namespace, so the gate is built without them. + + + Execution stops at the first error and removes the temporary files of files not yet put in place, but an unlink that ran earlier in the journal stays done. + + + The filesystem check and execution are separate, so a file can appear or change between them. Execution then fails on that instruction rather than acting on the change: a create whose file has appeared is refused by the hard link. + + + A process whose real or effective uid is account A's can send signals to the gate, the guard and the interpreter, whose real uid is account A's, and so can kill the interpreter partway through a journal. The file language puts its files in place only after the last instruction, which keeps the window small, but an unlink already done, or a file already put in place, stays as it is. + + + The gate checks the guard's verdict, not the guard's work: a guard that writes its approval line without reading the journal approves everything. The guard is installed code the application developer writes, and is to be tested as such. + + + The gate stops an interpreter whose output breaks its rules, but what the interpreter did before then stays done. + + + + + + Road map +
    +
  1. Gates holding only the capabilities their instruction language needs, in place of setuid root.
  2. +
  3. A language for installing programs, so that installing a gate, guard and interpreter is itself done through Man_In_Grey.
  4. +
  5. Later, interpreters in a compiled language in place of Python.
  6. +
+
+ + + Possible future feature + + Namespace in place of index symbol +

+ A symbol is now the index of the instruction that produces its value, so each instruction produces at most one value, and a value can be named only by a composer that holds its symbol. An alternative is to have instructions write their values into namespaces held by the journal interpreter, and later instructions read them by name. +

+

+ A namespace is a dictionary. An instruction that produces values is given the namespace and the keys to write them under, and a later instruction names a namespace and a key where it needs a value. A key can name another namespace, so namespaces form a tree with one root namespace; a value is then named by its path, such as ("unbound" ,"file"), as a file is named by its path. The namespace is the scope, so the journal needs no scoping model of its own beyond the tree. +

+

+ What the approach gives: +

+
    +
  • Several outputs from one instruction. An instruction can write each of its results under its own key, as a destructuring assignment does. An instruction that makes an account, for example, could give its uid, gid and home under three keys.
  • +
  • Values shared between composers. Composers run into one journal share no program variables, so one composer cannot hold another's symbol. A value written under a name one composer chooses can be read by another that knows the name.
  • +
  • A journal that reads by names. A printed journal would show what each value is, not only which instruction made it.
  • +
+

+ What the approach costs: the interpreter would hold state that instructions change, and the guard would have to know what a key holds at each point in the journal before anything runs. That stays as simple as it is with indices only under two rules: every key is written once, and every key is written before it is read. With those rules a path of keys can make its namespaces as it is first written, so no instruction for making namespaces is needed. Declarations, if they were wanted, would be instructions every instruction language carries. +

+

+ A smaller step gives the first advantage alone: a symbol that names a field of its instruction's value, as {"symbol": 3 ,"field": "uid"}, keeps index symbols and lets one instruction produce several values. +

+
+
+ + + Open question +
    +
  • Whether composers are given settings chosen when Man_In_Grey is run, such as a settings file every composer receives.
  • +
  • Whether constraints name files only exactly, as now, or also by pattern.
  • +
  • Where installed gates live, and how Man_In_Grey is told which gate a journal goes to.
  • +
+
+ + + Change from the first version +

+ 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, man_in_grey_apply, that accepted --inner <file> from its caller. That option let any account in the group sudo 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 which accounts can give journals into the constraints, raises an error in the composer on a wrong argument where the first version dropped the operation 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. +

+
+ +
+ + diff --git a/document/design_Man_In_Grey.html b/document/design_Man_In_Grey.html deleted file mode 100644 index e58187e..0000000 --- a/document/design_Man_In_Grey.html +++ /dev/null @@ -1,538 +0,0 @@ - - - - - Man_In_Grey: Design - - - - - - - - - - - - - - Purpose -

- The Man_In_Grey tools are used to facilitate the controlled generation and installation of system configuration files, such as the files under /etc. 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. -

-

- With the Man_In_Grey approach, each set of one or more configuration files is the work product of an unprivileged configuration composer program. When the composer program runs, it places commands into a journal. Later, a journal interpreter 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. -

-

- Nothing in this arrangement is particular to configuration files. Man_In_Grey is a general means for a program running as one account to compose changes that a program running as another account checks and then makes. Typically the first account is unprivileged and the second is root. 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. -

-
- - - What Man_In_Grey provides -
    -
  1. The journal interface, journal_iface, through which a composer makes a journal.
  2. -
  3. A structure for gating, checking and executing journals: a compiled gate that changes the account the work runs as, a guard that checks every command of a journal against constraints, and a general interpreter that executes the journal once the guard has found nothing wrong.
  4. -
-

- 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. -

-
- - - Terms - - - A program that places commands into a journal. It runs as the composer account. A composer can be a small Python file, run by Man_In_Grey compose, or one of a directory of them, run by Man_In_Grey orchestrate, or any program that uses the journal interface as a library, as the AccountTree server will. - - - The program that invokes the bespoke journal interpreter and gives it the journal: Man_In_Grey compose or Man_In_Grey orchestrate, which run composers in their own process, or an application such as the AccountTree server, which is both composer and orchestrator. - - - The ordered commands a composer places, encoded as data. Nothing is executed until the whole journal has been checked. - - - One entry of a journal: the command's name, its arguments, and the composer that placed it. - - - 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. - - - 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. - - - The declared rules of one bespoke journal interpreter: which orchestrator accounts can give 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. - - - The step that checks a journal against the constraints, before the general interpreter executes any of it. - - - The compiled program that starts a bespoke journal interpreter. A gate is blessed by making it owned by the interpreter account and setuid, so the interpreter runs as that account: root, or any other. A gate that has not been blessed, and so changes no uid, is a trivial gate. - - - A gate, a guard with its constraints, and the general interpreter, made for one application and accepting one command language. - - - The accounts the orchestrator, the composers, and the bespoke interpreter run as. See . - - - The account that asked for the work, when that is not the orchestrator account, as when an account asks the AccountTree server for a child account. - - - - - - Out-of-band signaling -

- The general method of filling in templates mixes data and control together: the content to be written, and the marks that say where to substitute. This method is therefore a form of in-band signaling. With in-band signaling, a content string can be identical to a control string, so data and control can alias, and there is no tight way to design the aliasing out. The typical workaround is to disallow control strings in the data, and to write each disallowed character as an escape sequence, where the escape is then itself control. 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. -

-

- 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. -

-

- 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. -

-
- - - Symbols -

- A composer writes commands; it does not run them. So when a command produces a value, such as the file an open opens, the value does not exist while the composer runs. The journal interface returns a symbol 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. -

- - file = journal_iface.open("/etc/unbound" ,"unbound-US.conf" ,"replace_keep" ,"root" ,0o644) - journal_iface.write(file ,"server:\n verbosity: 1\n") - -

- Here file is a symbol. In the journal it is the index of the open command, written {"symbol": 0}. 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. -

-
- - - Constraints and the guard -

- Accounts exist to limit what programs can do. Man_In_Grey keeps those limits while letting a program running as one account ask for work done as another: the bespoke journal interpreter runs as the interpreter account, 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. -

-

- 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 open. -

-

- Three rules make the guard dependable: -

-
    -
  • Check the whole journal, then execute. Every command is checked before the first is executed, so a journal that breaks a constraint anywhere changes nothing.
  • -
  • Keep the command language small. 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.
  • -
  • Check twice; the second check is the one that counts. 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 given by the orchestrator, and can have been made by anything.
  • -
-

- 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. Of who asked, it knows for certain only the orchestrator account; see . -

-
- - - Gates -

- 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 passwd lets a person change his own password in /etc/shadow. -

-

- The gate is compiled because Linux ignores the setuid bit on a script started with #!: 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: -

-
    -
  1. finds the interpreter at a path fixed when the gate is compiled, relative to the gate itself, so an orchestrator cannot name another;
  2. -
  3. refuses unless the interpreter, and every directory above it, is owned by root or by the interpreter account, and writable by no other account;
  4. -
  5. clears the environment, setting only PATH and LANG, sets the file mode mask to 077, marks the process not dumpable, and changes directory to /;
  6. -
  7. records the orchestrator account in MIG_ORCHESTRATOR_UID, MIG_ORCHESTRATOR_GID and MIG_ORCHESTRATOR_NAME;
  8. -
  9. starts /usr/bin/python3 -E -s -B on the interpreter, passing the journal on standard input.
  10. -
-

- 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 or by the interpreter account, and writable by no other account. A module runs code when it is imported, so the check comes first. -

-

- These checks are made on every run, through a trivial gate as well as a blessed one, and whoever the orchestrator account is. A check that can be got around by other means still catches mistakes, such as a group-writable directory or an interpreter run from the wrong tree. Through a trivial gate the interpreter account is the person's own, so his own files pass. -

-

- 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. -

-

- A trivial gate, one not blessed, changes no uid, so the interpreter account is the orchestrator account, and the guard still applies. The interpreter then accepts constraints given with --constraints, checked as its own files are, since nothing runs with more than the orchestrator account's own rights. When the gate changes the account, or the interpreter account is root, the interpreter refuses them and reads only its own 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 blessing a gate for it. -

- - Least privilege, planned -

- 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 CAP_DAC_OVERRIDE, to ignore file permissions; CAP_CHOWN, to change owners; CAP_SYS_ADMIN, to mount; and CAP_SETUID, to become another uid. The design is to give each gate only the capabilities its command language needs. -

-

- setcap 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 execs 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 exec. A gate for the file language would then be able to write files and change owners, and nothing else. The gain is uneven: CAP_SYS_ADMIN, needed for mounting, is nearly root by itself. -

-
- - Installation -

- A blessed gate and everything its interpreter loads are to be owned by root or by the interpreter account, in directories no other account can write, and the gate and the interpreter check this. A Harmony consumer/ directory is owned by the person who promoted into it, so a gate blessed there refuses to run, and so does root running the gate there. Installing a bespoke journal interpreter is a step of its own, done by root, or, when the interpreter account is not root, by root or by that account. -

-
-
- - - Command languages -

- 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. -

-

- 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. -

- - - The file language -

- Man_In_Grey's own command language is a constrained form of the POSIX file interface: -

- - - Produces a symbol for the file. A relative directory is made absolute from the composer's working directory. permission is an integer such as 0o644, or three or four octal digits such as "0644". - - - Appends content, text or bytes, to the file an open produced. Text is encoded as UTF-8. - - - Removes a file. A file that is absent is not an error. - - - - - The file must not exist. - - - An existing file is discarded. - - - An existing file is kept as <name>.YYYYMMDDTHHMMSSZ, the time in UTC, with .1, .2 added when that name is taken. - - -

- There is no close. 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 replace, a hard link that refuses an existing name for create, and for replace_keep 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. -

-

- Execution stops at the first error, and every temporary file not yet put in place is removed. An unlink earlier in the journal has already happened by then. -

-

- The interpreter checks a journal in four steps, each over every command, before executing any of it: -

- - - Known commands with exactly their arguments, of the right kinds; absolute directories in normal form; bare file names; every write naming the symbol of an open. - - - The guard: the orchestrator account is listed; a requesting account is named only by an orchestrator account listed as a relay; every file opened or unlinked is listed, in that mode, with that owner and permission. - - - Every owner names an account. - - - Every directory exists, opens, and passes through no symbolic link; no file is named by two commands; a file opened in create mode does not exist; an existing file to be replaced or unlinked is a regular file. - - -

- The constraints of the file language are a JSON file: -

- - { - "orchestrator_account": ["Thomas"], - "relay_account": [], - "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"} - ] - } - -

- mode lists the open modes allowed for the file, and unlink when it can be removed. orchestrator_account lists the accounts allowed to give journals, and relay_account those of them allowed to name a requesting account. The constraints file installed with the interpreter lists no account and no file, so it refuses everything until root writes its own. -

-
-
- - - Accounts -

- Three programs take part in a run, and each runs as an account: -

- - - The orchestrator runs as it. The gate learns it from the kernel, as its real uid, and the orchestrator cannot misstate it. It is the one account the guard knows for certain. - - - The composers run as it. Man_In_Grey runs composers in its own process, so its composer account is its orchestrator account. - - - The guard and the general interpreter run as it. A trivial gate leaves it the orchestrator account; a blessed gate makes it the gate's owner. - - -

- The point of Man_In_Grey is that the work is done as the interpreter account while the composers run as another, with only the journal passing between them, and the guard deciding what of it is done. Code that runs as the composer account has no rights it did not already have. The common case has an unprivileged composer account and root as the interpreter account, but Man_In_Grey is not particular to privilege: any two accounts serve. A gate blessed to an ordinary account lets one person's composers change, within the constraints, files only that account can write. -

- - The five arrangements -

- Each of the three accounts can be the same as another or different. The arrangements are the ways of grouping three things, of which there are five. (The 3! = 6 orderings of three things is a different count.) -

- - - The orchestrator runs the composers in its own process and invokes a trivial gate. The guard applies, and constraints can be given with --constraints. This is how a person uses Man_In_Grey on files he owns, how a command language is prototyped, and how the tester's test runs. When the one account is root, the composers have root's rights, and nothing stops one from changing files directly rather than through the journal; the guard checks only the journal. - - - The orchestrator runs the composers in its own process and invokes a blessed gate. The case Man_In_Grey is made for. The AccountTree server works this way, with its own account composing and root interpreting. - - - The orchestrator runs composers it does not trust as another account, and has the journal executed with its own rights through a trivial gate. The composers can do no harm while they run. - - - The composers have the rights the journal is executed with, so they could make the changes themselves; the guard protects nothing against them. Of little use. - - - The orchestrator runs composers as one account and has the journal executed as another, through a blessed gate. - - -

- Man_In_Grey as it stands gives arrangements 1 and 2, since its orchestrator runs composers in its own process. Arrangements 3 and 5 need an orchestrator that starts composers as another account, which takes root's rights or a gate of its own. -

-

- Whether an account is privileged is a separate matter from which accounts are the same. The guard needs only that the interpreter account can do what the orchestrator account asks of it, and the constraints say how much of that is allowed. -

-
- - The requesting account -

- When a person runs Man_In_Grey for himself, he asked for the work, and he is the orchestrator account. In AccountTree, an account asks the server for work, such as a child account, and the server composes the journal and gives it to the interpreter. The server's account is then the orchestrator account, and the account that asked is the requesting account, named in the journal. -

-

- The guard cannot verify a requesting account named in the journal; it can verify only that the orchestrator account is entitled to name one. So a journal naming a requesting account is accepted only from an orchestrator account the constraints list in relay_account. -

-

- How the requests reach the orchestrator, and how it learns who made them, belongs to the application. The requests are not a Man_In_Grey command language: they go to the orchestrator, and only the journal it composes goes to the gate. The AccountTree server learns who asked from the kernel through SO_PEERCRED, so the chain from the requesting account to the guard rests on the kernel at both ends, and on the account listed in relay_account between them. -

-
-
- - - The journal -

- A journal is a CBOR map: -

- - - 2. - - - The name of the command language, such as file. - - - Present when the work was asked for by an account other than the orchestrator account. - - - When, where and by whom it was composed, and the composers that ran. - - - The commands, each a map of command, argument, and composer. An argument that is a symbol is the map {"symbol": <index>}. - - -
- - - Transports -

- A journal reaches its interpreter by starting the interpreter's gate with the journal on standard input. Since a journal is data, it could travel other ways, such as over a Unix domain socket to an interpreter already running, which would learn the orchestrator account through SO_PEERCRED. Man_In_Grey does not do this. It is the mechanism a program that receives requests is built with, not such a program itself. -

-
- - - Using Man_In_Grey - - - Building -

- Man_In_Grey is a Harmony project with two namespaces: Man_In_Grey.copy, the Python programs, and Man_In_Grey_file_gate.make, the gate of the file language's interpreter. In the developer role, build "*" and promote write place them in the layout every part finds its partners by: -

- - 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 - -

- The same layout is staged in developer/scratchpad/made/, so a developer's experiment runs from there. Both sides need the Python package cbor2, each in its own Python. Man_In_Grey, and any program using the journal interface, runs on the first python3 on PATH, which in a Harmony role is the project's linked Python when there is one; install the package into it with python3 -m pip install cbor2. The gate always starts /usr/bin/python3 -E -s, so the interpreter uses neither a person's Python nor a copy installed with pip install --user into ~/.local. The system Python needs the package from the distribution, installed by root. openSUSE names the package for the Python it is built for, so for Python 3.13 it is python313-cbor2. To see what the interpreter will see: /usr/bin/python3 -E -s -c 'import cbor2; print(cbor2.__file__)'. -

-
- - - Writing composers -

- Composers are kept in a directory named, by Harmony's convention, composer, since every file in it is one. Each is a Python file defining compose(prov ,journal_iface). Its place in the directory carries no meaning; where its files go is said by its open and unlink commands. -

- - # 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") - - - - The composer's own file name less .py, for a composer named for the file it writes. - - - The name of the person who ran Man_In_Grey. - - - The composer directory, the composer's path absolute and relative to it, the directory holding the composer, and the working directory of the run. - - -

- Man_In_Grey compose <file> runs one composer. Man_In_Grey orchestrate <directory> runs every .py file in the directory whose name does not begin with a dot, breadth first and then by name; --input_acceptance names a Python file defining accept(prov) to choose otherwise. Nothing chosen when Man_In_Grey is run reaches compose; settings are written into the composer. -

-
- - - Running -

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

- - 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 - - - - Run the one composer in the file. - - - Run every composer in the directory. - - - The acceptance filter. - - - The gate to hand the journal to. Default: ../Man_In_Grey_file_gate/Man_In_Grey_file_gate from Man_In_Grey.py. - - - Constraints for a run through a trivial gate; refused when the gate changes the account or the interpreter account is root. - - - Stop after checking the path and the filter. - - - Print the journal after composing; stop after composing. - - - Print the journal as the interpreter decoded it; stop after decoding. - - - Stop after the named check, reporting OK when it passed. - - -

- The command and its path come first, and the options follow; given no arguments, Man_In_Grey 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 replace_keep, rename the backup back. -

-
- - - Installing a blessed interpreter -

- As root, copy consumer/Man_In_Grey/ and consumer/Man_In_Grey_file_gate/ 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 constraints_file.json, and bless the gate to the interpreter account. For root: -

- - 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 - -

- 4755 is the mode 755 with the setuid bit, 4000, added. For an interpreter account other than root, the gate is owned by that account and its group, and given the setgid bit, 2000, as well, as chmod 6755. Only root can give a file away, so an interpreter running as another account can make files only that account owns, and its group has to be that account's group too. --print-flags reports the real and effective uid, and whether the gate changes the account. 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. -

-
- - - Testing -

- The tester's test tester/authored/write_file 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 unlink are compared with expected/run_1 and expected/run_2, with backup times replaced by the letters YYYYMMDDTHHMMSSZ. A file opened in create 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. Since the gate checks ownership on every run, every directory from / down to the promoted interpreter is to be writable by no account but its owner. In the tester role: -

- - authored/write_file/run test - authored/write_file/run clean - -
-
- - - Known problems - - - The old compile script built the gate with -z relro, -z now and -fstack-protector-strong. Harmony's build has no flags for one namespace, so the gate is built without them. - - - Execution stops at the first error and removes the temporary files of files not yet put in place, but an unlink that ran earlier in the journal stays done. - - - 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 create whose file has appeared is refused by the hard link. - - - The journal and the guard carry a requesting account, but Man_In_Grey has no option to name one, and no test uses one. A program using the journal interface as a library sets it when it makes the journal. - - - When the interpreter fails to start, for example because the system Python lacks cbor2, Python exits with status 1, the status of a refused journal. It is to exit 2, with one line naming what is missing. - - - - - - Road map -
    -
  1. The journal interface used as a library by the AccountTree server, and AccountTree's command languages and their bespoke interpreters.
  2. -
  3. Gates holding only the capabilities their command language needs, in place of setuid root.
  4. -
  5. A language for installing programs, so that installing a bespoke interpreter is itself done through Man_In_Grey.
  6. -
  7. Later, interpreters in a compiled language in place of Python.
  8. -
-
- - - Open questions -
    -
  • Whether composers are given settings chosen when Man_In_Grey is run, such as a settings file every composer receives.
  • -
  • Whether the orchestrator runs composers as an account of their own, which arrangements 3 and 5 of need.
  • -
  • Whether constraints name files only exactly, as now, or also by pattern.
  • -
  • Where installed bespoke interpreters live, and how Man_In_Grey is told which gate a journal goes to.
  • -
-
- - - Changes from the first version -

- 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, man_in_grey_apply, that accepted --inner <file> from its caller. That option let any account in the group sudo 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 orchestrator accounts 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. -

-
- -
- - diff --git a/tester/authored/write_file/run b/tester/authored/write_file/run index 3aee789..4cc7c4a 100755 --- a/tester/authored/write_file/run +++ b/tester/authored/write_file/run @@ -4,8 +4,8 @@ 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: +promoted Man_In_Grey, from $REPO_HOME/consumer, through a gate that is not +setuid, with constraints it writes for the files it expects, and checks: run_1 orchestrate composer/: each composer's file written, and out/obsolete.conf, made by the test beforehand, unlinked; the @@ -13,12 +13,18 @@ with constraints it writes for the files it expects, and checks: run_2 orchestrate composer/ again: replace_keep files kept as backups named .YYYYMMDDTHHMMSSZ, the replace file overwritten with no backup; compared with expected/run_2 - create compose composer_create/new.py: a file opened in create mode is written once, and - a second run is refused by the filesystem check + create orchestrate composer_create/new.py: a file opened in create mode is written + once, and a second run is refused by the guard's filesystem check refused orchestrate composer_refused/: a journal opening one allowed file and one the - constraints do not allow is refused whole by the constraint check + constraints do not allow is refused whole by the guard's constraint check argument orchestrate composer_argument/: a composer giving a wrong mode fails when it places the command, and no journal reaches the gate + writable orchestrate a composer directory holding a composer other accounts can write: + the orchestrator refuses to run it + check_only orchestrate composer/ with check_only: the guard approves the journal, and the + interpreter is not run + limit orchestrate composer/ with a file size limit the gate cannot raise: the gate + refuses before starting the guard 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 @@ -33,7 +39,7 @@ Usage: run clean delete the test's scratchpad directory """ -import sys ,os ,re ,stat ,json ,shutil ,subprocess ,getpass +import sys ,os ,re ,stat ,json ,shutil ,subprocess ,getpass ,resource from pathlib import Path TEST_NAME = "write_file" @@ -143,9 +149,13 @@ def compare(log ,run_name ,work_dpath): 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 ,command ,path ,work_dpath ,constraint_fpath): - command = [str(program) ,command ,str(TEST_DPATH / path) ,"--constraints" ,str(constraint_fpath)] - result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True) +def limit_file_size(): + "run in the child before Man_In_Grey starts: a file size limit, soft and hard, of 1 MiB" + resource.setrlimit(resource.RLIMIT_FSIZE ,(1 << 20 ,1 << 20)) + +def run_Man_In_Grey(program ,path ,work_dpath ,constraint_fpath ,*option ,preexec=None): + command = [str(program) ,"orchestrate" ,str(path) ,"constraints" ,str(constraint_fpath) ,*option] + result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True ,preexec_fn=preexec) return result.returncode ,(result.stdout + result.stderr).strip() def check_status(log ,what ,status ,want ,output ,want_text=None): @@ -163,8 +173,7 @@ def check_status(log ,what ,status ,want ,output ,want_text=None): def write_constraint(work_dpath): person = getpass.getuser() constraint = { - "caller": [person] - ,"principal_caller": [] + "gate_invoking_account": [person] ,"file": [ {"directory": str(work_dpath / d) ,"name": n ,"mode": m ,"owner": person ,"permission": p} for d ,n ,m ,p in ALLOWED @@ -196,7 +205,7 @@ def test(repo_home): # run_1 and run_2 for run_name in ("run_1" ,"run_2"): - status ,output = run_Man_In_Grey(program ,"orchestrate" ,"composer" ,work_dpath ,constraint_fpath) + status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,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: @@ -204,28 +213,50 @@ def test(repo_home): return log.summary() # create: once, then refused - status ,output = run_Man_In_Grey(program ,"compose" ,"composer_create/new.py" ,work_dpath ,constraint_fpath) + status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,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 ,"compose" ,"composer_create/new.py" ,work_dpath ,constraint_fpath) - check_status(log ,"create again is refused by the filesystem check" ,status ,1 ,output ,"filesystem: refused") + status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath ,constraint_fpath) + check_status(log ,"create again is refused by the filesystem check" ,status ,1 ,output ,"filesystem_broken:") log.check(snapshot(out) == before ,"create again changes nothing") # a journal the constraints refuse before = snapshot(out) - status ,output = run_Man_In_Grey(program ,"orchestrate" ,"composer_refused" ,work_dpath ,constraint_fpath) - check_status(log ,"refused is refused by the constraint check" ,status ,1 ,output ,"constraint: refused") + status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_refused" ,work_dpath ,constraint_fpath) + check_status(log ,"refused is refused by the constraint check" ,status ,1 ,output ,"constraint_broken:") 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 ,"orchestrate" ,"composer_argument" ,work_dpath ,constraint_fpath) + status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_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") + # a composer other accounts can write + writable = work_dpath / "composer_writable" + writable.mkdir() + shutil.copy(TEST_DPATH / "composer" / "unbound_conf.py" ,writable / "unbound_conf.py") + os.chmod(writable / "unbound_conf.py" ,0o664) + before = snapshot(out) + status ,output = run_Man_In_Grey(program ,writable ,work_dpath ,constraint_fpath) + check_status(log ,"writable composer is refused by the orchestrator" ,status ,2 ,output ,"writable by group or other accounts") + log.check(snapshot(out) == before ,"writable changes nothing") + + # check_only + before = snapshot(out) + status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,constraint_fpath ,"check_only") + check_status(log ,"check_only is approved by the guard" ,status ,0 ,output ,"admitted: guard file check passed") + log.check(snapshot(out) == before ,"check_only changes nothing") + + # a resource limit the gate cannot raise + before = snapshot(out) + status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,constraint_fpath ,preexec=limit_file_size) + check_status(log ,"limit is refused by the gate" ,status ,2 ,output ,"the file size (bytes) limit is 1048576") + log.check(snapshot(out) == before ,"limit changes nothing") + return log.summary() def clean(repo_home):