Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions osiris_cli/exec_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<name> is judged as <name>.
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:
Expand Down Expand Up @@ -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<name> (Ubuntu 26.04), <name>."""
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])
Expand Down
29 changes: 29 additions & 0 deletions tests/test_exec_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions tests/test_github_and_sync_bridges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
Loading