@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.63.0",
3
+ "version": "1.64.1",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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: snap garbled tool-call paths in the response to paths the model
292
- used correctly earlier in the conversation. No-op unless enabled + available."""
293
- if not (PROXY_TOOLCALL_PATH_NORMALIZE and _TOOLCALL_NORMALIZER_OK):
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
- known = _shz_known_paths(request_body.get("messages", []))
303
- if not known:
304
- return
305
- corrections = _shz_normalize_tool_uses(tool_uses, known)
306
- for tu_id, key, frm, to, reason in corrections:
307
- logger.info(
308
- "TOOLCALL PATH NORMALIZER: %s.%s '%s' -> '%s' (%s)",
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()