@miller-tech/uap 1.63.0 → 1.64.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/workdir_scope.py +11 -0
- package/tools/agents/scripts/anthropic_proxy.py +65 -12
- package/tools/agents/scripts/toolcall_path_normalizer.py +162 -0
- package/tools/agents/tests/test_path_containment.py +149 -0
package/package.json
CHANGED
|
Binary file
|
|
@@ -80,6 +80,14 @@ def _inside(target: Path, roots: list[Path]) -> bool:
|
|
|
80
80
|
return False
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _is_dev_node(p: Path) -> bool:
|
|
84
|
+
"""True for device pseudo-files under /dev (/dev/null, /dev/stderr,
|
|
85
|
+
/dev/stdout, /dev/fd/N, /dev/tty, /dev/zero, ...). Redirecting or writing
|
|
86
|
+
to these never escapes the project workspace — it's universal shell idiom
|
|
87
|
+
(`2>/dev/null`, `>/dev/stdout`) — so they are always in scope."""
|
|
88
|
+
return p == Path("/dev") or str(p).startswith("/dev/")
|
|
89
|
+
|
|
90
|
+
|
|
83
91
|
def _check_path(target: str, roots: list[Path]) -> str:
|
|
84
92
|
"""Return the offending absolute path if out of scope, else ''."""
|
|
85
93
|
if not target:
|
|
@@ -88,6 +96,9 @@ def _check_path(target: str, roots: list[Path]) -> str:
|
|
|
88
96
|
if not p.is_absolute():
|
|
89
97
|
# Relative paths resolve under the enforcer cwd (the project root).
|
|
90
98
|
return ""
|
|
99
|
+
if _is_dev_node(p):
|
|
100
|
+
# /dev device nodes are not a filesystem escape (e.g. `2>/dev/null`).
|
|
101
|
+
return ""
|
|
91
102
|
return "" if _inside(p, roots) else str(p)
|
|
92
103
|
|
|
93
104
|
|
|
@@ -277,20 +277,62 @@ PROXY_HARD_FINALIZE_TURNS = int(
|
|
|
277
277
|
PROXY_TOOLCALL_PATH_NORMALIZE = os.environ.get(
|
|
278
278
|
"PROXY_TOOLCALL_PATH_NORMALIZE", "off"
|
|
279
279
|
).lower() in ("on", "1", "true", "yes")
|
|
280
|
+
# Path CONTAINMENT (separate from same-dir normalization): snap a garbled
|
|
281
|
+
# out-of-workdir path (the small quant mangling the absolute PREFIX, e.g.
|
|
282
|
+
# /home/cogtek -> /home/cogtec, octopus_invaders -> octus_invaders) back ONTO the
|
|
283
|
+
# session workdir. Safe because the OS sandbox contains any mis-snap to the
|
|
284
|
+
# workdir — without the sandbox this would risk cross-project relocation, so it
|
|
285
|
+
# defaults ON only alongside sandboxed sessions. Set off to disable.
|
|
286
|
+
PROXY_TOOLCALL_PATH_CONTAIN = os.environ.get(
|
|
287
|
+
"PROXY_TOOLCALL_PATH_CONTAIN", "on"
|
|
288
|
+
).lower() in ("on", "1", "true", "yes")
|
|
280
289
|
try:
|
|
281
290
|
import sys as _sys
|
|
282
291
|
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # launch-robust sibling import
|
|
283
292
|
from toolcall_path_normalizer import extract_known_paths as _shz_known_paths
|
|
284
293
|
from toolcall_path_normalizer import normalize_tool_uses as _shz_normalize_tool_uses
|
|
294
|
+
from toolcall_path_normalizer import derive_workdir as _shz_derive_workdir
|
|
295
|
+
from toolcall_path_normalizer import contain_tool_uses as _shz_contain_tool_uses
|
|
285
296
|
_TOOLCALL_NORMALIZER_OK = True
|
|
286
297
|
except Exception: # pragma: no cover - optional middleware
|
|
287
298
|
_TOOLCALL_NORMALIZER_OK = False
|
|
288
299
|
|
|
289
300
|
|
|
301
|
+
def _toolcall_workdir_hint(messages: list, limit: int = 8000) -> str:
|
|
302
|
+
"""Recent text/tool_result content (capped) so derive_workdir can recover the
|
|
303
|
+
real workdir echoed in command outputs even when the model's own tool-call
|
|
304
|
+
paths are all garbled."""
|
|
305
|
+
out: list[str] = []
|
|
306
|
+
total = 0
|
|
307
|
+
for msg in reversed(messages or []):
|
|
308
|
+
content = msg.get("content")
|
|
309
|
+
chunks: list[str] = []
|
|
310
|
+
if isinstance(content, str):
|
|
311
|
+
chunks.append(content)
|
|
312
|
+
elif isinstance(content, list):
|
|
313
|
+
for b in content:
|
|
314
|
+
if not isinstance(b, dict) or b.get("type") not in ("text", "tool_result"):
|
|
315
|
+
continue
|
|
316
|
+
c = b.get("content", b.get("text", ""))
|
|
317
|
+
if isinstance(c, list):
|
|
318
|
+
c = " ".join(x.get("text", "") for x in c if isinstance(x, dict))
|
|
319
|
+
if isinstance(c, str):
|
|
320
|
+
chunks.append(c)
|
|
321
|
+
for c in chunks:
|
|
322
|
+
out.append(c)
|
|
323
|
+
total += len(c)
|
|
324
|
+
if total >= limit:
|
|
325
|
+
break
|
|
326
|
+
return " ".join(out)[:limit]
|
|
327
|
+
|
|
328
|
+
|
|
290
329
|
def _maybe_normalize_toolcall_paths(anthropic_resp: dict, request_body: dict) -> None:
|
|
291
|
-
"""Gated:
|
|
292
|
-
|
|
293
|
-
|
|
330
|
+
"""Gated: (1) CONTAIN garbled out-of-workdir paths back onto the session
|
|
331
|
+
workdir (safe under the OS sandbox), then (2) snap remaining garbles to the
|
|
332
|
+
same real directory. No-op unless enabled + available."""
|
|
333
|
+
if not _TOOLCALL_NORMALIZER_OK or not (
|
|
334
|
+
PROXY_TOOLCALL_PATH_NORMALIZE or PROXY_TOOLCALL_PATH_CONTAIN
|
|
335
|
+
):
|
|
294
336
|
return
|
|
295
337
|
try:
|
|
296
338
|
content = anthropic_resp.get("content")
|
|
@@ -299,15 +341,26 @@ def _maybe_normalize_toolcall_paths(anthropic_resp: dict, request_body: dict) ->
|
|
|
299
341
|
tool_uses = [b for b in content if isinstance(b, dict) and b.get("type") == "tool_use"]
|
|
300
342
|
if not tool_uses:
|
|
301
343
|
return
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
tu_id, key, frm, to, reason,
|
|
310
|
-
|
|
344
|
+
messages = request_body.get("messages", [])
|
|
345
|
+
known = _shz_known_paths(messages)
|
|
346
|
+
|
|
347
|
+
# 1) Containment first, so step 2 sees corrected paths.
|
|
348
|
+
if PROXY_TOOLCALL_PATH_CONTAIN:
|
|
349
|
+
workdir = _shz_derive_workdir(known, _toolcall_workdir_hint(messages))
|
|
350
|
+
if workdir:
|
|
351
|
+
for tu_id, key, frm, to, reason in _shz_contain_tool_uses(tool_uses, workdir):
|
|
352
|
+
logger.info(
|
|
353
|
+
"TOOLCALL PATH CONTAINMENT: %s.%s '%.80s' -> '%.80s' (%s)",
|
|
354
|
+
tu_id, key, frm, to, reason,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# 2) Same-directory filename normalization (filesystem-verified).
|
|
358
|
+
if PROXY_TOOLCALL_PATH_NORMALIZE and known:
|
|
359
|
+
for tu_id, key, frm, to, reason in _shz_normalize_tool_uses(tool_uses, known):
|
|
360
|
+
logger.info(
|
|
361
|
+
"TOOLCALL PATH NORMALIZER: %s.%s '%s' -> '%s' (%s)",
|
|
362
|
+
tu_id, key, frm, to, reason,
|
|
363
|
+
)
|
|
311
364
|
except Exception as exc: # never break a response over normalization
|
|
312
365
|
logger.warning("TOOLCALL PATH NORMALIZER: skipped (%s)", type(exc).__name__)
|
|
313
366
|
# Recon-convergence guardrail: after this many consecutive turns that use
|
|
@@ -15,11 +15,15 @@ TS reference at src/self-harness/middleware/path-normalizer.ts.
|
|
|
15
15
|
See docs/design/SELF_HARNESS.md §4 (P2).
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
import difflib
|
|
18
19
|
import os
|
|
19
20
|
import re
|
|
20
21
|
|
|
21
22
|
_PATH_ARG_KEYS = ("file_path", "path", "filePath", "notebook_path")
|
|
22
23
|
|
|
24
|
+
# Absolute paths a tool call might target. Used to scan/rewrite Bash commands.
|
|
25
|
+
_ABS_PATH_RE = re.compile(r"/(?:home|root|Users|tmp|var|opt|srv|mnt)/[A-Za-z0-9._\-/]+")
|
|
26
|
+
|
|
23
27
|
|
|
24
28
|
def _squash(s: str) -> str:
|
|
25
29
|
return re.sub(r"[^a-z0-9]", "", s.lower())
|
|
@@ -123,6 +127,164 @@ def extract_known_paths(anthropic_messages) -> list:
|
|
|
123
127
|
return known
|
|
124
128
|
|
|
125
129
|
|
|
130
|
+
def _fuzzy_eq(a: str, b: str) -> bool:
|
|
131
|
+
"""Two path components are 'the same intent' if they squash-match or are very
|
|
132
|
+
close (handles octopus_invaders ~ octopus-invaders / octus_invaders / octpus_)."""
|
|
133
|
+
if not a or not b:
|
|
134
|
+
return False
|
|
135
|
+
if _squash(a) == _squash(b):
|
|
136
|
+
return True
|
|
137
|
+
return difflib.SequenceMatcher(None, a.lower(), b.lower()).ratio() >= 0.78
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def derive_workdir(known_paths, hint_text: str = "") -> str:
|
|
141
|
+
"""Best-effort session working directory, VALIDATED against disk: the deepest
|
|
142
|
+
absolute directory that exists on disk among the paths the model used
|
|
143
|
+
(known_paths) and any absolute paths in hint_text (request/tool-result text).
|
|
144
|
+
Garbled variants (e.g. /home/cogtec/...) don't exist on disk, so the real
|
|
145
|
+
workdir is recovered. Returns '' if none found.
|
|
146
|
+
"""
|
|
147
|
+
_STOP = {"/", "/home", "/root", "/tmp", "/var", "/opt", "/srv", "/mnt", "/Users"}
|
|
148
|
+
cands: set[str] = set()
|
|
149
|
+
for p in known_paths or []:
|
|
150
|
+
if isinstance(p, str) and p.startswith("/"):
|
|
151
|
+
cands.add(p if os.path.isdir(p) else os.path.dirname(p))
|
|
152
|
+
if hint_text:
|
|
153
|
+
for m in _ABS_PATH_RE.findall(hint_text):
|
|
154
|
+
cands.add(m if os.path.isdir(m) else os.path.dirname(m))
|
|
155
|
+
|
|
156
|
+
existing: list[str] = []
|
|
157
|
+
for c in cands:
|
|
158
|
+
d = c
|
|
159
|
+
while d and d not in _STOP and not os.path.isdir(d):
|
|
160
|
+
d = os.path.dirname(d)
|
|
161
|
+
if d and d not in _STOP and os.path.isdir(d):
|
|
162
|
+
existing.append(d)
|
|
163
|
+
if not existing:
|
|
164
|
+
return ""
|
|
165
|
+
# Prefer the PROJECT ROOT (a dir with .git/.uap/package.json) over a deep
|
|
166
|
+
# subdir — anchoring containment on the root catches more garbles. Walk each
|
|
167
|
+
# existing candidate up to its nearest project-root ancestor.
|
|
168
|
+
_MARKERS = (".git", ".uap", ".uap.json", "package.json")
|
|
169
|
+
roots: set[str] = set()
|
|
170
|
+
for d in existing:
|
|
171
|
+
x = d
|
|
172
|
+
while x and x not in _STOP:
|
|
173
|
+
if any(os.path.exists(os.path.join(x, mk)) for mk in _MARKERS):
|
|
174
|
+
roots.add(x)
|
|
175
|
+
break
|
|
176
|
+
x = os.path.dirname(x)
|
|
177
|
+
pool = roots or set(existing)
|
|
178
|
+
# Deepest (most specific) among the chosen pool.
|
|
179
|
+
return max(pool, key=lambda d: (len(d.split("/")), len(d)))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _fs_correct_suffix(workdir: str, suffix: str) -> str:
|
|
183
|
+
"""Walk `suffix` under `workdir`, fuzzy-correcting each intermediate DIRECTORY
|
|
184
|
+
component to an existing on-disk sibling when the exact name is absent but a
|
|
185
|
+
single close match exists (space-shootr -> space-shooter once that dir
|
|
186
|
+
exists). The final component (the file being created) is left as-is."""
|
|
187
|
+
if not suffix:
|
|
188
|
+
return suffix
|
|
189
|
+
parts = [x for x in suffix.split("/") if x]
|
|
190
|
+
cur = workdir
|
|
191
|
+
out: list[str] = []
|
|
192
|
+
for i, comp in enumerate(parts):
|
|
193
|
+
nxt = os.path.join(cur, comp)
|
|
194
|
+
if i == len(parts) - 1 or os.path.exists(nxt):
|
|
195
|
+
out.append(comp)
|
|
196
|
+
cur = nxt
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
cands = [
|
|
200
|
+
e for e in os.listdir(cur)
|
|
201
|
+
if os.path.isdir(os.path.join(cur, e)) and _fuzzy_eq(e, comp)
|
|
202
|
+
]
|
|
203
|
+
except OSError:
|
|
204
|
+
cands = []
|
|
205
|
+
chosen = cands[0] if len(cands) == 1 else comp
|
|
206
|
+
out.append(chosen)
|
|
207
|
+
cur = os.path.join(cur, chosen)
|
|
208
|
+
return "/".join(out)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def contain_to_workdir(path: str, workdir: str):
|
|
212
|
+
"""Snap a garbled in-workdir path back onto `workdir`. Returns
|
|
213
|
+
(new_path, changed, reason). Two garble classes, both handled:
|
|
214
|
+
|
|
215
|
+
* mangled absolute PREFIX / workdir name (/home/cogtek -> /home/cogtec,
|
|
216
|
+
octopus_invaders -> octus_invaders) — anchored by a fuzzy match of the
|
|
217
|
+
workdir-name component;
|
|
218
|
+
* mangled SUBDIR name in the suffix (space-shooter -> space-shootr) —
|
|
219
|
+
fuzzy-corrected against the real directories on disk.
|
|
220
|
+
|
|
221
|
+
Only ever relocates INTO the workdir, and never touches a path that exists
|
|
222
|
+
elsewhere (the OS sandbox blocks a genuine out-of-workdir write). Safe
|
|
223
|
+
precisely because the sandbox contains any mis-snap to the workdir.
|
|
224
|
+
"""
|
|
225
|
+
if not path or not workdir or not path.startswith("/"):
|
|
226
|
+
return path, False, None
|
|
227
|
+
wd = workdir.rstrip("/")
|
|
228
|
+
|
|
229
|
+
if path == wd or path.startswith(wd + "/"):
|
|
230
|
+
# Already inside: only fix garbled subdir names against disk.
|
|
231
|
+
suffix = path[len(wd):].lstrip("/")
|
|
232
|
+
reason = "corrected garbled subdir(s) under the workdir"
|
|
233
|
+
elif os.path.exists(path):
|
|
234
|
+
return path, False, None # a real path elsewhere — don't touch it
|
|
235
|
+
else:
|
|
236
|
+
# Garbled prefix/workdir-name: anchor on a fuzzy workdir-name match.
|
|
237
|
+
wd_name = wd.rsplit("/", 1)[-1]
|
|
238
|
+
parts = [x for x in path.split("/") if x]
|
|
239
|
+
anchor = next(
|
|
240
|
+
(i for i in range(len(parts) - 1, -1, -1) if _fuzzy_eq(parts[i], wd_name)),
|
|
241
|
+
None,
|
|
242
|
+
)
|
|
243
|
+
if anchor is None:
|
|
244
|
+
return path, False, None
|
|
245
|
+
suffix = "/".join(parts[anchor + 1:])
|
|
246
|
+
reason = f"contained garbled out-of-workdir path to '{wd_name}'"
|
|
247
|
+
|
|
248
|
+
corrected = _fs_correct_suffix(wd, suffix)
|
|
249
|
+
new = wd + ("/" + corrected if corrected else "")
|
|
250
|
+
if new != path:
|
|
251
|
+
return new, True, reason
|
|
252
|
+
return path, False, None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def contain_tool_uses(tool_uses, workdir: str):
|
|
256
|
+
"""Contain garbled out-of-workdir paths to `workdir` — in Write/Edit path args
|
|
257
|
+
AND in Bash command tokens. Returns corrections [(id, key, from, to, reason)].
|
|
258
|
+
"""
|
|
259
|
+
corrections = []
|
|
260
|
+
if not workdir:
|
|
261
|
+
return corrections
|
|
262
|
+
for tu in tool_uses:
|
|
263
|
+
if not isinstance(tu, dict) or tu.get("type") != "tool_use":
|
|
264
|
+
continue
|
|
265
|
+
inp = tu.get("input")
|
|
266
|
+
if not isinstance(inp, dict):
|
|
267
|
+
continue
|
|
268
|
+
tu_id = tu.get("id", "")
|
|
269
|
+
for key in _PATH_ARG_KEYS:
|
|
270
|
+
v = inp.get(key)
|
|
271
|
+
if isinstance(v, str):
|
|
272
|
+
nv, changed, reason = contain_to_workdir(v, workdir)
|
|
273
|
+
if changed:
|
|
274
|
+
inp[key] = nv
|
|
275
|
+
corrections.append((tu_id, key, v, nv, reason))
|
|
276
|
+
cmd = inp.get("command")
|
|
277
|
+
if isinstance(cmd, str) and "/" in cmd:
|
|
278
|
+
def _sub(m):
|
|
279
|
+
nv, changed, _ = contain_to_workdir(m.group(0), workdir)
|
|
280
|
+
return nv if changed else m.group(0)
|
|
281
|
+
new_cmd = _ABS_PATH_RE.sub(_sub, cmd)
|
|
282
|
+
if new_cmd != cmd:
|
|
283
|
+
inp["command"] = new_cmd
|
|
284
|
+
corrections.append((tu_id, "command", cmd, new_cmd, "contained garbled path(s) in bash command"))
|
|
285
|
+
return corrections
|
|
286
|
+
|
|
287
|
+
|
|
126
288
|
def normalize_tool_uses(tool_uses, known_paths):
|
|
127
289
|
"""Normalize path args of a list of Anthropic tool_use blocks in place.
|
|
128
290
|
Returns the list of corrections [(tool_use_id, key, from, to, reason)].
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for proxy-side path containment — recovering the workdir and snapping a
|
|
3
|
+
small quant's GARBLED absolute paths back onto it (the failure where it mangles
|
|
4
|
+
the prefix: /home/cogtek -> /home/cogtec, octopus_invaders -> octus_invaders).
|
|
5
|
+
Safe under the OS sandbox, which contains any mis-snap to the workdir.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load():
|
|
16
|
+
# this file: tools/agents/tests/ -> module: tools/agents/scripts/
|
|
17
|
+
p = Path(__file__).resolve().parents[1] / "scripts" / "toolcall_path_normalizer.py"
|
|
18
|
+
spec = importlib.util.spec_from_file_location("toolcall_path_normalizer", p)
|
|
19
|
+
m = importlib.util.module_from_spec(spec)
|
|
20
|
+
spec.loader.exec_module(m)
|
|
21
|
+
return m
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
N = _load()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _tu(tid, **inp):
|
|
28
|
+
return {"type": "tool_use", "id": tid, "name": "Write", "input": inp}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestDeriveWorkdir(unittest.TestCase):
|
|
32
|
+
def setUp(self):
|
|
33
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
34
|
+
self.wd = os.path.join(self._tmp.name, "octopus_invaders")
|
|
35
|
+
os.makedirs(os.path.join(self.wd, "space-shooter"))
|
|
36
|
+
os.makedirs(os.path.join(self.wd, ".git")) # project-root marker
|
|
37
|
+
|
|
38
|
+
def tearDown(self):
|
|
39
|
+
self._tmp.cleanup()
|
|
40
|
+
|
|
41
|
+
def test_recovers_workdir_from_correct_and_garbled_known_paths(self):
|
|
42
|
+
known = [
|
|
43
|
+
os.path.join(self.wd, "space-shooter", "js", "game.js"), # correct (parent exists)
|
|
44
|
+
self.wd.replace("octopus_invaders", "octus_invaders") + "/x.js", # garble (no exist)
|
|
45
|
+
"/home/cogtec/dev/octopus_invaders/y.js", # garble (no exist)
|
|
46
|
+
]
|
|
47
|
+
self.assertEqual(N.derive_workdir(known), self.wd)
|
|
48
|
+
|
|
49
|
+
def test_uses_hint_text_when_known_all_garbled(self):
|
|
50
|
+
known = ["/home/cogtec/dev/octus_invaders/a.js"] # all garbled
|
|
51
|
+
hint = f"running in {self.wd} now"
|
|
52
|
+
self.assertEqual(N.derive_workdir(known, hint), self.wd)
|
|
53
|
+
|
|
54
|
+
def test_returns_empty_when_nothing_exists(self):
|
|
55
|
+
self.assertEqual(N.derive_workdir(["/home/nope/x/y.js"]), "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestContainToWorkdir(unittest.TestCase):
|
|
59
|
+
def setUp(self):
|
|
60
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
61
|
+
self.wd = os.path.join(self._tmp.name, "octopus_invaders")
|
|
62
|
+
os.makedirs(self.wd)
|
|
63
|
+
|
|
64
|
+
def tearDown(self):
|
|
65
|
+
self._tmp.cleanup()
|
|
66
|
+
|
|
67
|
+
def test_garbled_prefix_contained(self):
|
|
68
|
+
p = "/home/cogtec/dev/octopus_invaders/space-shooter/js/game.js"
|
|
69
|
+
new, changed, _ = N.contain_to_workdir(p, self.wd)
|
|
70
|
+
self.assertTrue(changed)
|
|
71
|
+
self.assertEqual(new, self.wd + "/space-shooter/js/game.js")
|
|
72
|
+
|
|
73
|
+
def test_garbled_workdir_name_contained(self):
|
|
74
|
+
for bad in ("octus_invaders", "octpus_invaders", "octopus-invaders", "octopus_invders"):
|
|
75
|
+
p = f"/home/cogtec/dev/{bad}/space-shooter/css/styles.css"
|
|
76
|
+
new, changed, _ = N.contain_to_workdir(p, self.wd)
|
|
77
|
+
self.assertTrue(changed, bad)
|
|
78
|
+
self.assertEqual(new, self.wd + "/space-shooter/css/styles.css", bad)
|
|
79
|
+
|
|
80
|
+
def test_already_inside_unchanged(self):
|
|
81
|
+
p = self.wd + "/space-shooter/js/game.js"
|
|
82
|
+
_, changed, _ = N.contain_to_workdir(p, self.wd)
|
|
83
|
+
self.assertFalse(changed)
|
|
84
|
+
|
|
85
|
+
def test_real_existing_path_elsewhere_left_alone(self):
|
|
86
|
+
other = os.path.join(self._tmp.name, "real_other")
|
|
87
|
+
os.makedirs(other)
|
|
88
|
+
open(os.path.join(other, "f.txt"), "w").write("x")
|
|
89
|
+
p = os.path.join(other, "f.txt")
|
|
90
|
+
_, changed, _ = N.contain_to_workdir(p, self.wd)
|
|
91
|
+
self.assertFalse(changed)
|
|
92
|
+
|
|
93
|
+
def test_unrelated_nonexistent_path_not_contained(self):
|
|
94
|
+
# No component fuzzy-matches the workdir name -> leave it (sandbox blocks).
|
|
95
|
+
p = "/etc/cron.d/totally_unrelated"
|
|
96
|
+
_, changed, _ = N.contain_to_workdir(p, self.wd)
|
|
97
|
+
self.assertFalse(changed)
|
|
98
|
+
|
|
99
|
+
def test_garbled_subdir_corrected_against_disk(self):
|
|
100
|
+
# space-shooter exists on disk; a write to garbled space-shootr is fixed.
|
|
101
|
+
os.makedirs(os.path.join(self.wd, "space-shooter", "js"))
|
|
102
|
+
p = self.wd + "/space-shootr/js/game.js" # in-workdir but garbled subdir
|
|
103
|
+
new, changed, _ = N.contain_to_workdir(p, self.wd)
|
|
104
|
+
self.assertTrue(changed)
|
|
105
|
+
self.assertEqual(new, self.wd + "/space-shooter/js/game.js")
|
|
106
|
+
|
|
107
|
+
def test_garbled_prefix_and_subdir_both_corrected(self):
|
|
108
|
+
os.makedirs(os.path.join(self.wd, "space-shooter", "css"))
|
|
109
|
+
p = "/home/cogtec/dev/octus_invaders/space-shootr/css/styles.css"
|
|
110
|
+
new, changed, _ = N.contain_to_workdir(p, self.wd)
|
|
111
|
+
self.assertTrue(changed)
|
|
112
|
+
self.assertEqual(new, self.wd + "/space-shooter/css/styles.css")
|
|
113
|
+
|
|
114
|
+
def test_new_subdir_left_when_no_disk_match(self):
|
|
115
|
+
# First write that legitimately creates a new dir -> not fuzzy-mangled.
|
|
116
|
+
p = self.wd + "/space-shooter/js/game.js"
|
|
117
|
+
new, changed, _ = N.contain_to_workdir(p, self.wd)
|
|
118
|
+
self.assertFalse(changed) # nothing to correct; left as-is
|
|
119
|
+
self.assertEqual(new, p)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestContainToolUses(unittest.TestCase):
|
|
123
|
+
def setUp(self):
|
|
124
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
125
|
+
self.wd = os.path.join(self._tmp.name, "octopus_invaders")
|
|
126
|
+
os.makedirs(self.wd)
|
|
127
|
+
|
|
128
|
+
def tearDown(self):
|
|
129
|
+
self._tmp.cleanup()
|
|
130
|
+
|
|
131
|
+
def test_contains_write_path_and_bash_command(self):
|
|
132
|
+
tus = [
|
|
133
|
+
_tu("t1", file_path="/home/cogtec/dev/octus_invaders/space-shooter/js/game.js", content="x"),
|
|
134
|
+
{"type": "tool_use", "id": "t2", "name": "Bash",
|
|
135
|
+
"input": {"command": "mkdir -p /home/cogtk/dev/octopus_invaders/space-shooter/css && echo done"}},
|
|
136
|
+
]
|
|
137
|
+
corr = N.contain_tool_uses(tus, self.wd)
|
|
138
|
+
self.assertEqual(tus[0]["input"]["file_path"], self.wd + "/space-shooter/js/game.js")
|
|
139
|
+
self.assertIn(self.wd + "/space-shooter/css", tus[1]["input"]["command"])
|
|
140
|
+
self.assertTrue(tus[1]["input"]["command"].startswith("mkdir -p "))
|
|
141
|
+
self.assertEqual(len(corr), 2)
|
|
142
|
+
|
|
143
|
+
def test_noop_without_workdir(self):
|
|
144
|
+
tus = [_tu("t1", file_path="/home/cogtec/dev/octus_invaders/a.js")]
|
|
145
|
+
self.assertEqual(N.contain_tool_uses(tus, ""), [])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
unittest.main()
|