|
66 | 66 | ".wget-hsts", |
67 | 67 | ) |
68 | 68 |
|
| 69 | +# Characters that make a criterion `path` eligible for glob expansion. Eligible, |
| 70 | +# not automatic: `Sandbox.resolve_files` tries the literal path first. |
| 71 | +_GLOB_METACHARACTERS = "*?[" |
| 72 | + |
| 73 | +# Cap on how many matches an ambiguity error enumerates. The message is |
| 74 | +# persisted to task.json and injected into judge prompts, so an unbounded |
| 75 | +# listing over a wide pattern is a real payload. |
| 76 | +_MAX_LISTED_MATCHES = 10 |
| 77 | + |
| 78 | + |
| 79 | +def _is_glob(path: str) -> bool: |
| 80 | + """Return whether ``path`` contains a glob metacharacter.""" |
| 81 | + return any(c in path for c in _GLOB_METACHARACTERS) |
| 82 | + |
| 83 | + |
| 84 | +def _format_matches(matches: list[Path], root: Path) -> str: |
| 85 | + """Render matches as sandbox-relative paths, truncated to a bounded list.""" |
| 86 | + listed = ", ".join(str(p.relative_to(root)) for p in matches[:_MAX_LISTED_MATCHES]) |
| 87 | + remaining = len(matches) - _MAX_LISTED_MATCHES |
| 88 | + return f"{listed}, +{remaining} more" if remaining > 0 else listed |
| 89 | + |
69 | 90 |
|
70 | 91 | def _grant_read_traverse(root: Path) -> None: |
71 | 92 | """Recursively apply ``chmod a+rX`` semantics under ``root``. |
@@ -1093,38 +1114,121 @@ def run_command(self, command: str, timeout: float | int | None = None) -> tuple |
1093 | 1114 | # needs filesystem access beyond the sandbox root (e.g., reading installed packages, |
1094 | 1115 | # system headers). Path traversal protection is handled at the agent permission level. |
1095 | 1116 |
|
| 1117 | + def resolve_files(self, path: str) -> list[Path]: |
| 1118 | + """Resolve a criterion ``path`` to the sandbox files it addresses. |
| 1119 | +
|
| 1120 | + A path that names an existing file or directory resolves to itself, |
| 1121 | + **even when it contains a glob metacharacter** — a real file called |
| 1122 | + ``report[2024].json`` is graded as itself rather than reinterpreted as |
| 1123 | + a character class that would silently match ``report2.json``. Only when |
| 1124 | + the literal does not exist is a path containing ``*``, ``?`` or ``[`` |
| 1125 | + expanded against the sandbox root, so a criterion can address a file |
| 1126 | + whose exact location the task prompt does not pin — e.g. ``**/*.flow`` |
| 1127 | + matches a scaffolded wrapper directory the agent was free to name. |
| 1128 | +
|
| 1129 | + Glob matches are filtered through the sandbox's ignore patterns |
| 1130 | + (``.venv``, ``node_modules``, ``dist``, … — see |
| 1131 | + :func:`~coder_eval.resources.get_ignore_patterns`), because the sandbox |
| 1132 | + root holds harness-created content the agent never authored and |
| 1133 | + grading off it is neither fair nor deterministic. Only path segments |
| 1134 | + the glob *discovered* are filtered: a segment the pattern names |
| 1135 | + literally (``dist/**/*.js``) is an explicit opt-in and survives. |
| 1136 | + Matches are sorted so grading is deterministic, and directories are |
| 1137 | + dropped so a glob cannot resolve to something unreadable. |
| 1138 | +
|
| 1139 | + Args: |
| 1140 | + path: Relative path or glob pattern |
| 1141 | +
|
| 1142 | + Returns: |
| 1143 | + Sorted matching files; empty when nothing matches |
| 1144 | + """ |
| 1145 | + if not self.sandbox_dir: |
| 1146 | + return [] |
| 1147 | + |
| 1148 | + # Literal first: an existing path is never reinterpreted as a pattern. |
| 1149 | + candidate = self.sandbox_dir / path |
| 1150 | + if candidate.exists(): |
| 1151 | + return [candidate] |
| 1152 | + |
| 1153 | + if not _is_glob(path): |
| 1154 | + return [] |
| 1155 | + |
| 1156 | + patterns = get_ignore_patterns(self.config.ignore_patterns) |
| 1157 | + pinned = {segment for segment in path.split("/") if segment and not _is_glob(segment)} |
| 1158 | + |
| 1159 | + matches: list[Path] = [] |
| 1160 | + for match in self.sandbox_dir.glob(path): |
| 1161 | + if not match.is_file(): |
| 1162 | + continue |
| 1163 | + discovered = [part for part in match.relative_to(self.sandbox_dir).parts if part not in pinned] |
| 1164 | + if discovered and should_ignore_path(Path(*discovered), patterns): |
| 1165 | + continue |
| 1166 | + matches.append(match) |
| 1167 | + |
| 1168 | + return sorted(matches) |
| 1169 | + |
| 1170 | + def resolved_path_label(self, path: str) -> str | None: |
| 1171 | + """Sandbox-relative path a glob resolved to, for grading transparency. |
| 1172 | +
|
| 1173 | + With exactly-one-match semantics on content reads, *which* file was |
| 1174 | + graded is most of the signal. Returns ``None`` for a literal path |
| 1175 | + (nothing was inferred) and for a pattern that did not resolve to |
| 1176 | + exactly one file. |
| 1177 | +
|
| 1178 | + Args: |
| 1179 | + path: Relative path or glob pattern |
| 1180 | +
|
| 1181 | + Returns: |
| 1182 | + Sandbox-relative path of the single match, or ``None`` |
| 1183 | + """ |
| 1184 | + if not self.sandbox_dir or not _is_glob(path): |
| 1185 | + return None |
| 1186 | + |
| 1187 | + matches = self.resolve_files(path) |
| 1188 | + if len(matches) != 1: |
| 1189 | + return None |
| 1190 | + |
| 1191 | + return str(matches[0].relative_to(self.sandbox_dir)) |
| 1192 | + |
1096 | 1193 | def get_file_content(self, path: str) -> str: |
1097 | 1194 | """Read the content of a file in the sandbox. |
1098 | 1195 |
|
1099 | 1196 | Args: |
1100 | | - path: Relative path to the file |
| 1197 | + path: Relative path to the file, or a glob pattern matching exactly |
| 1198 | + one file |
1101 | 1199 |
|
1102 | 1200 | Returns: |
1103 | 1201 | File content as string |
1104 | 1202 |
|
1105 | 1203 | Raises: |
1106 | 1204 | RuntimeError: If sandbox is not set up |
1107 | | - FileNotFoundError: If file doesn't exist |
| 1205 | + FileNotFoundError: If nothing matches ``path`` |
| 1206 | + ValueError: If a glob matches more than one file |
1108 | 1207 | """ |
1109 | 1208 | if not self.sandbox_dir: |
1110 | 1209 | raise RuntimeError("Sandbox not set up") |
1111 | 1210 |
|
1112 | | - file_path = self.sandbox_dir / path |
1113 | | - return file_path.read_text(encoding="utf-8") |
| 1211 | + matches = self.resolve_files(path) |
| 1212 | + if not matches: |
| 1213 | + raise FileNotFoundError(f"No file matches '{path}' in the sandbox") |
| 1214 | + if len(matches) > 1: |
| 1215 | + raise ValueError( |
| 1216 | + f"Pattern '{path}' matches {len(matches)} files — refusing to guess which to grade: " |
| 1217 | + + _format_matches(matches, self.sandbox_dir) |
| 1218 | + ) |
| 1219 | + |
| 1220 | + return matches[0].read_text(encoding="utf-8") |
1114 | 1221 |
|
1115 | 1222 | def file_exists(self, path: str) -> bool: |
1116 | 1223 | """Check if a file exists in the sandbox. |
1117 | 1224 |
|
1118 | 1225 | Args: |
1119 | | - path: Relative path to the file |
| 1226 | + path: Relative path to the file, or a glob pattern |
1120 | 1227 |
|
1121 | 1228 | Returns: |
1122 | | - True if file exists, False otherwise |
| 1229 | + True if at least one file matches, False otherwise |
1123 | 1230 | """ |
1124 | | - if not self.sandbox_dir: |
1125 | | - return False |
1126 | | - |
1127 | | - return (self.sandbox_dir / path).exists() |
| 1231 | + return bool(self.resolve_files(path)) |
1128 | 1232 |
|
1129 | 1233 | def list_files(self, path: str = ".") -> list[str]: |
1130 | 1234 | """List files in a directory within the sandbox. |
|
0 commit comments