From 30e5d18e3f95f3d6d0052e086f24558418516996 Mon Sep 17 00:00:00 2001 From: Devin Phillip Davis <354534380+quantum-osiris@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:46:35 -0500 Subject: [PATCH] fix(exec_policy): judge every hop of a symlink chain; gnu as On Ubuntu 26.04 /usr/bin/rm -> gnurm. classify() judged only the final target name; "gnurm" is unknown and lives in a system dir, so it was treated as a distribution alternative and ignored, and ./ls -> /usr/bin/rm was classified by its link name: an agent-origin `./ls -rf x` was ALLOWed. Now every hop of the chain is judged (ls -> rm -> gnurm), and a gnu hop is also judged as . Two portable tests build the chain under a temp dir patched into _SYSTEM_DIRS; all three link tests fail without the fix. Also: the GitHubBridge auth test asserted the account name "osiris-dnalang"; it now checks only that an account is reported (the gh account changed to quantum-osiris on 2026-11-09). Drops an unused MagicMock import. Full suite: 1796 passed, 13 skipped, 0 failed. Co-Authored-By: Claude Opus 5.5 --- osiris_cli/exec_policy.py | 42 ++++++++++++++++++++++----- tests/test_exec_policy.py | 29 ++++++++++++++++++ tests/test_github_and_sync_bridges.py | 5 ++-- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/osiris_cli/exec_policy.py b/osiris_cli/exec_policy.py index 9c2c9e2c..b1dee881 100644 --- a/osiris_cli/exec_policy.py +++ b/osiris_cli/exec_policy.py @@ -178,14 +178,17 @@ def refuse(reasons: Sequence[str], normalized: Sequence[str] = ()) -> Classifica given = os.path.basename(inner[0]) verdict = _rules((given,) + inner[1:], pol) if resolved: - target = os.path.realpath(resolved) - target_name = os.path.basename(target) - if target_name != given: - # A symlink named ls that points at rm must be judged as rm. An unknown name inside a - # system directory is a distribution alternative (which -> which.debianutils), not a risk. - current = _rules((target_name,) + inner[1:], pol) - if not (current.unknown and target.startswith(_SYSTEM_DIRS)): - verdict = _merge(verdict, current) + # A symlink named ls that points at rm must be judged as rm. Every hop counts, not only the + # final file: on Ubuntu 26.04 /usr/bin/rm -> gnurm, so the last name alone is unknown. An + # unknown name inside a system directory is a distribution alternative + # (which -> which.debianutils), not a risk; gnu is judged as . + for hop in _link_chain(resolved): + for name in _hop_names(os.path.basename(hop)): + if name == given: + continue + current = _rules((name,) + inner[1:], pol) + if not (current.unknown and hop.startswith(_SYSTEM_DIRS)): + verdict = _merge(verdict, current) if any(tok in pol["publish_flags"] for tok in inner): verdict = _merge(verdict, _Verdict(agent_deny=True, reasons=["publish flag"])) if wrappers: @@ -293,6 +296,29 @@ def _resolve_program(name: str, cwd: str, path_env: str) -> Optional[str]: return os.path.abspath(found) if found else None +def _link_chain(path: str, max_hops: int = 40) -> List[str]: + """Each path a symlink chain passes through after the first, ending at the realpath.""" + chain: List[str] = [] + current = path + for _ in range(max_hops): + if not os.path.islink(current): + break + target = os.readlink(current) + current = os.path.normpath(os.path.join(os.path.dirname(current), target)) + chain.append(current) + real = os.path.realpath(path) + if not chain or chain[-1] != real: + chain.append(real) + return chain + + +def _hop_names(base: str) -> Tuple[str, ...]: + """Names to judge a hop by: the name itself and, for gnu (Ubuntu 26.04), .""" + if base.startswith("gnu") and len(base) > 3: + return (base, base[3:]) + return (base,) + + def _rules(args: Tuple[str, ...], pol: dict) -> _Verdict: """Rules for one program name plus its arguments. args[0] is the program name.""" name = os.path.basename(args[0]) diff --git a/tests/test_exec_policy.py b/tests/test_exec_policy.py index cc7ab5a5..bbb06406 100644 --- a/tests/test_exec_policy.py +++ b/tests/test_exec_policy.py @@ -83,6 +83,35 @@ def test_symlink_named_ls_pointing_at_rm_is_treated_as_rm(tmp_path): assert cls.tier == "MUTATE" +def test_symlink_chain_through_renamed_rm_in_a_system_dir_is_treated_as_rm(tmp_path, monkeypatch): + # Ubuntu 26.04: /usr/bin/rm -> gnurm. The final name is unknown and sits in a system dir, so + # judging only the last hop let ./ls -> rm -> gnurm through as ls. + sysdir = tmp_path / "sysbin" + sysdir.mkdir() + real = sysdir / "gnurm" + real.write_text("#!/bin/sh\n") + real.chmod(0o755) + (sysdir / "rm").symlink_to("gnurm") + (tmp_path / "ls").symlink_to(sysdir / "rm") + monkeypatch.setattr(exec_policy, "_SYSTEM_DIRS", (str(sysdir) + os.sep,)) + cls = classify(_req(["./ls", "-rf", "x"], "model", tmp_path)) + assert cls.decision == "DENY" + assert cls.tier == "MUTATE" + + +def test_symlink_straight_to_gnu_prefixed_rm_is_treated_as_rm(tmp_path, monkeypatch): + sysdir = tmp_path / "sysbin" + sysdir.mkdir() + real = sysdir / "gnurm" + real.write_text("#!/bin/sh\n") + real.chmod(0o755) + (tmp_path / "ls").symlink_to(real) + monkeypatch.setattr(exec_policy, "_SYSTEM_DIRS", (str(sysdir) + os.sep,)) + cls = classify(_req(["./ls", "-rf", "x"], "model", tmp_path)) + assert cls.decision == "DENY" + assert cls.tier == "MUTATE" + + def test_cwd_outside_root_is_denied(tmp_path): root = tmp_path / "root" outside = tmp_path / "outside" diff --git a/tests/test_github_and_sync_bridges.py b/tests/test_github_and_sync_bridges.py index ec232450..ebb85abf 100644 --- a/tests/test_github_and_sync_bridges.py +++ b/tests/test_github_and_sync_bridges.py @@ -10,7 +10,7 @@ import platform import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch from osiris_cli.github_bridge import GitHubBridge from osiris_cli.sync_bridge import DualNodeSyncBridge @@ -28,7 +28,8 @@ def test_auth_status_inspection(self): self.assertIn("installed", auth) self.assertIn("authenticated", auth) if auth["installed"] and auth["authenticated"]: - self.assertEqual(auth["account"], "osiris-dnalang") + # Whoever is logged in to gh here; the account changed on 2026-11-09 and may again. + self.assertTrue(auth.get("account")) self.assertIn("repo", auth.get("scopes", [])) def test_read_only_pr_listing(self):