Spaces:
Running
Running
| """Local path authorization — the fail-closed gate for host filesystem access.""" | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import re | |
| from collections.abc import Callable | |
| from pathlib import Path | |
| from frontier_agent.core.execution_context import get_current_execution_scope | |
| logger = logging.getLogger(__name__) | |
| _SERVICE_CHECKOUT_ROOT = Path(__file__).resolve().parents[2] | |
| # Allowed directory prefixes (relative paths for project dirs, absolute for output) | |
| _ALLOWED_RELATIVE_PREFIXES = [ | |
| "plugins/skills/", | |
| "data/", | |
| ] | |
| #: The operator-curated skills tree. Its symlinks are trusted (see | |
| #: :func:`_candidate_paths`), so it is named once rather than spelled inline. | |
| _SKILLS_DIR = str(_SERVICE_CHECKOUT_ROOT / "plugins" / "skills") | |
| _ALLOWED_ABSOLUTE_PREFIXES = [ | |
| "/tmp/agent-outputs/", | |
| _SKILLS_DIR, | |
| ] | |
| # Blocked file names (security). Matched on the BASENAME at word level rather | |
| # than as a substring of the whole path: a substring test also refused every | |
| # path under a directory that merely contains one of these words, plus names | |
| # like ``tokenizer_config.json``, ``secretary_notes.md`` and ``deck.keynote``. | |
| _BLOCKED_NAME_WORDS = frozenset({ | |
| "credential", "credentials", "secret", "secrets", | |
| "password", "passwords", "token", "tokens", | |
| }) | |
| _BLOCKED_SUFFIXES = (".key", ".pem", ".cert") | |
| _WORD_SPLIT = re.compile(r"[^a-z0-9]+") | |
| def _configured_workspace_root() -> Path | None: | |
| """Return the explicit workspace root, if one was configured for this task.""" | |
| scope = get_current_execution_scope() | |
| metadata = scope.metadata if scope else {} | |
| raw_root = ( | |
| str(metadata.get("coding_workspace_root") or metadata.get("workspace_root") or "").strip() | |
| or os.getenv("CODING_WORKSPACE_ROOT", "").strip() | |
| ) | |
| if not raw_root: | |
| return None | |
| workspace_root = Path(raw_root).expanduser().resolve() | |
| if not workspace_root.is_dir(): | |
| logger.warning("Ignoring invalid CODING_WORKSPACE_ROOT '%s'", raw_root) | |
| return None | |
| return workspace_root | |
| def _is_isolated_workspace_root(workspace_root: Path) -> bool: | |
| """Host writes are only allowed for workspaces outside the service checkout.""" | |
| service_root = _SERVICE_CHECKOUT_ROOT.resolve() | |
| try: | |
| workspace_root.relative_to(service_root) | |
| return False | |
| except ValueError: | |
| pass | |
| try: | |
| service_root.relative_to(workspace_root) | |
| return False | |
| except ValueError: | |
| return True | |
| def _candidate_paths( | |
| file_path: str, workspace_root: Path | None, *, write_access: bool = False, | |
| ) -> list[Path]: | |
| """Resolve a path against the explicit workspace root before falling back locally.""" | |
| raw_path = Path(file_path) | |
| candidates: list[Path] = [] | |
| if raw_path.is_absolute(): | |
| resolved = raw_path.resolve() | |
| if resolved == raw_path: | |
| return [resolved] | |
| # A symlink may only WIDEN access from inside the operator-curated | |
| # plugins/skills/ tree, whose links deliberately point at SKILL.md | |
| # bodies outside the project — and then only for READS, since nothing | |
| # in that tree is a write target. Everywhere else (and for every write) | |
| # the resolved target is the only candidate: the task workspace is | |
| # model-writable, so also accepting the unresolved path there let the | |
| # model ``ln -s ~/.ssh`` into the workspace and read the target | |
| # straight back through this gate. That reaches past bubblewrap too — | |
| # it jails ``bash``, while the file tools do in-process host IO, so | |
| # this gate is their only boundary. | |
| # | |
| # The resolved form comes FIRST so the blocked-name check in | |
| # :func:`_authorized_local_path` sees the real target: ordered the other | |
| # way, a curated ``SKILL.md -> .env.prod`` link would be judged by the | |
| # link's own harmless name. | |
| if _is_skill_path(raw_path) and not write_access: | |
| return [resolved, raw_path] | |
| return [resolved] | |
| if workspace_root is not None: | |
| candidates.append((workspace_root / raw_path).resolve()) | |
| candidates.append(raw_path.resolve()) | |
| deduped: list[Path] = [] | |
| seen: set[str] = set() | |
| for candidate in candidates: | |
| key = str(candidate) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| deduped.append(candidate) | |
| return deduped | |
| def _is_skill_path(path: Path) -> bool: | |
| """True for a path inside the operator-curated ``plugins/skills/`` tree.""" | |
| return _path_within(str(path), _resolve_prefix(_SKILLS_DIR)) | |
| def _blocked_name(name: str) -> str: | |
| """The blocked pattern *name* trips, else ``""``. | |
| Word-level so ``token`` refuses ``api_token.txt`` but not | |
| ``tokenizer_config.json``, and suffix-level so ``.key`` refuses | |
| ``server.key`` but not ``deck.keynote``. | |
| """ | |
| lower = name.lower() | |
| if lower == ".env" or lower.startswith(".env."): | |
| return ".env" | |
| for suffix in _BLOCKED_SUFFIXES: | |
| if lower.endswith(suffix): | |
| return suffix | |
| for word in _WORD_SPLIT.split(lower): | |
| if word in _BLOCKED_NAME_WORDS: | |
| return word | |
| return "" | |
| def _authorized_local_path(file_path: str, *, write_access: bool = False) -> tuple[Path | None, str]: | |
| """Resolve a local path and verify it stays inside approved prefixes.""" | |
| normalized = os.path.normpath(file_path) | |
| # Block path traversal | |
| if ".." in normalized: | |
| return None, "Path traversal (..) is not allowed" | |
| workspace_root = _configured_workspace_root() | |
| all_prefixes = _allowed_local_prefixes( | |
| write_access=write_access, | |
| workspace_root=workspace_root, | |
| ) | |
| # The blocked-name test must see the REAL target, so it runs against the | |
| # fully-resolved name as well as the candidate's own. A curated | |
| # ``plugins/skills/x/SKILL.md -> .env.prod`` link is authorized through the | |
| # unresolved candidate (that is the point of the exception), and judging | |
| # only that candidate would let the link's harmless name stand in for the | |
| # secret it points at. | |
| target_name = Path(normalized).resolve().name | |
| for candidate in _candidate_paths( | |
| normalized, workspace_root, write_access=write_access, | |
| ): | |
| resolved = str(candidate) | |
| for prefix in all_prefixes: | |
| prefix_resolved = _resolve_prefix(prefix) | |
| if _path_within(resolved, prefix_resolved): | |
| blocked = _blocked_name(candidate.name) or _blocked_name(target_name) | |
| if blocked: | |
| return None, ( | |
| f"Access to files matching '{blocked}' is blocked for security" | |
| ) | |
| return candidate, "" | |
| return None, f"Access restricted. Allowed directories: {', '.join(all_prefixes)}" | |
| def _is_path_allowed(file_path: str, *, write_access: bool = False) -> tuple[bool, str]: | |
| """Check if a local path is allowed for the requested access mode.""" | |
| resolved_path, reason = _authorized_local_path(file_path, write_access=write_access) | |
| return resolved_path is not None, reason | |
| def _resolve_prefix(prefix: str) -> str: | |
| """Resolve an allowed prefix to an absolute, symlink-resolved path. | |
| Uses Path.resolve() for all paths so that symlink targets match | |
| (e.g., on macOS /tmp → /private/tmp). | |
| """ | |
| return str(Path(prefix).resolve()) | |
| def _path_within(path: str, prefix: str) -> bool: | |
| """Check whether a resolved path is inside a prefix.""" | |
| norm_path = os.path.normpath(path) | |
| norm_prefix = os.path.normpath(prefix) | |
| return norm_path == norm_prefix or norm_path.startswith(norm_prefix + os.sep) | |
| def _resolve_inputs_dir() -> Path | None: | |
| """Return the mounted read-only ``/inputs`` dir, but only when it exists. | |
| Task input files are bind-mounted read-only at ``/inputs`` (container/serve | |
| mode; overridable via ``FRONTIER_AGENT_INPUTS_DIR``). Gating on the dir actually | |
| existing means non-container runs (local bwrap, tests — no ``/inputs``) get | |
| no new prefix and are unaffected. Imported lazily to avoid an import cycle | |
| with ``_sandbox``. | |
| """ | |
| try: | |
| from plugins.tools._sandbox import resolve_mount_dirs | |
| inputs_dir = Path(resolve_mount_dirs()[2]).expanduser().resolve() | |
| except Exception: | |
| return None | |
| return inputs_dir if inputs_dir.is_dir() else None | |
| def task_input_matcher() -> Callable[[str | Path], bool]: | |
| """Resolve the read-only input root once and return a per-path predicate. | |
| Task inputs may intentionally live below a repository-ignored runtime | |
| directory (for example ``.apodex/``). Search tools use this signal to avoid | |
| applying repository ignore rules to the separately-authorized input mount; | |
| normal path authorization and per-result symlink checks still apply. | |
| The root lookup imports ``_sandbox``, reads the environment and stats the | |
| mount, so it must not run once per candidate file: a search over a large | |
| checkout would spend more time re-deriving a constant than reading files. | |
| Runs with no input mount get a predicate that costs nothing at all. | |
| """ | |
| inputs_dir = _resolve_inputs_dir() | |
| if inputs_dir is None: | |
| return lambda _file_path: False | |
| root = str(inputs_dir) | |
| def _within(file_path: str | Path) -> bool: | |
| try: | |
| candidate = Path(file_path).expanduser().resolve() | |
| except (OSError, RuntimeError): | |
| return False | |
| return _path_within(str(candidate), root) | |
| return _within | |
| def _resolve_spill_dirs() -> list[Path]: | |
| """Return the spill directories this conversation may read. | |
| Authorized for READ so ``read_file`` / ``grep_search`` can recover a body | |
| compaction dropped, and never for write — the same shape as ``/inputs``. The | |
| canonical ``/spill`` path a model sees is rewritten to this by | |
| ``resolve_runtime_path`` before it reaches here. Gating on existence means a | |
| run that never spilled adds no prefix. Imported lazily to avoid an import | |
| cycle with ``_sandbox``. | |
| """ | |
| try: | |
| from plugins.tools._overflow import _created_stores, _current_task_id | |
| from plugins.tools._overflow import _scope_component as scope_of | |
| from plugins.tools._sandbox import spill_root | |
| root = spill_root() | |
| except Exception: | |
| return [] | |
| if not root.is_dir(): | |
| return [] | |
| # Narrower than the root on purpose. The root is shared — a temp directory, | |
| # or a run directory — so authorizing it would let one conversation read | |
| # another's spilled tool results, which the old in-workspace layout made | |
| # impossible. Two things are authorized instead: | |
| # | |
| # * this conversation's own scope, which is what its recovery index names; | |
| # * every store THIS process created, because in-process sub-agents spill | |
| # under their own scope and a fan-in report can carry one of those paths | |
| # back to the parent. | |
| # | |
| # A different session in a different process matches neither. | |
| allowed: list[Path] = [] | |
| scope = scope_of(_current_task_id()) | |
| if scope and (root / scope).is_dir(): | |
| allowed.append(root / scope) | |
| allowed.extend(store for store in _created_stores if store.is_dir()) | |
| return allowed | |
| def _allowed_local_prefixes( | |
| *, | |
| write_access: bool = False, | |
| workspace_root: Path | None = None, | |
| ) -> list[str]: | |
| """Return the local path prefixes allowed in the current execution context.""" | |
| prefixes = list(_ALLOWED_RELATIVE_PREFIXES) + list(_ALLOWED_ABSOLUTE_PREFIXES) | |
| resolved_workspace_root = workspace_root or _configured_workspace_root() | |
| if resolved_workspace_root is not None: | |
| if write_access and not _is_isolated_workspace_root(resolved_workspace_root): | |
| logger.warning( | |
| "Refusing local write access to non-isolated workspace root '%s'", | |
| resolved_workspace_root, | |
| ) | |
| else: | |
| prefixes.append(str(resolved_workspace_root)) | |
| # Uploaded task inputs live under a read-only ``/inputs`` mount. Authorize | |
| # them for READ so grep_search / glob_search / read_text can list and search | |
| # them; never for write (the mount is read-only). | |
| if not write_access: | |
| inputs_dir = _resolve_inputs_dir() | |
| if inputs_dir is not None: | |
| prefixes.append(str(inputs_dir)) | |
| # Recovery reads of spilled tool results. READ ONLY, and deliberately | |
| # absent from the write branch: that omission is what makes the store | |
| # read-only to every file tool, replacing a special case each writer had | |
| # to remember. | |
| prefixes.extend(str(path) for path in _resolve_spill_dirs()) | |
| return prefixes | |