From: Thomas Walker Lynch
Date: Sat, 7 Nov 2026 09:05:34 +0000 (+0000)
Subject: signal management appears to be working
X-Git-Url: https://git.reasoningtechnology.com/%27%20%20%20window.RT.dirpr_library%20%20%20%27/USER_NAME.png?a=commitdiff_plain;h=28426acdbccab335eab9fe233c330f9c967579f0;p=Man_In_Grey
signal management appears to be working
---
diff --git a/administrator/tool/skeleton_diff b/administrator/tool/skeleton_diff
index 973c4ae..5cf6ee9 100755
--- a/administrator/tool/skeleton_diff
+++ b/administrator/tool/skeleton_diff
@@ -18,6 +18,23 @@ def get_git_branch(repo_path: Path) -> str:
except (subprocess.CalledProcessError ,FileNotFoundError):
return "unknown_branch"
+def git_listed_files(repo_path: Path):
+ """
+ The files git sees in repo_path, relative to it: those it tracks, and those
+ not yet tracked that no .gitignore excludes. None when repo_path is not a
+ git repository, or git is not available.
+ """
+ try:
+ result = subprocess.run(
+ ["git" ,"-C" ,str(repo_path) ,"ls-files" ,"-z" ,"--cached" ,"--others" ,"--exclude-standard"],
+ capture_output=True ,check=True
+ )
+ except (subprocess.CalledProcessError ,FileNotFoundError):
+ return None
+ TM_names = [TM_n for TM_n in result.stdout.decode("utf-8" ,"surrogateescape").split("\0") if TM_n]
+ # a file deleted from the work tree but still in the index is not a file here
+ return [TM_n for TM_n in TM_names if (repo_path / TM_n).is_file()]
+
def format_perms(mode: int) -> str:
# Extract the 9-character permission string (e.g., rwxr-xr-x), dropping the leading file type char
return stat.filemode(mode)[1:]
@@ -26,8 +43,15 @@ def CLI(argv=None) -> int:
if argv is None:
argv = sys.argv[1:]
+ use_git = True
+ if argv[:1] == ["all_files"]:
+ use_git = False
+ argv = argv[1:]
+
if len(argv) != 2:
- print("Usage: skeleton_diff ")
+ print("Usage: skeleton_diff [all_files] ")
+ print(" Compares the files git sees in each repository: tracked files, and untracked")
+ print(" files no .gitignore excludes. all_files compares every file on disk instead.")
return 1
harmony_dir = Path(argv[0]).resolve()
@@ -78,23 +102,30 @@ def CLI(argv=None) -> int:
if path_obj.name.endswith("~"): return True
return False
- harmony_files = set()
- for TM_root ,TM_dirs ,TM_files in os.walk(harmony_dir):
- TM_dirs[:] = [TM_d for TM_d in TM_dirs if not is_ignored(Path(TM_d))]
- for TM_f in TM_files:
- f_path = Path(TM_root) / TM_f
- if not is_ignored(f_path):
- rel_path = f_path.relative_to(harmony_dir)
- harmony_files.add(str(rel_path))
-
- project_files = set()
- for TM_root ,TM_dirs ,TM_files in os.walk(project_dir):
- TM_dirs[:] = [TM_d for TM_d in TM_dirs if not is_ignored(Path(TM_d))]
- for TM_f in TM_files:
- f_path = Path(TM_root) / TM_f
- if not is_ignored(f_path):
- rel_path = f_path.relative_to(project_dir)
- project_files.add(str(rel_path))
+ def is_in_ignored_zone(rel: str) -> bool:
+ "true when any part of the relative path is ignored"
+ return any(is_ignored(Path(TM_part)) for TM_part in Path(rel).parts)
+
+ def walked_files(root: Path) -> set:
+ TM_found = set()
+ for TM_root ,TM_dirs ,TM_files in os.walk(root):
+ TM_dirs[:] = [TM_d for TM_d in TM_dirs if not is_ignored(Path(TM_d))]
+ for TM_f in TM_files:
+ f_path = Path(TM_root) / TM_f
+ if not is_ignored(f_path):
+ TM_found.add(str(f_path.relative_to(root)))
+ return TM_found
+
+ def listed_files(root: Path):
+ "the files to compare in root, and how they were found"
+ if use_git:
+ TM_listed = git_listed_files(root)
+ if TM_listed is not None:
+ return {TM_n for TM_n in TM_listed if not is_in_ignored_zone(TM_n)} ,"git"
+ return walked_files(root) ,"disk"
+
+ harmony_files ,harmony_source = listed_files(harmony_dir)
+ project_files ,project_source = listed_files(project_dir)
# Align the Opus file names for comparison
if project_opus in project_files:
@@ -150,6 +181,9 @@ def CLI(argv=None) -> int:
print(f" Harmony {harmony_branch}")
print(" vs")
print(f" {project_name} {project_branch}")
+ source_note = {"git": "the files git sees (.gitignore honored)" ,"disk": "every file on disk"}
+ print(f" Harmony files: {source_note[harmony_source]}")
+ print(f" {project_name} files: {source_note[project_source]}")
print("=" * 60)
print("\n1. 0pus File Audit")
diff --git a/developer/authored/Man_In_Grey.copy/EmplaceBioLanguage.py b/developer/authored/Man_In_Grey.copy/EmplaceBioLanguage.py
index e0db6cc..61dda8d 100644
--- a/developer/authored/Man_In_Grey.copy/EmplaceBioLanguage.py
+++ b/developer/authored/Man_In_Grey.copy/EmplaceBioLanguage.py
@@ -9,8 +9,8 @@ owns the directory, so a person cannot write there. He gives his page's
display name, text and picture as instructions, and the emplace_bio gate,
owned by the web server's account, places the page.
-A journal names no file. The file names come from the user name of the
-real uid, which the kernel gives the interpreter, and the directory comes
+A journal names no file. The file names come from the user name of
+account A, which the gate gives the interpreter, and the directory comes
from page_directory() at the top of the guard. The interpreter writes the
HTML itself, from a fixed form, and escapes every text it is given, so
nothing a person gives becomes markup. Decoration travels out of band, as a
@@ -36,7 +36,7 @@ guard_emplace_bio.py: is_allowed_account, is_allowed_text_size and
is_allowed_picture_size, and page_directory(), where the pages go.
"""
-import html ,os ,pwd ,stat ,urllib.parse
+import html ,os ,stat ,urllib.parse
from Journal import JournalError ,has_control ,clean
@@ -301,14 +301,14 @@ def _write_temporary(dirfd ,target ,content):
os.close(fd)
return tmp
-def execute(data ,log ,constraint):
+def execute(data ,log ,constraint ,gate_invoking_account):
"""
Write the picture and the page to temporary files in the directory, then
rename each into place, and remove a picture the page no longer shows.
log is given a tag and a message for each thing done. Returns 0, or 1 on
an error, removing every temporary file not yet put in place.
"""
- user_name = pwd.getpwuid(os.getuid()).pw_name
+ user_name = gate_invoking_account
directory = constraint.page_directory()
display_name ,paragraph_list ,picture = None ,[] ,None
for instruction in data["instruction_list"]:
diff --git a/developer/authored/Man_In_Grey.copy/Guard.py b/developer/authored/Man_In_Grey.copy/Guard.py
index 13db77b..cd2116c 100644
--- a/developer/authored/Man_In_Grey.copy/Guard.py
+++ b/developer/authored/Man_In_Grey.copy/Guard.py
@@ -8,19 +8,45 @@ 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. An instruction language's guard program calls check()
-and writes the verdict; see guard_emplace_bio.py.
+This module is a library. An instruction language's guard program calls
+check() and writes the verdict; see guard_emplace_bio.py.
"""
+import os ,pwd
+
import Journal
+def gate_invoking_account():
+ """
+ The user name of account A, the account that invoked the gate.
+
+ A setuid gate gives up account A as its real uid before it starts the
+ guard and the interpreter, so the kernel no longer says who account A is.
+ The gate records account A's uid and user name first, in MIG_REAL_UID and
+ MIG_REAL_USER_NAME, having cleared the environment, so what they say is
+ what the gate says. The two are checked against each other. Raises
+ RuntimeError when they are missing or disagree.
+ """
+ try:
+ uid = int(os.environ["MIG_REAL_UID"])
+ user_name = os.environ["MIG_REAL_USER_NAME"]
+ except (KeyError ,ValueError):
+ raise RuntimeError("MIG_REAL_UID and MIG_REAL_USER_NAME are not set; the guard and the interpreter are started by a gate")
+ try:
+ recorded = pwd.getpwuid(uid).pw_name
+ except KeyError:
+ raise RuntimeError(f"MIG_REAL_UID {uid} names no account")
+ if recorded != user_name:
+ raise RuntimeError(f"MIG_REAL_UID {uid} is the account {recorded!r}, not {user_name!r}")
+ return user_name
+
+
def check(journal_bytes ,language ,constraint ,gate_invoking_account ,report):
"""
Given the encoded journal, the instruction language module, its constraint
module of predicates, 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
+ ,message). Returns 0 when every check passed, 1 when a check found problems, 2 when the journal
could not be read.
"""
try:
diff --git a/developer/authored/Man_In_Grey.copy/Interpreter.py b/developer/authored/Man_In_Grey.copy/Interpreter.py
index 18368e4..8730dc8 100644
--- a/developer/authored/Man_In_Grey.copy/Interpreter.py
+++ b/developer/authored/Man_In_Grey.copy/Interpreter.py
@@ -14,11 +14,11 @@ interpret(); see interpreter_emplace_bio.py.
import Journal
-def interpret(journal_bytes ,language ,log ,constraint):
+def interpret(journal_bytes ,language ,log ,constraint ,gate_invoking_account):
"""
Given the encoded journal, the instruction language module, log(tag
- ,message), and the constraint module, which the language reads for what
- execution needs. Returns 0 when the journal was executed, 1 when
+ ,message), the guard program, whose constraints the language reads for
+ what execution needs, and the user name of account A. Returns 0 when the journal was executed, 1 when
execution failed, 2 when the journal could not be read or is not well
formed.
"""
@@ -35,4 +35,4 @@ def interpret(journal_bytes ,language ,log ,constraint):
log("wellformed_broken" ,problem)
if problem_list:
return 2
- return language.execute(data ,log ,constraint)
+ return language.execute(data ,log ,constraint ,gate_invoking_account)
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 2186782..2bd4653 100755
--- a/developer/authored/Man_In_Grey.copy/Man_In_Grey.py
+++ b/developer/authored/Man_In_Grey.copy/Man_In_Grey.py
@@ -14,15 +14,20 @@ gate's instruction language. The orchestrator learns the language from the
gate's flags, and loads the module named for it: emplace_bio from
EmplaceBioLanguage.py.
-Usage: Man_In_Grey gate [option ...]
+Usage: Man_In_Grey ...
help | -h print this message (also printed when given no arguments)
- orchestrate is a composer file, or a composer directory; every
+ orchestrate gate [option ...]
+ 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
- gate the gate to invoke, which also sets the instruction
- language the composers write in; required
-
-Options:
+ depth first, in order of name at each level; is the
+ gate to invoke, which also sets the instruction language
+ the composers write in
+ install owner [group ] into [constraints ]
+ install a gate setuid and setgid, by running install_gate
+ as root; when not run by root, su asks for root's password;
+ see install_gate for the rest
+
+Options of orchestrate:
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
@@ -39,7 +44,7 @@ a composer failed, or the gate, guard or interpreter could not run.
import sys ,os
sys.dont_write_bytecode = True
-import datetime ,importlib ,pwd ,re ,runpy ,stat ,subprocess ,traceback
+import datetime ,importlib ,pwd ,re ,runpy ,shlex ,stat ,subprocess ,traceback
from pathlib import Path
import Journal
@@ -233,10 +238,33 @@ def print_help():
print(__doc__.strip())
return 0
+# --------------------------------------------------------------------------------
+# installing a gate
+
+def install(argument_list):
+ """
+ Run install_gate install with the arguments given, as root. Run by root, it
+ runs install_gate itself; run by anyone else, it runs it through su, which
+ asks for root's password. A constraints file is made absolute first, as
+ root's shell starts elsewhere. Returns install_gate's exit status.
+ """
+ argument_list = list(argument_list)
+ for i ,word in enumerate(argument_list[:-1]):
+ if word == "constraints":
+ argument_list[i + 1] = str(Path(argument_list[i + 1]).absolute())
+ command = [str(HERE_DPATH / "install_gate") ,"install" ,*argument_list]
+ if os.geteuid() != 0:
+ print("install_gate is run by root; su asks for root's password" ,file=sys.stderr)
+ command = ["su" ,"root" ,"-c" ,shlex.join(command)]
+ return subprocess.run(command).returncode
+
+
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 == "install":
+ return install(rest)
if command not in COMMAND:
print(f"Error: unknown command '{command}'." ,file=sys.stderr)
print_help()
diff --git a/developer/authored/Man_In_Grey.copy/SmokeLanguage.py b/developer/authored/Man_In_Grey.copy/SmokeLanguage.py
index bc82634..2022119 100644
--- a/developer/authored/Man_In_Grey.copy/SmokeLanguage.py
+++ b/developer/authored/Man_In_Grey.copy/SmokeLanguage.py
@@ -254,7 +254,7 @@ def _backup_name(dirfd ,name):
return candidate
candidate ,n = f"{name}.{stamp}.{n}" ,n + 1
-def execute(data ,log ,constraint):
+def execute(data ,log ,constraint ,gate_invoking_account):
"""
Execute the instructions in order, then put each opened file in place. Stops
at the first error, removing every temporary file not yet put in place.
diff --git a/developer/authored/Man_In_Grey.copy/guard_emplace_bio.py b/developer/authored/Man_In_Grey.copy/guard_emplace_bio.py
index 963b90b..6cf6671 100644
--- a/developer/authored/Man_In_Grey.copy/guard_emplace_bio.py
+++ b/developer/authored/Man_In_Grey.copy/guard_emplace_bio.py
@@ -66,7 +66,7 @@ def page_directory():
import sys ,os
sys.dont_write_bytecode = True
-import pwd ,stat
+import stat
from pathlib import Path
LANGUAGE = "emplace_bio"
@@ -116,7 +116,11 @@ def report(tag ,message):
def check():
report("constraints" ,str(Path(__file__).resolve()))
- gate_invoking_account = pwd.getpwuid(os.getuid()).pw_name
+ try:
+ gate_invoking_account = Guard.gate_invoking_account()
+ except RuntimeError as error:
+ report("cannot_check" ,str(error))
+ return 2
status = Guard.check(sys.stdin.buffer.read() ,Language ,sys.modules[__name__] ,gate_invoking_account ,report)
if status == 0:
sys.stdout.write(f"guard {LANGUAGE} check passed\n")
diff --git a/developer/authored/Man_In_Grey.copy/guard_smoke.py b/developer/authored/Man_In_Grey.copy/guard_smoke.py
index 7790532..092ab59 100644
--- a/developer/authored/Man_In_Grey.copy/guard_smoke.py
+++ b/developer/authored/Man_In_Grey.copy/guard_smoke.py
@@ -62,7 +62,7 @@ def is_allowed_unlink(directory ,name):
import sys ,os
sys.dont_write_bytecode = True
-import pwd ,stat
+import stat
from pathlib import Path
LANGUAGE = "smoke"
@@ -112,7 +112,11 @@ def report(tag ,message):
def check():
report("constraints" ,str(Path(__file__).resolve()))
- gate_invoking_account = pwd.getpwuid(os.getuid()).pw_name
+ try:
+ gate_invoking_account = Guard.gate_invoking_account()
+ except RuntimeError as error:
+ report("cannot_check" ,str(error))
+ return 2
status = Guard.check(sys.stdin.buffer.read() ,Language ,sys.modules[__name__] ,gate_invoking_account ,report)
if status == 0:
sys.stdout.write(f"guard {LANGUAGE} check passed\n")
diff --git a/developer/authored/Man_In_Grey.copy/install_gate b/developer/authored/Man_In_Grey.copy/install_gate
index bf0d638..2a946e3 100755
--- a/developer/authored/Man_In_Grey.copy/install_gate
+++ b/developer/authored/Man_In_Grey.copy/install_gate
@@ -3,14 +3,19 @@
"""
install_gate - install a Man_In_Grey gate, setuid to the account that is to own it.
-Run by root, by the administrator who installs an application. It copies
-Man_In_Grey/, the directory beside this program, and the one gate named
-from Man_In_Grey_gate/ into a directory of root's, side by side, as the
-gate's compiled paths expect. Every file and directory it installs is
+Run by root, by the Man_In_Grey administrator, who installs an application.
+A person without root's rights runs it through Man_In_Grey install, which
+asks su for root's password.
+
+It copies Man_In_Grey/, the directory beside this program, and the one gate
+named from Man_In_Grey_gate/ into a directory of root's, side by side, as
+the gate's compiled paths expect. Every file and directory it installs is
owned by root and writable by no other account, except the gate, which is
-given to the owner account and made setuid: mode 4755 when the owner is
-root, and 6755, setuid and setgid, with the owner's group, for any other
-account. It then prints the gate's flags.
+given to the owner account and the group, and made setuid and setgid: mode
+6755. The group is the owner's own group unless group names
+another; the interpreter makes files with that group. When both are root's,
+the setgid bit changes nothing and the mode is 4755. It then prints the
+gate's flags.
A guard's constraints are the predicates written at its top, between the
lines '# constraints begin' and '# constraints end'. With constraints
@@ -26,13 +31,13 @@ the kernel ignores the setuid and setgid bits.
Usage: install_gate ...
help | -h print this message (also printed when given no arguments)
- install owner into [constraints ]
+ install owner [group ] into [constraints ]
is a gate's file name in Man_In_Grey_gate/, such as
Man_In_Grey_emplace_bio_gate; is a Python file holding
the guard's predicates
-Example, run from consumer/Man_In_Grey/:
- sudo ./install_gate install Man_In_Grey_emplace_bio_gate owner http into /usr/local/lib/Man_In_Grey constraints bio_constraint.py
+Example, run by root from consumer/Man_In_Grey/:
+ ./install_gate install Man_In_Grey_emplace_bio_gate owner http into /usr/local/lib/Man_In_Grey constraints bio_constraint.py
Exit status: 0 when the gate was installed, 1 when the installation was
refused, 2 when it was given wrong arguments.
@@ -134,13 +139,14 @@ def gate_flag(gate_fpath):
# --------------------------------------------------------------------------------
# installing
-def install(gate ,owner ,directory ,constraints=None):
+def install(gate ,owner ,directory ,constraints=None ,group=None):
"""
- Install the gate named gate, setuid to the account owner, into directory.
- Raises Refused.
+ Install the gate named gate into directory, owned by the account owner
+ and by group, or by owner's own group when group is None, and setuid and
+ setgid. Raises Refused.
"""
if os.geteuid() != 0:
- raise Refused("install_gate is run by root, as: sudo install_gate ...")
+ raise Refused("install_gate is run by root; a person without root's rights runs Man_In_Grey install, which asks for root's password")
if "/" in gate or gate in ("" ,"." ,".."):
raise Refused(f"{gate!r} is to be the file name of a gate in {SOURCE_DPATH / GATE_DNAME}")
source_gate = SOURCE_DPATH / GATE_DNAME / gate
@@ -150,6 +156,10 @@ def install(gate ,owner ,directory ,constraints=None):
account = pwd.getpwnam(owner)
except KeyError:
raise Refused(f"there is no account {owner!r}")
+ try:
+ gid = grp.getgrnam(group).gr_gid if group is not None else account.pw_gid
+ except KeyError:
+ raise Refused(f"there is no group {group!r}")
given_constraints = None
if constraints is not None:
given_constraints = Path(constraints).resolve()
@@ -216,11 +226,10 @@ def install(gate ,owner ,directory ,constraints=None):
say(f"put the constraints of {given_constraints} in {guard_path}")
# chown clears the setuid and setgid bits, so the mode is set after it
- os.chown(installed_gate ,account.pw_uid ,account.pw_gid)
- mode = 0o4755 if account.pw_uid == 0 else 0o6755
+ os.chown(installed_gate ,account.pw_uid ,gid)
+ mode = 0o4755 if account.pw_uid == 0 and gid == 0 else 0o6755
os.chmod(installed_gate ,mode)
- group = grp.getgrgid(account.pw_gid).gr_name
- say(f"installed {installed_gate}, owner {owner}:{group}, mode {mode:o}")
+ say(f"installed {installed_gate}, owner {owner}:{grp.getgrgid(gid).gr_name}, mode {mode:o}")
problem = root_chain_problem(program_dpath)
if problem:
@@ -243,14 +252,22 @@ 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 != "install":
- print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+ usage = "install owner [group ] into [constraints ]"
+ if command != "install" or not rest:
+ print(f"Error: {usage}" ,file=sys.stderr)
return 2
- if len(rest) not in (5 ,7) or rest[1] != "owner" or rest[3] != "into" or (len(rest) == 7 and rest[5] != "constraints"):
- print("Error: install owner into [constraints ]" ,file=sys.stderr)
+ gate ,rest = rest[0] ,rest[1:]
+ value = {}
+ while rest:
+ if len(rest) < 2 or rest[0] not in ("owner" ,"group" ,"into" ,"constraints") or rest[0] in value:
+ print(f"Error: {usage}" ,file=sys.stderr)
+ return 2
+ value[rest[0]] ,rest = rest[1] ,rest[2:]
+ if "owner" not in value or "into" not in value:
+ print(f"Error: {usage}" ,file=sys.stderr)
return 2
try:
- return install(rest[0] ,rest[2] ,rest[4] ,rest[6] if len(rest) == 7 else None)
+ return install(gate ,value["owner"] ,value["into"] ,value.get("constraints") ,value.get("group"))
except Refused as error:
print(f"install_gate: refused: {error}" ,file=sys.stderr)
return 1
diff --git a/developer/authored/Man_In_Grey.copy/interpreter_emplace_bio.py b/developer/authored/Man_In_Grey.copy/interpreter_emplace_bio.py
index cc605c8..6979b1a 100644
--- a/developer/authored/Man_In_Grey.copy/interpreter_emplace_bio.py
+++ b/developer/authored/Man_In_Grey.copy/interpreter_emplace_bio.py
@@ -34,7 +34,7 @@ import stat
from pathlib import Path
HERE_DPATH = Path(__file__).resolve().parent
-MODULE = ("Journal.py" ,"Interpreter.py" ,"Constraint.py" ,"EmplaceBioLanguage.py" ,"guard_emplace_bio.py" ,"interpreter_emplace_bio.py")
+MODULE = ("Journal.py" ,"Guard.py" ,"Interpreter.py" ,"Constraint.py" ,"EmplaceBioLanguage.py" ,"guard_emplace_bio.py" ,"interpreter_emplace_bio.py")
def trusted_owner_problem(path):
@@ -60,6 +60,7 @@ for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]:
try:
import Journal
+ import Guard
import Interpreter
import EmplaceBioLanguage as Language
import guard_emplace_bio as Guard_program
@@ -72,7 +73,12 @@ def log(tag ,message):
print(f"{tag}: {Journal.clean(message)}" ,flush=True)
def execute():
- return Interpreter.interpret(sys.stdin.buffer.read() ,Language ,log ,Guard_program)
+ try:
+ gate_invoking_account = Guard.gate_invoking_account()
+ except RuntimeError as error:
+ log("cannot_execute" ,str(error))
+ return 2
+ return Interpreter.interpret(sys.stdin.buffer.read() ,Language ,log ,Guard_program ,gate_invoking_account)
# --------------------------------------------------------------------------------
diff --git a/developer/authored/Man_In_Grey.copy/interpreter_smoke.py b/developer/authored/Man_In_Grey.copy/interpreter_smoke.py
index 9cff4f4..4d22080 100644
--- a/developer/authored/Man_In_Grey.copy/interpreter_smoke.py
+++ b/developer/authored/Man_In_Grey.copy/interpreter_smoke.py
@@ -34,7 +34,7 @@ import stat
from pathlib import Path
HERE_DPATH = Path(__file__).resolve().parent
-MODULE = ("Journal.py" ,"Interpreter.py" ,"Constraint.py" ,"SmokeLanguage.py" ,"guard_smoke.py" ,"interpreter_smoke.py")
+MODULE = ("Journal.py" ,"Guard.py" ,"Interpreter.py" ,"Constraint.py" ,"SmokeLanguage.py" ,"guard_smoke.py" ,"interpreter_smoke.py")
def trusted_owner_problem(path):
@@ -60,6 +60,7 @@ for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]:
try:
import Journal
+ import Guard
import Interpreter
import SmokeLanguage as Language
import guard_smoke as Guard_program
@@ -72,7 +73,12 @@ def log(tag ,message):
print(f"{tag}: {Journal.clean(message)}" ,flush=True)
def execute():
- return Interpreter.interpret(sys.stdin.buffer.read() ,Language ,log ,Guard_program)
+ try:
+ gate_invoking_account = Guard.gate_invoking_account()
+ except RuntimeError as error:
+ log("cannot_execute" ,str(error))
+ return 2
+ return Interpreter.interpret(sys.stdin.buffer.read() ,Language ,log ,Guard_program ,gate_invoking_account)
# --------------------------------------------------------------------------------
diff --git a/developer/authored/Man_In_Grey_gate.make/Gate.lib.c b/developer/authored/Man_In_Grey_gate.make/Gate.lib.c
index e762e2f..3e003cf 100644
--- a/developer/authored/Man_In_Grey_gate.make/Gate.lib.c
+++ b/developer/authored/Man_In_Grey_gate.make/Gate.lib.c
@@ -4,30 +4,40 @@
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 name of the instruction language, 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.
+ 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 account B, a gate runs with the effective uid of
+ Setuid and owned by account B, a gate starts with the effective uid of
account B and the real uid of account A, the gate invoking account. Not
- setuid, it keeps both uids of the program that invoked it. The guard and
- the interpreter keep the gate's two uids.
+ setuid, it keeps both uids of the program that invoked it.
- admit reads the journal on standard input, then:
+ admit:
- 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;
+ 0. settles what the invoking program handed it: puts every signal's
+ disposition back to its default, cancels the interval timers and
+ clears the blocked mask, so a signal already pending is delivered now;
+ opens /dev/null on any of descriptors 0, 1 and 2 left closed, and
+ 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 effective uid's 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 its real
- ids in MIG_REAL_UID, MIG_REAL_GID and MIG_REAL_USER_NAME;
- 3. starts the guard with the journal on its standard input, and admits
+ process not dumpable, changes directory to /, and records account A
+ in MIG_REAL_UID, MIG_REAL_GID and MIG_REAL_USER_NAME;
+ 3. reads the journal from standard input;
+ 4. isolates itself from account A, when the two accounts differ: makes
+ its real uid account B's, so the kernel refuses account A's signals,
+ and ignores the signals a terminal sends;
+ 5. 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.
+ 6. starts the interpreter with the same journal.
+
+ The guard and the interpreter inherit the gate's uids and its ignored
+ signals, and learn account A from MIG_REAL_UID and MIG_REAL_USER_NAME.
The constraints are code installed with the guard, so nothing about them is
given to the gate.
@@ -35,9 +45,10 @@
The gate logs each journal's course itself, whatever the guard and the
interpreter write: received, then approved or refused, then interpreting,
then interpreted or failed. Every line the guard and the interpreter
- write is copied to the person and to the system log as well. 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.
+ write is copied to the person and to the system log as well. 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
@@ -73,6 +84,7 @@ int Man_In_Grey_gate·Gate·run(
#include
#include
#include
+ #include
#include
#include
#include
@@ -134,6 +146,31 @@ int Man_In_Grey_gate·Gate·run(
// ------------------------------------------------------------------------------
// what the invoking program hands the gate
+ /* Signal state survives exec. The invoking program can leave a signal
+ ignored, leave signals blocked with some already pending, and leave an
+ interval timer running that will send SIGALRM, SIGVTALRM or SIGPROF
+ later. The kernel sends a timer's signal to the gate itself, with no
+ check of who set the timer, so any of these could act after the gate has
+ isolated itself from account A. The gate puts it all back to the state a
+ fresh process has: each disposition to its default, the timers off, the
+ mask empty. Clearing the mask delivers any signal already pending, now,
+ while the gate has done nothing that would need undoing. */
+ Local void settle_signals(void){
+ struct sigaction initial = {.sa_handler = SIG_DFL};
+ sigemptyset(&initial.sa_mask);
+ for(int signal_number = 1; signal_number < NSIG; ++signal_number){
+ if( signal_number == SIGKILL || signal_number == SIGSTOP ) continue; // never caught, never ignored
+ sigaction(signal_number ,&initial ,NULL); // refused, harmlessly, for numbers the C library keeps
+ }
+ struct itimerval off = {0};
+ setitimer(ITIMER_REAL ,&off ,NULL);
+ setitimer(ITIMER_VIRTUAL ,&off ,NULL);
+ setitimer(ITIMER_PROF ,&off ,NULL);
+ sigset_t empty;
+ sigemptyset(&empty);
+ sigprocmask(SIG_SETMASK ,&empty ,NULL);
+ }
+
/* 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
@@ -433,6 +470,37 @@ int Man_In_Grey_gate·Gate·run(
printf("interpreter.path=%s\n" ,resolve(interpreter_relative ,resolved) ? resolved : "(missing)");
}
+ // ------------------------------------------------------------------------------
+ // isolation from account A
+
+ /* The kernel lets a process signal another when the sender's real or
+ effective uid matches the target's real or saved uid. A setuid gate
+ starts with account A as its real uid, so account A can signal it,
+ SIGKILL and SIGSTOP included, which no handler can catch.
+
+ A process may set its real uid to its effective uid. Once the journal
+ is read, the gate does, and setreuid then sets the saved uid to account
+ B as well. With all three uids account B's, the kernel refuses account
+ A's signals before delivering anything. A terminal sends its signals to
+ the foreground process group without that check, so the gate ignores
+ them; the guard and the interpreter inherit both the uids and the
+ ignored signals.
+
+ When accounts A and B are the same there is nothing to isolate, and the
+ person can still interrupt his own run. Returns whether the gate
+ isolated itself. */
+ Local bool isolate_from_invoker(void){
+ uid_t account_B = geteuid();
+ if( getuid() == account_B ) return false;
+ if( setreuid(account_B ,account_B) != 0 ) die("cannot set the real uid to account B's: %s" ,strerror(errno));
+ if( getuid() != account_B || geteuid() != account_B ) die("the real uid did not become account B's");
+ int terminal_signal[] = {SIGINT ,SIGQUIT ,SIGTSTP ,SIGTTIN ,SIGTTOU ,SIGHUP};
+ for(size_t i = 0; i < sizeof terminal_signal / sizeof terminal_signal[0]; ++i){
+ signal(terminal_signal[i] ,SIG_IGN);
+ }
+ return true;
+ }
+
Local unsigned char *read_journal(size_t *length){
size_t size = 1 << 16;
unsigned char *journal = malloc(size);
@@ -486,6 +554,7 @@ int Man_In_Grey_gate·Gate·run(
return 2;
}
+ settle_signals();
settle_descriptors();
settle_limits();
@@ -520,6 +589,9 @@ int Man_In_Grey_gate·Gate·run(
unsigned char *journal = read_journal(&journal_length);
say("received" ,"real uid %ld (%s), effective uid %ld (%s), journal of %zu bytes%s"
,(long)real_uid ,real_user_name ,(long)geteuid() ,user_name(geteuid()) ,journal_length ,check_only ? ", check only" : "");
+ if( isolate_from_invoker() ){
+ say("isolated" ,"real uid now %ld (%s); account A can no longer signal the gate" ,(long)getuid() ,user_name(getuid()));
+ }
// the guard
char *guard_argument[] = {
diff --git a/document/Man_In_Grey.html b/document/Man_In_Grey.html
index 2206eb1..f19e94e 100644
--- a/document/Man_In_Grey.html
+++ b/document/Man_In_Grey.html
@@ -97,6 +97,9 @@
+
+ A third uid a process carries, which a setuid program starts with set to its file's owner, so that it can give up that uid for a while and take it back. See .
+
@@ -125,7 +128,7 @@
Each account has a number called its user id, or uid. Each file has an owner, noted with a uid. Each process is associated with 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 colloquial speech such processes are said to be owned by the person, or to run under his account. However, the two uids associated with a process can differ, so these colloquialisms are imprecise.
- The effective uid determines a process's permissions. So for example when opening a file, the kernel compares the process's effective uid with a file's owner and permission bits to determine if the process is allowed to open the file. 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.
+ The effective uid determines a process's permissions. So for example when opening a file, the kernel compares the process's effective uid with a file's owner and permission bits to determine if the process is allowed to open the file. Among its other uses, the kernel lets a process send a signal to another process whose real uid, or saved uid, equals its own real or effective uid; explains signals and the saved uid, and how the gate stops account A signaling it.
A process inherits both uids from the process that invoked it, unless the program file it runs has its setuid permission bit set. In which case, the kernel then takes the effective uid from the file's owner instead, while the real uid remains 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.)
@@ -265,13 +268,15 @@
+ - puts every signal's disposition back to its default, turns off the interval timers, and clears the blocked mask, so no signal state the invoking program left can act later;
- 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;
- 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;
- 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;
- 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;
- clears the environment, setting only PATH and LANG, sets the file mode mask to 077, marks the process not dumpable, and changes directory to /;
- - records its real ids, account A's, in MIG_REAL_UID, MIG_REAL_GID and MIG_REAL_USER_NAME;
+ - records its real ids, account A's, in MIG_REAL_UID, MIG_REAL_GID and MIG_REAL_USER_NAME, for the guard and the interpreter;
- reads the journal from its standard input, up to 16 MiB;
+ - when accounts A and B differ, makes its real uid account B's, so the kernel refuses account A's signals, and ignores the signals a terminal sends; see ;
- 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;
- starts the interpreter the same way, with the same journal.
@@ -301,7 +306,7 @@
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:. The gate logs each journal's course itself, whatever the guard and the interpreter write: received, with account A, account B and the journal's size; then approved or refused; then interpreting; then interpreted or failed. 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. The guard writes any control character it quotes as \u{XXXX}, and the example language's wellformed check refuses any text that holds one.
+ 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:. The gate logs each journal's course itself, whatever the guard and the interpreter write: received, with account A, account B and the journal's size; isolated, when the gate gives up account A; then approved or refused; then interpreting; then interpreted or failed. 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. The guard writes any control character it quotes as \u{XXXX}, and the example language's wellformed check refuses any text that holds one.
@@ -321,7 +326,7 @@
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. 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 the accounts it names, or from the members of a group, as its constraints decide.
+ 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. The second is who account A is. The gate had account A's uid from the kernel, as its real uid, and records it for the guard before giving it up, ; the real gid and supplementary groups the gate inherited from the invoking program stay account A's, and the guard reads them from the kernel. Account A cannot misstate any of them. A guard can therefore allow a journal from the accounts it names, or from the members of a group, as its constraints decide.
The other checks a guard makes belong to its instruction language. The example language's four checks are in .
@@ -377,6 +382,123 @@
+
+ Isolation from account A
+
+ The aim is that a journal be played as a transaction: either all of it takes effect, or none of it does, with nothing in between. Man_In_Grey is not there yet, since an interpreter can still fail partway, as discusses. But no interpreter can be transactional while account A can stop it partway, and until this design, account A could: by sending a signal to the gate, the guard or the interpreter, or by having arranged before the gate started for a signal to arrive later. This chapter explains signals, the uid that lets a setuid program give up account A, and what the gate does so that once it has the journal, account A can no longer interrupt it.
+
+
+
+ Signal
+
+ A signal is a small message the kernel delivers to a process: a number, such as SIGTERM, asking it to end, SIGINT, sent by Ctrl-C, or SIGKILL. One process sends a signal to another with the system call kill; the kernel also sends signals of its own, as when a timer expires or a process writes to a pipe nobody reads.
+
+
+ Sending a signal is two steps. First the kernel decides whether the sender may signal the target at all: a process may signal another when the sender's real or effective uid equals the target's real uid or its saved uid, described below, or when the sender is root. If neither holds, kill fails with EPERM, "Operation not permitted", and no signal exists. Only then, second, is the signal delivered, and the target's disposition for that signal decides what happens: the default action, which for most signals ends the process; ignoring it; or running a handler the program installed. Two signals, SIGKILL and SIGSTOP, can be neither handled nor ignored, so a handler can never protect a process from them. Only the first step can.
+
+
+ A setuid gate starts with account A as its real uid, so it passes the first step for account A. Account A could therefore end the gate, the guard or the interpreter with SIGKILL at any moment, the interpreter halfway through a journal included.
+
+
+
+
+ Saved uid
+
+ A process holds three uids, not two: real, effective, and saved. When a setuid program is executed, the kernel sets the effective uid to the file's owner and copies it into the saved uid; the real uid stays as inherited. A setuid gate owned by account B therefore starts holding account A as its real uid, and account B as its effective and saved uids.
+
+
+ A process may rearrange its uids among the ones it already holds, without privilege, but it cannot take a uid it does not hold. The call setreuid(real ,effective) may set the real uid to the current real or effective uid, and when it changes the real uid it sets the saved uid to the new effective uid. So a setuid gate may call setreuid(B ,B), making all three of its uids account B's. It gains nothing by this, since it already acts with account B's rights; it only gives up its connection to account A, and once given up, account A cannot be taken back. Only a process with CAP_SETUID, in practice root, may set a uid it does not hold, which is how login starts a shell under the account a person signed in to.
+
+
+ The saved uid exists so that a setuid program can drop its rights for a while and take them back: it sets its effective uid to its real uid, does some work on the person's behalf, and returns to the saved uid. Man_In_Grey uses the same rules the other way, to drop account A for good.
+
+
+
+
+ Isolating the gate
+
+ Once the gate has read the whole journal, and before it starts the guard, it makes its real uid account B's, as above. From then on account A matches neither the gate's real uid nor its saved uid, and the kernel refuses account A's signals at the first step, SIGKILL and SIGSTOP included. The guard and the interpreter are started after this, and inherit all three uids. The gate logs the moment:
+
+
+ gate: received: real uid 2006 (Thomas), effective uid 996 (http), journal of 1857 bytes
+ gate: isolated: real uid now 996 (http); account A can no longer signal the gate
+
+
+ Here is account A trying, after that line, to stop the example language's gate, installed setuid to http. The guard was slowed for the demonstration so there was time to try:
+
+
+ gate pid 693
+ Uid: 996 996 996 996
+ kill: (693) - Operation not permitted
+ kill: (693) - Operation not permitted
+ kill: (693) - Operation not permitted
+
+
+ The Uid line from /proc/693/status gives the real, effective, saved and file-system uids, all http's; the three refusals are SIGKILL, SIGSTOP and SIGTERM. The run went on to place the page.
+
+
+ Waiting until the journal is read costs nothing. Until then the gate has done nothing that would need undoing, so account A interrupting it earlier only stops a run that has not begun.
+
+
+
+
+ Signal state that outlives exec
+
+ Refusing account A's signals is not enough by itself, since account A could arrange beforehand for a signal to arrive after the switch. Three pieces of a process's signal state survive exec, so the program that invokes the gate can leave them behind:
+
+
+
+ A timer set with alarm or setitimer keeps running in the gate. When it expires, the kernel sends SIGALRM, SIGVTALRM or SIGPROF to the gate itself, with no question of who set the timer, so the switch does not stop it. Each ends the process by default: a timer set to expire partway through would end the interpreter there.
+
+
+ The invoking program can block a signal and send it, so it waits, pending. Both survive exec, and the signal would be delivered whenever it came to be unblocked.
+
+
+ A signal set to be ignored stays ignored. Ignoring SIGCHLD, for one, makes the kernel discard a child's exit status, so the gate could not learn how the guard ended.
+
+
+
+ The gate therefore settles its signal state first, before anything else, as it settles its descriptors and resource limits: it puts every signal's disposition back to its default, turns off the three interval timers, and clears the blocked mask. Clearing the mask delivers any signal already pending, then and there, while the gate has done nothing. A process that goes past its limit of CPU time or of file size is sent SIGXCPU or SIGXFSZ; the gate raises those limits at its start, and refuses to run when it cannot.
+
+
+ The test tester/authored/emplace_bio starts the gate from a process that leaves each kind behind: a timer set to send SIGALRM fifty milliseconds in, an ignored SIGCHLD, and a blocked, pending SIGTERM. With the gate settling its signals, the first two runs place the page, and the third ends at once, having changed nothing. With the settling step taken out, the timer ends the gate partway, the ignored SIGCHLD makes it refuse, and the pending SIGTERM stays blocked, unnoticed.
+
+
+
+
+ The terminal
+
+ A terminal sends signals of its own to the programs running in its foreground: SIGINT for Ctrl-C, SIGQUIT for Ctrl-\, SIGTSTP for Ctrl-Z, SIGTTIN and SIGTTOU when a program in the background reads it or, if the terminal is so set, writes it, and SIGHUP when it is closed. These come from the terminal driver, not from a process, and are not checked against uids. So when it isolates itself, the gate also ignores all six; the guard and the interpreter inherit that. No key on a terminal sends SIGKILL or SIGSTOP.
+
+
+ Pressing Ctrl-C, Ctrl-\ and Ctrl-Z during a run of the example ended the orchestrator, account A's own program, by SIGQUIT; the gate, the guard and the interpreter went on, and the page was placed. The gate also ignores SIGPIPE, so a reader of its output that has gone away does not end it; its lines still reach the system log.
+
+
+ One signal still reaches the gate from account A: the kernel lets any process in the same session send SIGCONT, whatever its uid. SIGCONT only resumes a stopped process, and account A can no longer stop the gate, so it does nothing.
+
+
+
+
+ Who account A is, afterwards
+
+ Once the gate has given up account A, the kernel no longer says who account A is: the guard and the interpreter find account B as their real uid. The gate records account A before giving it up, in MIG_REAL_UID, MIG_REAL_GID and MIG_REAL_USER_NAME, having first cleared the environment, so the variables say what the gate says and nothing account A put there. The guard and the interpreter read account A from them, and refuse to work when the uid and the user name do not name the same account. Knowledge of account A therefore rests on the gate, which had it from the kernel.
+
+
+ The gate gives up only the uid. Its real gid and supplementary groups stay account A's, so Constraint.has_real_group still answers for account A's groups.
+
+
+
+
+ What isolation does not give
+
+ - Before the journal is read, account A can still stop the gate. Nothing has been done, so nothing is lost.
+ - When accounts A and B are the same, there is nothing to isolate, and the gate does not; the person can interrupt his own run, which has no more than his own rights.
+ - Root can signal any process. Root is trusted.
+ - The interpreter can still fail partway on its own. Isolation removes account A as a cause, which a transactional play needs, but does not make the play transactional; see .
+
+
+
+
Using Man_In_Grey
@@ -479,19 +601,22 @@
Installing a setuid gate
- 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, the guard and the interpreter check this on every run. 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 therefore a step of its own, done by root.
+ 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, the guard and the interpreter check this on every run. 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 therefore a step of its own, done with root's rights by the Man_In_Grey administrator, the person who installs Man_In_Grey and the applications that use it.
- The installation is done with install_gate, which is promoted beside Man_In_Grey:
+ The installation is done by install_gate, which is promoted beside Man_In_Grey and is run by root. The administrator runs it through Man_In_Grey:
- sudo consumer/Man_In_Grey/install_gate install <gate> owner <account> into <directory> [constraints <file>]
+ consumer/Man_In_Grey/Man_In_Grey install <gate> owner <account> [group <group>] into <directory> [constraints <file>]
- It copies Man_In_Grey/ and the one gate named from Man_In_Grey_gate/ into the directory, side by side, as the gate's compiled paths expect. Every file and directory it installs is owned by root and writable by no other account, except the gate, which it gives to account B, the owner account named, and then makes setuid. The order matters, since changing a file's owner clears its setuid bit. For root as account B the gate's mode is 4755, the mode 755 with the setuid bit, 4000, added. For any other account B it is 6755, with the setgid bit, 2000, too, and account B's group as the gate's group. Only root can give a file away, so an interpreter whose effective uid is that of an account B other than root makes files account B owns, with the effective gid's group; setgid makes that account B's group.
+ Run by root, Man_In_Grey install runs install_gate directly. Run by any other account, such as a developer account without sudo, it runs it through su, which asks for root's password; it first makes a constraints file's path absolute, as root's shell starts in another directory. Root can also run install_gate install itself, with the same arguments. Either way the administrator runs, with root's rights, the install_gate and the files of the tree he names, so he is to install only a tree he has reviewed.
- With constraints <file>, a Python file of predicates, install_gate puts them between the constraint marks of the gate's guard. Without it, the constraints of a guard already installed are kept, so installing again brings a new guard and keeps the administrator's constraints; a first installation gets the guard's as promoted, which refuse every account. The constraints of every other installed guard are kept too. install_gate refuses a directory that is not absolute, that an account other than root owns or can write, or that is on a file system mounted nosuid, where the kernel ignores the setuid and setgid bits, as it does on removable drives and some separately mounted /home directories. It ends by printing the installed gate's flags, as root sees them: real uid root's, effective uid account B's. Promotion replaces files and sets neither the owner nor the setuid bit, so the installation is redone after each promotion that changes the gate, the guard or the interpreter. The installation is a candidate for Man_In_Grey itself, once there is a language for installing programs.
+ It copies Man_In_Grey/ and the one gate named from Man_In_Grey_gate/ into the directory, side by side, as the gate's compiled paths expect. Every file and directory it installs is owned by root and writable by no other account, except the gate, which it gives to account B, the owner account named, and to a group, and then makes setuid and setgid. The order matters, since changing a file's owner clears its setuid and setgid bits. The gate's mode is 6755: the mode 755 with the setuid bit, 4000, and the setgid bit, 2000, added. The setgid bit gives the gate the group as its effective gid, and an interpreter makes its files with the effective gid's group: only root can give a file away, so an interpreter whose effective uid is that of an account B other than root makes files that account B owns, in that group. The group is account B's own unless group <group> names another, such as a group the web server reads through. When account B and the group are both root's, the setgid bit changes nothing, and the mode is 4755.
+
+
+ With constraints <file>, a Python file of predicates, install_gate puts them between the constraint marks of the gate's guard. Without it, the constraints of a guard already installed are kept, so installing again brings a new guard and keeps the administrator's constraints; a first installation gets the guard's as promoted, which refuse every account. The constraints of every other installed guard are kept too. install_gate refuses a directory that is not absolute, that an account other than root owns or can write, or that is on a file system mounted nosuid, where the kernel ignores the setuid and setgid bits, as it does on removable drives and some separately mounted /home directories. It ends by printing the installed gate's flags, as root sees them: real uid and gid root's, effective uid account B's, effective gid the group's. Promotion replaces files and sets neither the owner nor the setuid bit, so the installation is redone after each promotion that changes the gate, the guard or the interpreter. The installation is a candidate for Man_In_Grey itself, once there is a language for installing programs.
@@ -501,7 +626,7 @@
The tester's test tester/authored/emplace_bio copies the promoted Man_In_Grey into its scratchpad directory, puts constraints of its own at the top of the copy's guard, and runs the copy through the example language's gate, not setuid, so accounts A and B are the same. The gate checks ownership on every run, so the test makes its copy writable by its owner alone, and every directory from / down to the scratchpad directory is to be writable by no account but its owner too.
- It checks twenty-six things. A page with every style and with text that looks like markup is compared with expected/page.html, and its picture with the one given. A later run with a JPEG places it and removes the PNG, and a run with no picture removes the JPEG. A check_only run changes nothing. Seven journals are then made without the journal interface and given to the gate directly, as a program other than the orchestrator could: one whose text instruction carries a file name, one with a style the language does not have, one with text before any paragraph, one with an SVG picture, one with more text than its constraints allow, one from an account they do not allow, and one for a page that has been made a symbolic link. Each is refused by the check that is to refuse it, and changes nothing:
+ It checks thirty-two things. A page with every style and with text that looks like markup is compared with expected/page.html, and its picture with the one given. A later run with a JPEG places it and removes the PNG, and a run with no picture removes the JPEG. A check_only run changes nothing. Seven journals are then made without the journal interface and given to the gate directly, as a program other than the orchestrator could: one whose text instruction carries a file name, one with a style the language does not have, one with text before any paragraph, one with an SVG picture, one with more text than its constraints allow, one from an account they do not allow, and one for a page that has been made a symbolic link. Each is refused by the check that is to refuse it, and changes nothing. Last, the gate is started three times from a process that leaves signal state behind, as describes:
authored/emplace_bio/run test
@@ -654,15 +779,22 @@
Installing
- The administrator makes the directory, owned by the web server's account, and installs the gate, as root, with the constraints above:
+ The administrator makes the directory, as root, owned by the web server's account:
+
+
+ install -d -o http -g http -m 755 /srv/http/bio
+
+
+ and installs the gate with the constraints above, from his own account, here ubuntu; su asks for root's password:
- sudo install -d -o http -g http -m 755 /srv/http/bio
- sudo consumer/Man_In_Grey/install_gate install Man_In_Grey_emplace_bio_gate owner http into /usr/local/lib/Man_In_Grey constraints bio_constraint.py
+ consumer/Man_In_Grey/Man_In_Grey install Man_In_Grey_emplace_bio_gate owner http into /usr/local/lib/Man_In_Grey constraints bio_constraint.py
+ install_gate is run by root; su asks for root's password
+ Password:
install_gate: installed /usr/local/lib/Man_In_Grey/Man_In_Grey/
- install_gate: put the constraints of /root/bio_constraint.py in /usr/local/lib/Man_In_Grey/Man_In_Grey/guard_emplace_bio.py
+ install_gate: put the constraints of /home/ubuntu/bio_constraint.py in /usr/local/lib/Man_In_Grey/Man_In_Grey/guard_emplace_bio.py
install_gate: installed /usr/local/lib/Man_In_Grey/Man_In_Grey_gate/Man_In_Grey_emplace_bio_gate, owner http:http, mode 6755
install_gate: its flags, as seen by root:
real.uid=0
@@ -695,6 +827,7 @@
gate: received: real uid 2006 (Thomas), effective uid 996 (http), journal of 1857 bytes
+ gate: isolated: real uid now 996 (http); account A can no longer signal the gate
guard: name: emplace_bio
guard: version: 3
guard: constraints: /usr/local/lib/Man_In_Grey/Man_In_Grey/guard_emplace_bio.py
@@ -714,6 +847,7 @@
gate: received: real uid 2007 (alice), effective uid 996 (http), journal of 1855 bytes
+ gate: isolated: real uid now 996 (http); account A can no longer signal the gate
guard: name: emplace_bio
guard: version: 3
guard: constraints: /usr/local/lib/Man_In_Grey/Man_In_Grey/guard_emplace_bio.py
@@ -730,7 +864,8 @@
Why it is safe
- - A journal names no file, and the guard refuses an instruction with any argument the language does not have, so a journal cannot point the interpreter anywhere. The page's name comes from the kernel's real uid, and its directory from the administrator's constraints.
+ - A journal names no file, and the guard refuses an instruction with any argument the language does not have, so a journal cannot point the interpreter anywhere. The page's name comes from account A's user name, which the gate had from the kernel as its real uid, and its directory from the administrator's constraints.
+ - Once it has the journal, the gate gives up account A, so Thomas cannot stop the guard or the interpreter partway, and a page is never left half placed by him.
- The interpreter runs with the effective uid of http, not root, so even a fault in it reaches only what the web server's account can write.
- The page's text cannot become markup, since the interpreter escapes it and writes every tag itself. The page cannot become server code either: its name always ends in .html, never .php or .htaccess.
- The guard refuses a page or picture that has been made a symbolic link, and a directory reached through one, so a link cannot redirect the write to another file of http's.
@@ -742,15 +877,6 @@
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.
-
-
- The guard's filesystem check and the interpreter's execution are separate moments, so a file can appear or change between them. An interpreter is to be written so that it fails on such a change rather than acting on it: the example language's interpreter opens its directory without following a symbolic link, and a rename into place replaces a symbolic link put there since rather than following it.
-
-
- 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 example language writes its files to temporary names and renames each into place, so a page is never half written, but 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.
@@ -804,6 +930,28 @@
+
+ Group and setgid
+
+ The gate gives up account A's uid when it isolates itself, , but not account A's real gid or supplementary groups. Signals are permitted by uid alone, so the groups do not let account A interrupt it, and keeping them lets a constraint ask about account A's groups with Constraint.has_real_group. Three pieces of work remain:
+
+
+ - Recording account A's groups. If the gate gave up account A's gids too, with setregid and setgroups, so that nothing of account A's identity remained in the process, it would record the groups first, as it records the uid, and has_real_group would read them from the record.
+ - A constraint by group. Constraint.is_group_member and has_real_group let a guard admit the members of a group, but the example's guard admits accounts by name; a group, such as bio, is the more natural grant on a real host, and the example could show it.
+ - Which group the interpreter's files take. A setgid gate makes its files in the group install_gate gives it. A language that places files for several groups would need the interpreter to set each file's group, which an account B other than root can do only to the groups account B belongs to.
+
+
+
+
+ Checking at the moment of acting
+
+ The guard checks the files a journal will touch before the interpreter runs, so they can change in between. Changing them takes write access to their directory, and an account with that access could change them without the gate, so the gap gives no account anything it did not already have. It would still be better closed, so that what was checked is what is acted on.
+
+
+ File locks are the obvious tool, but on Linux they bind only programs that take them: flock and fcntl locks are advisory, and the kernel dropped mandatory locking in version 5.15. The way to close the gap is for the interpreter to make the filesystem checks itself, on the very directory descriptors it then writes through, as the example language's interpreter already does in part by opening its directory without following a symbolic link and renaming into place within it.
+
+
+
Transactional play on Btrfs
diff --git a/shared/tool/makefile/Harmony.mk b/shared/tool/makefile/Harmony.mk
index b168ac5..78db1a2 100644
--- a/shared/tool/makefile/Harmony.mk
+++ b/shared/tool/makefile/Harmony.mk
@@ -18,6 +18,7 @@ CFLAGS ?= -std=gnu11 -Wall -Wextra -Wpedantic -finput-charset=UTF-8
CFLAGS += -MMD -MP
CFLAGS += -include "$(REPO_HOME)/shared/tool/makefile/RT_global.h"
CFLAGS += -I $(C_SOURCE_DIR)
+CFLAGS += $(CFLAGS_PROJECT_LOCAL)
# Project administrators can override this in their local makefile
LIBRARY_NAME ?= $(PROJECT)
@@ -33,6 +34,7 @@ LIBRARY_FILE ?= $(LIBRARY_DIR)/lib$(LIBRARY_NAME).a
# the project library is linked only when the namespace makes one
LN_FLAGS ?= -L$(LIBRARY_DIR) -L/lib64 -L/lib $(if $(object_lib),-l$(LIBRARY_NAME))
# LN_FLAGS ?= -L$(LIBRARY_DIR) -L/lib64 -L/lib -l$(LIBRARY_NAME)
+LN_FLAGS += $(LN_FLAGS_PROJECT_LOCAL)
KMOD_SOURCE_DIR ?= authored
KMOD_CCFLAGS ?= -I $(KMOD_SOURCE_DIR)
diff --git a/tester/authored/emplace_bio/run b/tester/authored/emplace_bio/run
index 92b010e..f71343e 100755
--- a/tester/authored/emplace_bio/run
+++ b/tester/authored/emplace_bio/run
@@ -30,6 +30,16 @@ directly, as a program other than the orchestrator could:
account an account is_allowed_account does not allow: refused by the constraint check
link a page that has been made a symbolic link: refused by the filesystem check
+and, with the gate started from a process that left signal state behind,
+which survives exec:
+
+ timer an interval timer set to send SIGALRM partway through: the gate
+ cancels it, and the page is placed
+ ignored SIGCHLD ignored, which would keep the gate from learning how the
+ guard ended: the gate puts it back, and the page is placed
+ pending SIGTERM blocked and pending: the gate clears the mask, the signal
+ ends the gate before it reads the journal, and nothing changes
+
A refused run is to leave every file as it was.
The test reads consumer/ and writes only in its scratchpad directory,
@@ -42,7 +52,7 @@ Usage: run
clean delete the test's scratchpad directory
"""
-import sys ,os ,stat ,json ,shutil ,subprocess ,getpass ,urllib.parse
+import sys ,os ,stat ,json ,shutil ,subprocess ,getpass ,urllib.parse ,signal
sys.dont_write_bytecode = True
from pathlib import Path
@@ -146,11 +156,28 @@ def run_Man_In_Grey(program ,gate ,path ,work_dpath ,*option):
result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True)
return result.returncode ,(result.stdout + result.stderr).strip()
-def run_gate(gate ,journal_bytes):
- "a journal given to the gate directly, with no orchestrator"
- result = subprocess.run([str(gate) ,"admit"] ,input=journal_bytes ,capture_output=True)
+def run_gate(gate ,journal_bytes ,preexec=None):
+ """
+ A journal given to the gate directly, with no orchestrator. preexec runs in
+ the gate's process before exec, to leave state behind as an invoking
+ program could.
+ """
+ result = subprocess.run([str(gate) ,"admit"] ,input=journal_bytes ,capture_output=True ,preexec_fn=preexec)
return result.returncode ,(result.stdout + result.stderr).decode("utf-8" ,"replace").strip()
+# state an invoking program can leave behind, which survives exec
+
+def leave_timer():
+ "SIGALRM in 50 milliseconds, while the guard is still starting"
+ signal.setitimer(signal.ITIMER_REAL ,0.05)
+
+def leave_sigchld_ignored():
+ signal.signal(signal.SIGCHLD ,signal.SIG_IGN)
+
+def leave_sigterm_pending():
+ signal.pthread_sigmask(signal.SIG_BLOCK ,{signal.SIGTERM})
+ os.kill(os.getpid() ,signal.SIGTERM)
+
def hand_made(Journal ,instruction_list):
"a journal of the emplace_bio language holding the instructions given as (name ,argument)"
journal = Journal.Journal("emplace_bio")
@@ -177,13 +204,14 @@ def test(repo_home):
if not log.check(promoted ,"Man_In_Grey and the emplace_bio gate are promoted"
,None if promoted else f"the developer promotes Man_In_Grey and {GATE_NAME} first"):
return log.summary()
- sys.path.insert(0 ,str(tree / "Man_In_Grey"))
- import Journal
if work_dpath.exists():
shutil.rmtree(work_dpath)
(work_dpath / "bio").mkdir(parents=True)
make_tree(consumer ,tree)
+ # the journals the test makes by hand use the copy's Journal module, so it is imported once the copy exists
+ sys.path.insert(0 ,str(tree / "Man_In_Grey"))
+ import Journal
person = getpass.getuser()
write_constraint(tree ,work_dpath ,person)
bio = work_dpath / "bio"
@@ -236,6 +264,21 @@ def test(repo_home):
log.check(snapshot(bio) == before ,f"{name} changes nothing")
write_constraint(tree ,work_dpath ,person)
+ # signal state left behind by the invoking program
+ for name ,preexec ,content in (
+ ("timer" ,leave_timer ,"Placed under a timer.")
+ ,("ignored" ,leave_sigchld_ignored ,"Placed with SIGCHLD ignored.")
+ ):
+ journal = hand_made(Journal ,[("display_name" ,{"text": "A. Tester"}) ,("paragraph" ,{}) ,("text" ,{"content": content ,"style": []})])
+ status ,output = run_gate(gate ,journal ,preexec)
+ check_status(log ,f"{name} is interpreted" ,status ,0 ,output ,"interpreted:")
+ log.check(page_fpath.is_file() and content in page_fpath.read_text() ,f"{name} places the page")
+ before = snapshot(bio)
+ status ,output = run_gate(gate ,hand_made(Journal ,good_page()) ,leave_sigterm_pending)
+ log.check(status == -signal.SIGTERM and "received:" not in output ,"pending ends the gate before it reads the journal"
+ ,None if status == -signal.SIGTERM else f"exit status {status}")
+ log.check(snapshot(bio) == before ,"pending changes nothing")
+
# a page made a symbolic link
target = work_dpath / "elsewhere.html"
target.write_text("not to be overwritten\n")