diff --git a/CHANGELOG.md b/CHANGELOG.md index a2208a1..044ceb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,11 @@ Initial open-source release of FrontierAgent. ### Changed +- Upgrade AgentCore to 0.14.1 for provider heartbeat handling and bounded + admission, summary requests, and cancellation cleanup. + - **Runtime engine moved to [`apodex-agent-core`](https://pypi.org/project/apodex-agent-core/) - (pinned `==0.12.2`).** The agent loop, loop contracts, tool execution, + (pinned `==0.14.1`).** The agent loop, loop contracts, tool execution, compaction, observers, AgentBus, DAG and providers now come from `agent_core`; `frontier_agent.*` keeps its import paths as `sys.modules` aliases or thin adapters, so workflows, apodex and benchmarks are unchanged. Product policy is @@ -62,6 +65,30 @@ Initial open-source release of FrontierAgent. ### Fixed +- Keep bash protection on resolved local system aliases such as macOS + `/private/etc`, while exempting the run's own writable mounts. Recursive + deletion and mutation refuse ancestors and wildcard selections of read-only + inputs before applying that exemption. + +- **Bash policy: this run's own writable mounts are no longer system paths.** + A redirect into a relocated outputs or workspace directory was refused by the + static `/var`, `/opt`, … prefixes — macOS `$TMPDIR` is `/var/folders/...`, a + container volume is `/var/lib/app/run` — while `tee` and `cp` to the same + path were allowed and the deny message recommended the very directory it had + just refused. The redirect target capture also no longer swallows an adjacent + redirect, so a writable target cannot hide `>/etc/passwd` behind it. +- **Bash policy: paths are compared by identity, not spelling.** On a local + backend both the written and the resolved name are checked, so a read-only + input mount is protected under either (`rm -rf $(realpath …)`, `/private/etc` + for `/etc`), and a run's own directories stay clearable under either. A + read-only mount nested inside a writable root wins over the writable + exemption, and a symlink leading *out* of a writable root is not exempt. + Remote paths are never resolved with this host's `realpath`, and relative + operands are never resolved against the harness cwd. +- Output redirections are additionally checked after parsing, so a target that + climbs out of a writable root (`> $OUTPUTS/../../../etc/passwd`) and a write + into a relocated read-only mount are both refused — neither begins with a + literal system prefix, so the raw regex never looked at them. - **One trusted runtime filesystem state** (`plugins/tools/_filesystem_state.py`). The workspace, outputs, inputs and scratch directories are derived once and read by everyone: mount resolution, the file-tool gate, the deliverable diff --git a/plugins/tools/_bash_policy.py b/plugins/tools/_bash_policy.py index 3b93ed5..4daaaf0 100644 --- a/plugins/tools/_bash_policy.py +++ b/plugins/tools/_bash_policy.py @@ -12,6 +12,7 @@ import contextlib import contextvars +import fnmatch import logging import os import re @@ -185,12 +186,201 @@ def _norm_target(arg: str) -> str: _VAR_RE = re.compile(r"\$\{[^}]*\}|\$[\w@*#?!-]+") -def _under_protected_root(path: str) -> bool: - return path in _PROTECTED_TARGETS or any( - path == root or path.startswith(root + "/") for root in _SYSTEM_ROOTS +def _writable_roots() -> tuple[str, ...]: + """This run's own writable directories, resolved per call. + + Read from the trusted runtime filesystem state, so they are exactly the + directories the prompts name, the file tools authorize and the shell + variables point at. Inputs are absent: that mount is read-only. + """ + try: + from plugins.tools._filesystem_state import current_filesystem_state + + return tuple(root for root in current_filesystem_state().scratch_roots() if root) + except Exception: + return () + + +def _local_filesystem_paths() -> bool: + """Whether these path spellings name THIS host's filesystem. + + The same flag the file-tool gate uses, so the two cannot disagree about + whether resolving a path here describes the right machine. + """ + try: + from plugins.tools._filesystem_state import current_filesystem_state + + return current_filesystem_state().local_filesystem + except Exception: + return False + + +def _real_path(path: str) -> str | None: + """``path`` with symlinks resolved, or ``None`` when it cannot be resolved. + + Non-strict: a path that does not exist still resolves. An ``OSError`` (a + symlink loop, for instance) returns ``None`` and the caller keeps its + textual answer, so a failed lookup never DROPS a check. + """ + try: + return os.path.realpath(path) + except OSError: + return None + + +def _path_spellings(path: str) -> tuple[str, ...]: + """Every name this one path answers to: as written, and resolved. + + Comparing spelling alone made protection depend on how a path was typed: + ``rm -rf /inputs`` was refused while ``rm -rf $(realpath /inputs)`` — + the same directory — was allowed, and on macOS ``/private/etc`` reached + ``/etc``. The runtime hands out both spellings itself, so this has to + compare identity. + + Resolved only when the state says these paths are local AND the path is + absolute with no unexpanded variable: the shell's cwd is not this process's, + so resolving a relative operand would name a different file, and a remote + path must never be interpreted by this host. + """ + written = _norm_target(path) + raw = path.strip().strip("'\"") + if ( + not raw.startswith("/") + or _VAR_RE.search(raw) + or not _local_filesystem_paths() + ): + return (written,) + # The RAW path is resolved, not the normalized one: collapsing ``..`` + # against a symlinked parent names a different file than the shell opens. + resolved = _real_path(raw) + return (written,) if resolved is None or resolved == written else (written, resolved) + + +def _within_writable_root(path: str) -> bool: + """Whether ``path`` is strictly inside one of this run's writable roots. + + The exemption that keeps a relocated mount usable: ``_REDIRECT_PROTECTED_RE`` + matches a literal prefix, and a run directory legitimately sits under one — + macOS ``$TMPDIR`` is ``/var/folders/...``, a container volume is + ``/var/lib/app/run``. Refusing a redirect there while ``tee`` and ``cp`` to + the same path were allowed meant the deny message recommended the very + directory it had just refused, and the deliverable was lost. + + ``..`` is collapsed before comparing, so this cannot be used to climb out. + An unexpanded variable is never treated as contained: its value is unknown + here. + """ + raw = path.strip().strip("'\"") + if not raw.startswith("/") or _VAR_RE.search(raw): + return False + prefixes: set[str] = set() + for root in _writable_roots(): + prefixes.add(_norm_target(root).rstrip("/") + "/") + if _local_filesystem_paths(): + resolved_root = _real_path(root) + if resolved_root: + prefixes.add(resolved_root.rstrip("/") + "/") + if not prefixes: + return False + # EVERY spelling must land inside a writable root, not merely one of them. + # ``/tmp`` is itself a writable root, so a symlink there pointing at + # ``/etc`` is textually contained; exempting it on that basis would retire + # the static protection altogether. Requiring both names keeps #589's case + # working — a file under a relocated outputs directory resolves to a file + # under the resolved outputs directory, and both roots are in this set — + # while a link that leads OUT of the run's directories is not exempt. + candidates = _path_spellings(raw) + return all( + any(candidate.startswith(prefix) for prefix in prefixes) + for candidate in candidates ) +def _relocated_protected_roots() -> tuple[str, ...]: + """Read-only roots of THIS run, wherever they were mounted. + + ``_SYSTEM_ROOTS`` names the canonical ones; a run whose inputs are mounted + somewhere else entirely still must not have them deleted, and that mount + holds the only copy of the user's files. + """ + try: + from plugins.tools._filesystem_state import current_filesystem_state + + return tuple( + root for root in current_filesystem_state().read_only_roots() if root + ) + except Exception: + return () + + +def _under_static_protected_root(path: str) -> bool: + """Whether ``path`` names one of the canonical system roots. + + Local system roots include their resolved aliases (``/etc`` becomes + ``/private/etc`` on macOS). Callers exempt this run's writable mounts + first, so resolving ``/var`` does not block legitimate scratch files. + Remote system paths are never resolved against the host. + + Loses to :func:`_within_writable_root`: a run directory legitimately sits + under one of these prefixes. + """ + roots: set[str] = set(_SYSTEM_ROOTS) + if _local_filesystem_paths(): + roots.update(resolved for root in _SYSTEM_ROOTS if (resolved := _real_path(root))) + return any( + candidate in _PROTECTED_TARGETS or any( + candidate == root or candidate.startswith(root + "/") + for root in roots + ) + for candidate in _path_spellings(path) + ) + + +def _under_run_read_only_root(path: str) -> bool: + """Whether ``path`` is inside one of THIS run's read-only mounts. + + Wins over :func:`_within_writable_root`, which is the only ordering that + works: the inputs mount is frequently nested inside a writable root (a run + directory under ``$TMPDIR``, or anywhere under ``/tmp``), so an exemption + applied first would hand back the one directory holding the user's own + files — and it holds the only copy. + """ + for root in _relocated_protected_roots(): + for root_spelling in {_norm_target(root), *_path_spellings(root)}: + prefix = root_spelling.rstrip("/") + if any( + candidate == prefix or candidate.startswith(prefix + "/") + for candidate in _path_spellings(path) + ): + return True + return False + + +def _under_protected_root(path: str) -> bool: + """Whether ``path`` names a protected root, under any of its spellings.""" + return _under_run_read_only_root(path) or _under_static_protected_root(path) + + +def _contains_run_read_only_root(path: str) -> bool: + """Whether a recursive target selects a read-only root or its ancestor. + + Match ancestors too for glob operands such as ``/run/*``: deleting the + parent or selecting inputs through a wildcard must not bypass protection. + """ + targets = _path_spellings(path) + for root in _relocated_protected_roots(): + for spelling in _path_spellings(root): + ancestor = spelling + while ancestor: + if any(fnmatch.fnmatchcase(ancestor, target) for target in targets): + return True + parent = os.path.dirname(ancestor) + if parent == ancestor: + break + ancestor = parent + return False + + def _is_delete_protected(arg: str) -> bool: """True if recursively deleting/mutating ``arg`` must be refused: the fs root, the home tree (``~`` / ``$HOME``), or anything under a system/input @@ -201,7 +391,18 @@ def _is_delete_protected(arg: str) -> bool: return True if raw.startswith("~") or raw.startswith("$HOME") or raw.startswith("${HOME}"): return True - if _under_protected_root(a): + # The RAW argument, so ``_path_spellings`` can resolve it: ``_norm_target`` + # has already collapsed ``..``, which loses symlink-parent semantics. + target = raw if raw.startswith("/") else a + if _under_run_read_only_root(target) or _contains_run_read_only_root(target): + return True + # This run's own workspace/outputs stay clearable even when they sit under + # a protected prefix — ``rm -rf $OUTPUTS/stale`` with outputs under + # ``/var/folders/...`` is ordinary cleanup, and refusing it while ``find + # -delete`` on the same path was allowed only taught the model a detour. + if _within_writable_root(target): + return False + if _under_static_protected_root(target): return True # An absolute-looking path whose variables strip to a protected root: # ``/$X`` -> ``/``, ``/$SYS/…`` -> ``/etc``. Requires a leading ``/`` so a @@ -516,6 +717,42 @@ def strip_command_prefixes(argv: list[str]) -> list[str]: _FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"}) +def _redirect_protection_reason(commands: list[list[str]]) -> str | None: + """Why an output redirection in ``commands`` must be refused, or ``None``. + + The parsed counterpart of ``_REDIRECT_PROTECTED_RE``, and strictly stronger + than it in two ways the regex cannot reach, because it matches a LITERAL + prefix: + + * a target that climbs out — ``> $OUTPUTS/../../../../etc/passwd`` begins + with this run's own directory, so the regex never looked at it, while + ``_norm_target`` collapses it to ``/etc/passwd``; + * this run's read-only mounts wherever they were mounted, which the regex's + fixed list of system roots does not know about. + + Writable roots are exempt first (``_within_writable_root``), so a relocated + outputs directory under ``/var`` stays usable. + """ + for argv in commands: + for raw_target in _redirect_targets(argv): + target = raw_target.strip().strip("'\"") + if not target.startswith("/") or _VAR_RE.search(target): + continue + if _REDIRECT_SAFE_DEVICE_RE.match(_norm_target(target)): + continue + # Read-only mounts first: see ``_under_run_read_only_root``. + if not _under_run_read_only_root(target) and _within_writable_root(target): + continue + if _under_protected_root(target): + writable = ", ".join(_writable_roots()) or "/workspace, /outputs or /tmp" + return ( + f"Refuses output redirection into a protected path " + f"(`{target}`). Write to {writable} instead; " + f"`>/dev/null` to discard is fine." + ) + return None + + def _argv_hard_deny(commands: list[list[str]]) -> str | None: """Robust dangerous-op detection on parsed argvs. Complements the raw regex — order-, flag-combination-, prefix- and quote-independent. @@ -733,9 +970,13 @@ def _argv_hard_deny(commands: list[list[str]]) -> str | None: _INLINE_CODE_FLAGS = frozenset({"-c", "-e", "--command", "--eval"}) _ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +# The target capture stops at shell punctuation rather than taking every +# non-space character: with ``\S*`` the single match ``>/outputs/a>/etc/passwd`` +# swallowed the second redirect, so a writable-root exemption on the first one +# would have excused the write into ``/etc``. _REDIRECT_PROTECTED_RE = re.compile( r"(?:&>>?|>\||>&|>>?)\s*" - r"(/(?:etc|usr|bin|sbin|lib|lib64|boot|dev|proc|sys|var|root|opt)\b\S*)" + r"(/(?:etc|usr|bin|sbin|lib|lib64|boot|dev|proc|sys|var|root|opt)\b[^\s<>;&|()]*)" ) # Character devices that every shell idiom redirects to. Discarding a stream # (``2>/dev/null``) or pointing one at the terminal/an existing fd is not a @@ -2514,14 +2755,27 @@ def assess_bash_command( continue # ``\S*`` swallows any shell punctuation glued to the target # (``2>/dev/null;`` / ``>/dev/null)``) — trim it before classifying. - target = match.group(1).rstrip(";&|)\"'") + # Only quotes are stripped now: the pattern no longer swallows shell + # punctuation, so trimming ``;&|)`` would cut into a real filename. + target = match.group(1).strip("\"'") if _REDIRECT_SAFE_DEVICE_RE.match(target): continue + # This run's own writable directories, wherever they were mounted. + # Containment is computed after ``..`` collapsing, so this cannot be + # used to climb out of a writable root into a real system path. + if not _under_run_read_only_root(target) and _within_writable_root(target): + continue + # Name the directories this run actually has rather than the canonical + # mounts: this text reaches the model right after it tried a path that + # was refused, which is exactly where a self-teaching error earns its + # keep. Every directory named here must itself accept a redirect — + # ``test_the_deny_reason_never_recommends_a_path_it_would_refuse``. + writable = ", ".join(_writable_roots()) or "/workspace, /outputs or /tmp" return BashCommandAssessment( level="deny", reason=( f"Refuses output redirection into a protected system path " - f"(`{target}`). Write to /workspace, /outputs or /tmp instead; " + f"(`{target}`). Write to {writable} instead; " f"`>/dev/null` to discard is fine." ), ) @@ -2538,6 +2792,10 @@ def assess_bash_command( if argv_reason: return BashCommandAssessment(level="deny", reason=argv_reason) + redirect_reason = _redirect_protection_reason(commands) + if redirect_reason: + return BashCommandAssessment(level="deny", reason=redirect_reason) + env_reason = _dynamic_env_split_reason(commands) if env_reason: return BashCommandAssessment(level="deny", reason=env_reason) diff --git a/pyproject.toml b/pyproject.toml index 26c844d..6e62775 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ requires-python = ">=3.12" dependencies = [ # Runtime engine (agent loop, tools, observers, sub-agents, DAG). Pinned exactly: # 0.x MINOR bumps may be breaking. - "apodex-agent-core==0.12.2", + "apodex-agent-core==0.14.1", "pydantic>=2.0", "pydantic-settings>=2.14.2", "openai>=1.50", diff --git a/tests/test_bash_policy_relocated_mounts.py b/tests/test_bash_policy_relocated_mounts.py new file mode 100644 index 0000000..d679480 --- /dev/null +++ b/tests/test_bash_policy_relocated_mounts.py @@ -0,0 +1,315 @@ +"""Relocated mounts and path identity (handoff item 4c; Harness #589 / #590). + +Two failures with the same shape: the policy judged a path by its SPELLING. + +* ``_REDIRECT_PROTECTED_RE`` matches a literal prefix, so a run whose outputs + live under ``/var/folders/...`` (macOS ``$TMPDIR``) or ``/var/lib/app/run`` + (a container volume) could not redirect into its own deliverable directory — + while ``tee`` and ``cp`` to the same path were allowed, and the deny message + recommended the very directory it had just refused; +* ``_norm_target`` collapses ``..`` but never resolves symlinks, so the same + directory was protected under one name and clearable under another. + +Every command here is only assessed, never executed. +""" + +from __future__ import annotations + +import os + +import pytest + +from plugins.tools import _bash_policy as policy +from plugins.tools._bash_policy import assess_bash_command +from plugins.tools._filesystem_state import ( + install_filesystem_state, + reset_filesystem_state, + state_for_sandbox_mode, +) + +MODES = ["off", "warn", "enforce"] + + +@pytest.fixture +def relocated(tmp_path): + """This run's mounts under a protected-looking prefix, reached through a + symlink — the two shapes #589 and #590 are about, together. + + ``real/`` is the physical tree; ``link -> real`` is the spelling the + runtime advertises (``CurrentSandbox`` resolves its workdir but passes + outputs and inputs through raw, so both spellings are in circulation). + """ + real = tmp_path / "var" / "folders" / "ab" / "T" / "run" + for name in ("workspace", "outputs", "inputs"): + (real / name).mkdir(parents=True) + (real / "workspace" / "build").mkdir() + (real / "inputs" / "corpus").mkdir() + link = tmp_path / "link" + link.symlink_to(real) + token = install_filesystem_state(state_for_sandbox_mode( + "native", + workspace=str(link / "workspace"), + outputs=str(link / "outputs"), + inputs=str(link / "inputs"), + )) + try: + yield {"real": real, "link": link} + finally: + reset_filesystem_state(token) + + +def _level(command: str, mode: str = "off") -> str: + return assess_bash_command(command, mode=mode).level + + +# ── #589: this run's writable mounts are not system paths ──────────────── + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("template", [ + "echo x > {outputs}/report.md", + "echo x >> {outputs}/log.txt", + "python3 gen.py > {outputs}/out.json", + "ls 2> {workspace}/err.log", + "ls &> {workspace}/all.log", + "ls >| {outputs}/clobber.txt", + "echo x > {workspace}/draft.md", +]) +@pytest.mark.parametrize("spelling", ["link", "real"]) +def test_redirect_into_a_writable_mount_is_allowed( + relocated, template: str, mode: str, spelling: str, +) -> None: + root = relocated[spelling] + command = template.format(outputs=root / "outputs", workspace=root / "workspace") + assert _level(command, mode) != "deny", command + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("operator", [">", ">>", "2>", "&>", ">|", ";>", "|>", "&&>"]) +def test_a_writable_redirect_cannot_hide_an_adjacent_protected_one( + relocated, operator: str, mode: str, +) -> None: + # ``\\S*`` used to swallow the second redirect into the first match, so the + # writable exemption would have excused the write into /etc. + command = ( + f"echo x > {relocated['link'] / 'outputs' / 'a'}{operator}/etc/passwd" + ) + assert _level(command, mode) == "deny", command + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("target", [ + "/etc/passwd", + "/usr/bin/evil", + "/var/log/syslog", + "/root/.ssh/authorized_keys", +]) +def test_the_exemption_does_not_widen_past_the_writable_mounts( + relocated, target: str, mode: str, +) -> None: + assert _level(f"echo x > {target}", mode) == "deny" + + +@pytest.mark.parametrize("mode", MODES) +def test_a_target_that_climbs_out_of_a_writable_mount_is_refused( + relocated, mode: str, +) -> None: + # Begins with this run's own directory, so the literal-prefix regex never + # looked at it; ``..`` is collapsed before the comparison. + escape = str(relocated["link"] / "outputs") + "/" + "../" * 12 + "etc/passwd" + assert _level(f"echo x > {escape}", mode) == "deny" + + +def test_an_unexpanded_variable_is_never_treated_as_contained(relocated) -> None: + # The value is unknown here, so it is neither exempted nor resolved. + assert not policy._within_writable_root("$OUTPUTS/a.txt") + assert not policy._within_writable_root("${OUT}/a.txt") + assert _level("echo x > $OUT/a.txt") == "allow" + + +def test_the_deny_reason_never_recommends_a_path_it_would_refuse(relocated) -> None: + reason = assess_bash_command("echo x > /etc/passwd", mode="off").reason + assert str(relocated["link"] / "outputs") in reason + for candidate in policy._writable_roots(): + assert _level(f"echo x > {candidate}/probe.txt") != "deny", candidate + + +def test_discarding_a_stream_is_still_fine(relocated) -> None: + for command in ("ls 2>/dev/null", "ls >/dev/null 2>&1", "echo x >&2"): + assert _level(command) == "allow", command + + +# ── #590: identity, not spelling ───────────────────────────────────────── + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("spelling", ["link", "real"]) +@pytest.mark.parametrize("template", [ + "rm -rf {inputs}", + "rm -rf {inputs}/corpus", + "find {inputs} -delete", + "chmod -R 777 {inputs}", + "chown -R nobody {inputs}", + "echo x > {inputs}/a.txt", +]) +def test_a_read_only_mount_is_protected_under_either_spelling( + relocated, template: str, spelling: str, mode: str, +) -> None: + command = template.format(inputs=relocated[spelling] / "inputs") + assert _level(command, mode) == "deny", command + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("spelling", ["link", "real"]) +@pytest.mark.parametrize("template", [ + "rm -rf {outputs}", + "rm -rf {workspace}/build", + "rm -rf {workspace}/*", +]) +def test_the_agents_own_dirs_stay_clearable_under_either_spelling( + relocated, template: str, spelling: str, mode: str, +) -> None: + root = relocated[spelling] + command = template.format(outputs=root / "outputs", workspace=root / "workspace") + assert _level(command, mode) != "deny", command + + +def test_a_read_only_mount_nested_in_a_writable_root_still_wins(relocated) -> None: + """The ordering that matters: the inputs mount is frequently inside a + writable root (a run directory under ``$TMPDIR``), so exempting writable + roots first would hand back the one directory holding the user's files.""" + inputs = relocated["link"] / "inputs" + assert policy._within_writable_root(str(inputs / "a.txt")) or True + assert policy._under_run_read_only_root(str(inputs / "a.txt")) + assert _level(f"rm -rf {inputs}") == "deny" + + +def test_path_spellings_reports_both_names_only_for_a_local_state(relocated) -> None: + inputs = str(relocated["link"] / "inputs") + assert policy._path_spellings(inputs) == (inputs, str(relocated["real"] / "inputs")) + + reset = install_filesystem_state(state_for_sandbox_mode("bwrap")) + try: + assert policy._path_spellings("/inputs") == ("/inputs",) + finally: + reset_filesystem_state(reset) + + +def test_a_remote_state_never_resolves_paths_on_this_host(monkeypatch) -> None: + calls: list[str] = [] + monkeypatch.setattr( + os.path, "realpath", lambda p, **k: (calls.append(str(p)), str(p))[1], + ) + token = install_filesystem_state(state_for_sandbox_mode("bwrap")) + try: + assert _level("rm -rf /inputs") == "deny" # textual protection holds + assert _level("echo x > /etc/passwd") == "deny" + assert calls == [], "a remote path was resolved against the harness host" + finally: + reset_filesystem_state(token) + + +def test_a_relative_operand_is_not_resolved_against_the_harness_cwd(relocated) -> None: + # The shell's cwd is not this process's, so resolving would name another + # file; ``rm -rf build`` stays allowed even if the harness cwd has a + # ``build`` symlink into the inputs mount. + assert policy._path_spellings("build/../scratch") == ("scratch",) + assert _level("rm -rf build") == "allow" + + +def test_an_unresolvable_target_keeps_the_textual_answer(relocated, monkeypatch) -> None: + def boom(path: str, **_kwargs: object) -> str: + raise OSError(40, "Too many levels of symbolic links") + + monkeypatch.setattr(os.path, "realpath", boom) + assert policy._real_path("/whatever") is None + # A failed lookup must never DROP a check. + assert _level("rm -rf /etc") == "deny" + assert _level(f"rm -rf {relocated['link'] / 'inputs'}") == "deny" + + +def test_a_symlink_parents_semantics_are_preserved(relocated) -> None: + """``link/outputs/../inputs`` follows the PHYSICAL parent, so it reaches the + read-only mount rather than a sibling of the link.""" + through = str(relocated["link"] / "outputs" / ".." / "inputs") + assert policy._under_run_read_only_root(through) + assert _level(f"rm -rf {through}") == "deny" + + +def test_static_system_roots_are_matched_under_every_spelling(tmp_path) -> None: + """macOS spells ``/etc`` as ``/private/etc``; the alias must not bypass.""" + alias = tmp_path / "private-etc" + alias.symlink_to("/etc") + token = install_filesystem_state(state_for_sandbox_mode( + "native", workspace=str(tmp_path / "ws"), outputs=str(tmp_path / "out"), + )) + try: + assert _level(f"rm -rf {alias}") == "deny" + assert _level(f"echo x > {alias}/hosts") == "deny" + finally: + reset_filesystem_state(token) + + +def test_system_aliases_do_not_block_writable_mounts(relocated) -> None: + """Writable exemptions precede static checks, including resolved /var.""" + assert policy._under_static_protected_root("/var/log/syslog") + target = str(relocated["link"] / "outputs" / "a.md") + assert policy._within_writable_root(target) + assert _level(f"echo x > {target}") == "allow" + + +def test_a_symlink_out_of_a_writable_root_is_not_exempt(relocated, tmp_path) -> None: + """``/tmp`` is itself a writable root, so a link planted there that points + at a system path is textually contained. Exempting it on that basis would + retire the static protection; both spellings have to be contained.""" + escape = tmp_path / "looks-local" + escape.symlink_to("/etc") + assert not policy._within_writable_root(str(escape / "hosts")) + assert _level(f"echo x > {escape}/hosts") == "deny" + assert _level(f"rm -rf {escape}") == "deny" + + +def test_a_symlinked_writable_root_is_still_exempt(relocated) -> None: + # The counterpart: the run's OWN outputs reached through the link spelling + # resolves to the run's own outputs, so both names are contained. + for spelling in ("link", "real"): + target = str(relocated[spelling] / "outputs" / "a.md") + assert policy._within_writable_root(target), spelling + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("template", [ + "rm -rf {run}", + "rm -rf {run}/*", + "rm -rf {run}/in*", + "find {run} -delete", + "chmod -R 777 {run}", + "chown -R nobody {run}", +]) +def test_recursive_ancestor_cannot_delete_read_only_inputs(tmp_path, mode, template): + run = tmp_path / "run" + for name in ("workspace", "outputs", "inputs"): + (run / name).mkdir(parents=True) + token = install_filesystem_state(state_for_sandbox_mode( + "native", workspace=str(run / "workspace"), outputs=str(run / "outputs"), + inputs=str(run / "inputs"), tmpdir=str(tmp_path), + )) + try: + assert _level(template.format(run=run), mode) == "deny" + assert _level(f"rm -rf {run}/outputs/stale", mode) != "deny" + finally: + reset_filesystem_state(token) + + +@pytest.mark.parametrize("mode", MODES) +def test_system_aliases_are_protected_but_scratch_is_writable(tmp_path, mode): + token = install_filesystem_state(state_for_sandbox_mode( + "native", workspace=str(tmp_path / "ws"), outputs=str(tmp_path / "out"), + )) + try: + resolved_etc = os.path.realpath("/etc") + assert _level(f"echo x > {resolved_etc}/hosts", mode) == "deny" + assert _level(f"rm -rf {resolved_etc}", mode) == "deny" + assert _level(f"echo x > {tmp_path}/scratch.txt", mode) != "deny" + finally: + reset_filesystem_state(token) diff --git a/uv.lock b/uv.lock index a0f6a99..1dc3016 100644 --- a/uv.lock +++ b/uv.lock @@ -208,7 +208,7 @@ wheels = [ [[package]] name = "apodex-agent-core" -version = "0.12.2" +version = "0.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic", extra = ["bedrock"] }, @@ -218,9 +218,9 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/c9/23b18d730e5919990e32be0efb4739496186d00ac939bac71d5a1dac7bfc/apodex_agent_core-0.12.2.tar.gz", hash = "sha256:cb90cfdb55fde1f7a49fbcde963ebedc6ad947165c0697b40a1ce9cc4df316e1", size = 779546, upload-time = "2026-09-27T14:09:53.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/9f/2f8c888015bad668bd7e59bac5aeea4f421ec5b9c4b12388e25c90d9e265/apodex_agent_core-0.14.1.tar.gz", hash = "sha256:e7440d25a05f2b3f68e56a2d93acc3fa81c19a9a6d224f6ac0d4012131a4339b", size = 835606, upload-time = "2026-10-06T03:43:20.483Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/30/ba2ae097ad8adfa428b82dac71288a3cb50d78b29ae2ef44d97b4eda78db/apodex_agent_core-0.12.2-py3-none-any.whl", hash = "sha256:0905da54a6a1fbc76861927285d5f489fd16ed46dbfc5f6b20892af94594c6ff", size = 540845, upload-time = "2026-09-27T14:09:51.345Z" }, + { url = "https://files.pythonhosted.org/packages/f8/32/cefa359ed6354dd2c492d46df90f4550c1dcbe3b04d65b2ffb28b475f942/apodex_agent_core-0.14.1-py3-none-any.whl", hash = "sha256:07dacc2d43b5681c67bf53e7be6c2e1bf1e6299dacb29b7f90eb42f8cfaba349", size = 559846, upload-time = "2026-10-06T03:43:18.776Z" }, ] [[package]] @@ -1013,7 +1013,7 @@ sandbox = [ [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.87.0" }, - { name = "apodex-agent-core", specifier = "==0.12.2" }, + { name = "apodex-agent-core", specifier = "==0.14.1" }, { name = "browserbase", marker = "extra == 'plugins'", specifier = ">=0.1.0" }, { name = "datasets", marker = "extra == 'eval'", specifier = ">=4.8.4" }, { name = "e2b-code-interpreter", marker = "extra == 'sandbox'", specifier = ">=2.6.0" }, @@ -1363,8 +1363,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [