Spaces:
Running
Running
File size: 12,744 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | """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
|