CoolFace
Apppublic

apodex/frontier-agent-demo

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
14likes
_path_auth.py320 linesDownload Raw Back to tools
1"""Local path authorization — the fail-closed gate for host filesystem access."""2 3from __future__ import annotations4 5import logging6import os7import re8from collections.abc import Callable9from pathlib import Path10 11from frontier_agent.core.execution_context import get_current_execution_scope12 13logger = logging.getLogger(__name__)14 15_SERVICE_CHECKOUT_ROOT = Path(__file__).resolve().parents[2]16 17# Allowed directory prefixes (relative paths for project dirs, absolute for output)18_ALLOWED_RELATIVE_PREFIXES = [19    "plugins/skills/",20    "data/",21]22 23#: The operator-curated skills tree. Its symlinks are trusted (see24#: :func:`_candidate_paths`), so it is named once rather than spelled inline.25_SKILLS_DIR = str(_SERVICE_CHECKOUT_ROOT / "plugins" / "skills")26 27_ALLOWED_ABSOLUTE_PREFIXES = [28    "/tmp/agent-outputs/",29    _SKILLS_DIR,30]31 32# Blocked file names (security). Matched on the BASENAME at word level rather33# than as a substring of the whole path: a substring test also refused every34# path under a directory that merely contains one of these words, plus names35# like ``tokenizer_config.json``, ``secretary_notes.md`` and ``deck.keynote``.36_BLOCKED_NAME_WORDS = frozenset({37    "credential", "credentials", "secret", "secrets",38    "password", "passwords", "token", "tokens",39})40_BLOCKED_SUFFIXES = (".key", ".pem", ".cert")41_WORD_SPLIT = re.compile(r"[^a-z0-9]+")42 43 44def _configured_workspace_root() -> Path | None:45    """Return the explicit workspace root, if one was configured for this task."""46    scope = get_current_execution_scope()47    metadata = scope.metadata if scope else {}48    raw_root = (49        str(metadata.get("coding_workspace_root") or metadata.get("workspace_root") or "").strip()50        or os.getenv("CODING_WORKSPACE_ROOT", "").strip()51    )52    if not raw_root:53        return None54 55    workspace_root = Path(raw_root).expanduser().resolve()56    if not workspace_root.is_dir():57        logger.warning("Ignoring invalid CODING_WORKSPACE_ROOT '%s'", raw_root)58        return None59    return workspace_root60 61 62def _is_isolated_workspace_root(workspace_root: Path) -> bool:63    """Host writes are only allowed for workspaces outside the service checkout."""64    service_root = _SERVICE_CHECKOUT_ROOT.resolve()65    try:66        workspace_root.relative_to(service_root)67        return False68    except ValueError:69        pass70 71    try:72        service_root.relative_to(workspace_root)73        return False74    except ValueError:75        return True76 77 78def _candidate_paths(79    file_path: str, workspace_root: Path | None, *, write_access: bool = False,80) -> list[Path]:81    """Resolve a path against the explicit workspace root before falling back locally."""82    raw_path = Path(file_path)83    candidates: list[Path] = []84    if raw_path.is_absolute():85        resolved = raw_path.resolve()86        if resolved == raw_path:87            return [resolved]88        # A symlink may only WIDEN access from inside the operator-curated89        # plugins/skills/ tree, whose links deliberately point at SKILL.md90        # bodies outside the project — and then only for READS, since nothing91        # in that tree is a write target. Everywhere else (and for every write)92        # the resolved target is the only candidate: the task workspace is93        # model-writable, so also accepting the unresolved path there let the94        # model ``ln -s ~/.ssh`` into the workspace and read the target95        # straight back through this gate. That reaches past bubblewrap too —96        # it jails ``bash``, while the file tools do in-process host IO, so97        # this gate is their only boundary.98        #99        # The resolved form comes FIRST so the blocked-name check in100        # :func:`_authorized_local_path` sees the real target: ordered the other101        # way, a curated ``SKILL.md -> .env.prod`` link would be judged by the102        # link's own harmless name.103        if _is_skill_path(raw_path) and not write_access:104            return [resolved, raw_path]105        return [resolved]106 107    if workspace_root is not None:108        candidates.append((workspace_root / raw_path).resolve())109    candidates.append(raw_path.resolve())110 111    deduped: list[Path] = []112    seen: set[str] = set()113    for candidate in candidates:114        key = str(candidate)115        if key in seen:116            continue117        seen.add(key)118        deduped.append(candidate)119    return deduped120 121 122def _is_skill_path(path: Path) -> bool:123    """True for a path inside the operator-curated ``plugins/skills/`` tree."""124    return _path_within(str(path), _resolve_prefix(_SKILLS_DIR))125 126 127def _blocked_name(name: str) -> str:128    """The blocked pattern *name* trips, else ``""``.129 130    Word-level so ``token`` refuses ``api_token.txt`` but not131    ``tokenizer_config.json``, and suffix-level so ``.key`` refuses132    ``server.key`` but not ``deck.keynote``.133    """134    lower = name.lower()135    if lower == ".env" or lower.startswith(".env."):136        return ".env"137    for suffix in _BLOCKED_SUFFIXES:138        if lower.endswith(suffix):139            return suffix140    for word in _WORD_SPLIT.split(lower):141        if word in _BLOCKED_NAME_WORDS:142            return word143    return ""144 145 146def _authorized_local_path(file_path: str, *, write_access: bool = False) -> tuple[Path | None, str]:147    """Resolve a local path and verify it stays inside approved prefixes."""148    normalized = os.path.normpath(file_path)149 150    # Block path traversal151    if ".." in normalized:152        return None, "Path traversal (..) is not allowed"153 154    workspace_root = _configured_workspace_root()155    all_prefixes = _allowed_local_prefixes(156        write_access=write_access,157        workspace_root=workspace_root,158    )159    # The blocked-name test must see the REAL target, so it runs against the160    # fully-resolved name as well as the candidate's own. A curated161    # ``plugins/skills/x/SKILL.md -> .env.prod`` link is authorized through the162    # unresolved candidate (that is the point of the exception), and judging163    # only that candidate would let the link's harmless name stand in for the164    # secret it points at.165    target_name = Path(normalized).resolve().name166    for candidate in _candidate_paths(167        normalized, workspace_root, write_access=write_access,168    ):169        resolved = str(candidate)170        for prefix in all_prefixes:171            prefix_resolved = _resolve_prefix(prefix)172            if _path_within(resolved, prefix_resolved):173                blocked = _blocked_name(candidate.name) or _blocked_name(target_name)174                if blocked:175                    return None, (176                        f"Access to files matching '{blocked}' is blocked for security"177                    )178                return candidate, ""179 180    return None, f"Access restricted. Allowed directories: {', '.join(all_prefixes)}"181 182 183def _is_path_allowed(file_path: str, *, write_access: bool = False) -> tuple[bool, str]:184    """Check if a local path is allowed for the requested access mode."""185    resolved_path, reason = _authorized_local_path(file_path, write_access=write_access)186    return resolved_path is not None, reason187 188 189def _resolve_prefix(prefix: str) -> str:190    """Resolve an allowed prefix to an absolute, symlink-resolved path.191 192    Uses Path.resolve() for all paths so that symlink targets match193    (e.g., on macOS /tmp → /private/tmp).194    """195    return str(Path(prefix).resolve())196 197 198def _path_within(path: str, prefix: str) -> bool:199    """Check whether a resolved path is inside a prefix."""200    norm_path = os.path.normpath(path)201    norm_prefix = os.path.normpath(prefix)202    return norm_path == norm_prefix or norm_path.startswith(norm_prefix + os.sep)203 204 205def _resolve_inputs_dir() -> Path | None:206    """Return the mounted read-only ``/inputs`` dir, but only when it exists.207 208    Task input files are bind-mounted read-only at ``/inputs`` (container/serve209    mode; overridable via ``FRONTIER_AGENT_INPUTS_DIR``). Gating on the dir actually210    existing means non-container runs (local bwrap, tests — no ``/inputs``) get211    no new prefix and are unaffected. Imported lazily to avoid an import cycle212    with ``_sandbox``.213    """214    try:215        from plugins.tools._sandbox import resolve_mount_dirs216 217        inputs_dir = Path(resolve_mount_dirs()[2]).expanduser().resolve()218    except Exception:219        return None220    return inputs_dir if inputs_dir.is_dir() else None221 222 223def task_input_matcher() -> Callable[[str | Path], bool]:224    """Resolve the read-only input root once and return a per-path predicate.225 226    Task inputs may intentionally live below a repository-ignored runtime227    directory (for example ``.apodex/``). Search tools use this signal to avoid228    applying repository ignore rules to the separately-authorized input mount;229    normal path authorization and per-result symlink checks still apply.230 231    The root lookup imports ``_sandbox``, reads the environment and stats the232    mount, so it must not run once per candidate file: a search over a large233    checkout would spend more time re-deriving a constant than reading files.234    Runs with no input mount get a predicate that costs nothing at all.235    """236    inputs_dir = _resolve_inputs_dir()237    if inputs_dir is None:238        return lambda _file_path: False239    root = str(inputs_dir)240 241    def _within(file_path: str | Path) -> bool:242        try:243            candidate = Path(file_path).expanduser().resolve()244        except (OSError, RuntimeError):245            return False246        return _path_within(str(candidate), root)247 248    return _within249 250 251def _resolve_spill_dirs() -> list[Path]:252    """Return the spill directories this conversation may read.253 254    Authorized for READ so ``read_file`` / ``grep_search`` can recover a body255    compaction dropped, and never for write — the same shape as ``/inputs``. The256    canonical ``/spill`` path a model sees is rewritten to this by257    ``resolve_runtime_path`` before it reaches here. Gating on existence means a258    run that never spilled adds no prefix. Imported lazily to avoid an import259    cycle with ``_sandbox``.260    """261    try:262        from plugins.tools._overflow import _created_stores, _current_task_id263        from plugins.tools._overflow import _scope_component as scope_of264        from plugins.tools._sandbox import spill_root265 266        root = spill_root()267    except Exception:268        return []269    if not root.is_dir():270        return []271 272    # Narrower than the root on purpose. The root is shared — a temp directory,273    # or a run directory — so authorizing it would let one conversation read274    # another's spilled tool results, which the old in-workspace layout made275    # impossible. Two things are authorized instead:276    #277    #   * this conversation's own scope, which is what its recovery index names;278    #   * every store THIS process created, because in-process sub-agents spill279    #     under their own scope and a fan-in report can carry one of those paths280    #     back to the parent.281    #282    # A different session in a different process matches neither.283    allowed: list[Path] = []284    scope = scope_of(_current_task_id())285    if scope and (root / scope).is_dir():286        allowed.append(root / scope)287    allowed.extend(store for store in _created_stores if store.is_dir())288    return allowed289 290 291def _allowed_local_prefixes(292    *,293    write_access: bool = False,294    workspace_root: Path | None = None,295) -> list[str]:296    """Return the local path prefixes allowed in the current execution context."""297    prefixes = list(_ALLOWED_RELATIVE_PREFIXES) + list(_ALLOWED_ABSOLUTE_PREFIXES)298    resolved_workspace_root = workspace_root or _configured_workspace_root()299    if resolved_workspace_root is not None:300        if write_access and not _is_isolated_workspace_root(resolved_workspace_root):301            logger.warning(302                "Refusing local write access to non-isolated workspace root '%s'",303                resolved_workspace_root,304            )305        else:306            prefixes.append(str(resolved_workspace_root))307    # Uploaded task inputs live under a read-only ``/inputs`` mount. Authorize308    # them for READ so grep_search / glob_search / read_text can list and search309    # them; never for write (the mount is read-only).310    if not write_access:311        inputs_dir = _resolve_inputs_dir()312        if inputs_dir is not None:313            prefixes.append(str(inputs_dir))314        # Recovery reads of spilled tool results. READ ONLY, and deliberately315        # absent from the write branch: that omission is what makes the store316        # read-only to every file tool, replacing a special case each writer had317        # to remember.318        prefixes.extend(str(path) for path in _resolve_spill_dirs())319    return prefixes320