Skip to content

Commit b4e7b0f

Browse files
authored
ufs-runner-mcp: fix path sandbox symlink regression, override warning, and output dedup (#101)
* ufs-runner-mcp: fix path sandbox, honest overrides, output dedup, async blocking The path sandbox rejected every real RDHPCS mount (/scratch3, /work2/noaa, ...) since only the exact unnumbered prefix passed is_relative_to; fix by allowing a digit suffix on the path component right after root while still rejecting siblings like /work-attacker or /workfoo. ufs_create_experiment documented flat overrides (dt_ocean, rnday, ibc, ...) that have no matching {{placeholder}} in the schism_sandy_duck template, so they were silently dropped while the tool still reported success; now unmatched flat/namelist-group overrides and namelist write failures are tracked and surfaced as an explicit warning section in the tool output. collect_outputs globbed slurm-*.out separately even though the existing "*.out" pattern already matches it, double-counting every Slurm log; dedupe by resolved path instead. All async tools called blocking UfsRunner methods (sbatch/sacct/scancel subprocess calls, shutil.copytree, read_text/write_text, rglob) directly, stalling the event loop for up to 30s per Slurm call; wrap those calls in asyncio.to_thread at the tool layer. * ufs-runner-mcp: fix review findings in path sandbox, overrides, dedup Path sandbox: _prefix_matches resolved the input path but compared it against the raw-string prefix, so a symlinked allowed root (common on HPC sites, e.g. /work -> /lustre/work) got rejected — the same class of "rejects real HPC mounts" bug the previous commit was meant to fix, reintroduced via the digit-suffix matching logic. Now try two checks: numbered-mount leniency on the raw prefix (unchanged, so /scratch3 style siblings still work even if the base prefix is itself a symlink) and symlink-transparent containment on the resolved prefix (fixes the regression, and also resolves a relative env-supplied prefix against cwd instead of silently no-op'ing). A configured prefix that resolves to just "/" is now dropped with a warning instead of silently doing nothing or becoming an accidental allow-all. Override honesty: unmatched_flat_keys was computed against only the keys that hit a literal {{placeholder}}, so overriding ocn_tasks/atm_tasks — which drive OCN_petlist_bounds and total_tasks through _compute_derived_vars without ever appearing as a placeholder themselves — was falsely reported as having no effect, contradicting the feature's own intent. Seed the matched-keys set with what _compute_derived_vars actually consumes. collect_outputs dedup: keying on the resolved path collapsed distinct, both-meaningful directory entries that happen to point at the same file (e.g. latest.nc -> history.nc), silently dropping one from the listing. Key on the path relative to run_dir instead — this still catches the original slurm-*.out double-count (same directory entry matched twice) without merging legitimate symlinks. ufs_list_templates still did blocking filesystem I/O directly in an async function, unlike every other tool in this file. Move the logic into a UfsRunner.list_templates method and call it via asyncio.to_thread, matching the existing pattern. Each fix was reproduced against the pre-fix code and reverified after; 6 new tests cover the symlink/relative/root-prefix path cases, the ocn_tasks-alone override, and the symlinked-output listing.
1 parent 3db7544 commit b4e7b0f

6 files changed

Lines changed: 558 additions & 54 deletions

File tree

servers/ufs-runner-mcp/src/ufs_runner_mcp/models.py

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
from __future__ import annotations
44

5+
import logging
56
import os
67
import re
78
from enum import Enum
89
from pathlib import Path
910

11+
logger = logging.getLogger(__name__)
12+
1013

1114
class ModelType(str, Enum):
1215
"""Supported UFS-Coastal model configurations."""
@@ -25,27 +28,115 @@ class ModelType(str, Enum):
2528

2629

2730
def get_allowed_prefixes() -> list[str]:
28-
"""Return allowed path prefixes, including any from UFS_RUNNER_ALLOWED_PATHS."""
31+
"""Return allowed path prefixes, including any from UFS_RUNNER_ALLOWED_PATHS.
32+
33+
A configured entry that resolves down to just the filesystem root (e.g.
34+
a literal "/", or a relative value whose parent chain bottoms out there)
35+
would match *every* absolute path via the symlink-transparent containment
36+
check in _prefix_matches — silently disabling the sandbox instead of
37+
adding a narrower allowance. That's essentially never the intent (no
38+
admin means to allow the whole filesystem by setting this), so such an
39+
entry is dropped with a loud warning rather than silently honored or
40+
silently ignored.
41+
"""
2942
extra = os.environ.get("UFS_RUNNER_ALLOWED_PATHS", "")
3043
prefixes = list(_ALLOWED_PATH_PREFIXES)
3144
if extra.strip():
32-
prefixes.extend(p.strip() for p in extra.split(":") if p.strip())
45+
for raw in extra.split(":"):
46+
candidate = raw.strip()
47+
if not candidate:
48+
continue
49+
resolved_candidate = Path(candidate).resolve()
50+
if len(resolved_candidate.parts) < 2:
51+
logger.warning(
52+
"UFS_RUNNER_ALLOWED_PATHS entry %r resolves to the "
53+
"filesystem root (%s); ignoring it instead of allowing "
54+
"every path. Configure a specific subdirectory instead.",
55+
candidate,
56+
resolved_candidate,
57+
)
58+
continue
59+
prefixes.append(candidate)
3360
return prefixes
3461

3562

63+
def _prefix_matches(resolved: Path, prefix: str) -> bool:
64+
"""Check whether *resolved* falls under *prefix*.
65+
66+
Two independent checks are tried; either is sufficient:
67+
68+
1. Numbered-mount leniency, on the RAW (unresolved) prefix. Real RDHPCS
69+
mounts are numbered (/scratch3, /scratch4, /work2/noaa, ...), so a
70+
plain is_relative_to(prefix) check (or the raw-string-prefix check it
71+
replaced) rejects every genuine mount except the exact, un-numbered
72+
name. Only the path *component immediately after the filesystem root*
73+
is given digit-suffix leniency — e.g. prefix "/scratch" matches
74+
resolved component "scratch5" — via re.fullmatch on that single
75+
component, so "work-attacker" / "workshop" / "scratchpad-evil" still
76+
fail (they are not the base name plus only digits). Any deeper
77+
components of the prefix (e.g. the "noaa" in a hypothetical
78+
"/work/noaa") must still match exactly, same as the original
79+
is_relative_to check.
80+
81+
This is deliberately checked on the prefix's raw string components,
82+
not resolved ones: numbered siblings (e.g. /work2) are separate
83+
mounts, not reachable through whatever symlink /work itself might be
84+
on a given site, so resolving the prefix first would compare against
85+
the symlink's *target* name instead of "work" and silently break this
86+
leniency.
87+
88+
2. Symlink-transparent containment, on the RESOLVED prefix. Many real
89+
HPC sites symlink an allowed root itself (e.g. /work -> /lustre/work,
90+
/scratch -> /gpfs/scratch, or a custom env-configured prefix pointing
91+
at a symlink). The input path is already resolved by the caller, so
92+
also resolve the prefix and check plain containment in that resolved
93+
space — the same principle as the original pre-numbered-mount code
94+
(is_relative_to), which resolved both sides. This also transparently
95+
handles a *relative* env-supplied prefix, since Path.resolve() anchors
96+
it against the current working directory instead of leaving it as a
97+
silent no-op. A prefix that resolves to just the filesystem root is
98+
never treated as a match here (see get_allowed_prefixes).
99+
"""
100+
resolved_parts = resolved.parts
101+
102+
# 1. numbered-mount leniency, compared on the raw prefix
103+
raw_prefix_parts = Path(prefix).parts
104+
if len(raw_prefix_parts) >= 2 and len(resolved_parts) >= 2:
105+
base_name = raw_prefix_parts[1]
106+
pattern = re.compile(rf"^{re.escape(base_name)}\d*$")
107+
if pattern.fullmatch(resolved_parts[1]):
108+
remaining_prefix_parts = raw_prefix_parts[2:]
109+
candidate = resolved_parts[2 : 2 + len(remaining_prefix_parts)]
110+
if not remaining_prefix_parts or list(candidate) == list(
111+
remaining_prefix_parts
112+
):
113+
return True
114+
115+
# 2. symlink-transparent containment, compared on the resolved prefix
116+
resolved_prefix_parts = Path(prefix).resolve().parts
117+
if len(resolved_prefix_parts) >= 2 and len(resolved_parts) >= len(
118+
resolved_prefix_parts
119+
):
120+
if resolved_parts[: len(resolved_prefix_parts)] == resolved_prefix_parts:
121+
return True
122+
123+
return False
124+
125+
36126
def validate_path(path: str, label: str = "path") -> str | None:
37127
"""Validate that *path* is under an allowed path prefix.
38128
39129
Returns None if valid, or an error message if not.
40130
"""
41131
# Compare on path-component boundaries, not raw string prefix:
42132
# str.startswith("/work") wrongly accepted "/work-attacker", "/workshop",
43-
# "/scratchpad-evil" — a full sandbox escape. is_relative_to() (which is
44-
# true for an exact match too) only accepts genuine descendants.
133+
# "/scratchpad-evil" — a full sandbox escape. _prefix_matches (like
134+
# is_relative_to, which it replaces) only accepts genuine descendants,
135+
# while still tolerating numbered HPC mounts (see its docstring).
45136
resolved = Path(path).resolve()
46137
prefixes = get_allowed_prefixes()
47138
for prefix in prefixes:
48-
if resolved.is_relative_to(Path(prefix).resolve()):
139+
if _prefix_matches(resolved, prefix):
49140
return None
50141
allowed = ", ".join(prefixes)
51142
return (

0 commit comments

Comments
 (0)