@miller-tech/uap 1.61.2 → 1.62.0

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.
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for the hardened (filesystem-verified, same-directory-only) tool-call
3
+ path normalizer.
4
+
5
+ The heuristic predecessor silently RELOCATED writes across projects/worktrees
6
+ (e.g. octopus_invaders/js/config.js -> octopus-invader/space-shooter/js/config.js),
7
+ turning a loud self-correcting failure into a silent wrong-write. These tests
8
+ pin the conservative contract: only a filename may be repaired, only inside a
9
+ directory that already exists on disk, only when exactly one real sibling
10
+ matches — and a wrong/garbled directory or any ambiguity is left untouched.
11
+ """
12
+
13
+ import importlib.util
14
+ import os
15
+ import tempfile
16
+ import unittest
17
+ from pathlib import Path
18
+
19
+
20
+ def _load():
21
+ p = Path(__file__).resolve().parents[1] / "scripts" / "toolcall_path_normalizer.py"
22
+ spec = importlib.util.spec_from_file_location("toolcall_path_normalizer", p)
23
+ m = importlib.util.module_from_spec(spec)
24
+ spec.loader.exec_module(m)
25
+ return m
26
+
27
+
28
+ norm = _load()
29
+
30
+
31
+ def _touch(path):
32
+ os.makedirs(os.path.dirname(path), exist_ok=True)
33
+ with open(path, "w") as f:
34
+ f.write("x")
35
+ return path
36
+
37
+
38
+ class TestHardenedNormalizer(unittest.TestCase):
39
+ def setUp(self):
40
+ self._tmp = tempfile.TemporaryDirectory()
41
+ self.root = self._tmp.name
42
+
43
+ def tearDown(self):
44
+ self._tmp.cleanup()
45
+
46
+ # --- the repairs it SHOULD make (filename only, same real dir) ---
47
+
48
+ def test_fixes_case_in_same_directory(self):
49
+ real = _touch(os.path.join(self.root, "proj", "config.js"))
50
+ proposed = os.path.join(self.root, "proj", "Config.js") # wrong case, doesn't exist
51
+ path, changed, _ = norm.normalize_tool_path(proposed)
52
+ self.assertTrue(changed)
53
+ self.assertEqual(path, real)
54
+
55
+ def test_fixes_dropped_extension_in_same_directory(self):
56
+ real = _touch(os.path.join(self.root, "proj", "config.js"))
57
+ proposed = os.path.join(self.root, "proj", "configjs") # squash-match
58
+ path, changed, _ = norm.normalize_tool_path(proposed)
59
+ self.assertTrue(changed)
60
+ self.assertEqual(path, real)
61
+
62
+ def test_trims_whitespace_on_real_path(self):
63
+ real = _touch(os.path.join(self.root, "proj", "a.js"))
64
+ path, changed, reason = norm.normalize_tool_path(f" {real} ")
65
+ self.assertTrue(changed)
66
+ self.assertEqual(path, real)
67
+ self.assertIn("trimmed", reason)
68
+
69
+ # --- the corruption it MUST NOT cause anymore ---
70
+
71
+ def test_does_not_relocate_to_a_different_directory(self):
72
+ # config.js exists only in projA; a (non-existent) write to projB/config.js
73
+ # must NOT be snapped into projA — the live octopus-style corruption.
74
+ _touch(os.path.join(self.root, "projA", "config.js"))
75
+ os.makedirs(os.path.join(self.root, "projB")) # real but has no config.js
76
+ proposed = os.path.join(self.root, "projB", "config.js")
77
+ path, changed, _ = norm.normalize_tool_path(proposed)
78
+ self.assertFalse(changed)
79
+ self.assertEqual(path, proposed)
80
+
81
+ def test_garbled_directory_is_never_guessed(self):
82
+ # The real dir is 'octopus-invader/space-shooter/js'; the model wrote to a
83
+ # DIFFERENT, non-existent dir 'octopus_invaders/js'. Must be a no-op.
84
+ _touch(os.path.join(self.root, "octopus-invader", "space-shooter", "js", "config.js"))
85
+ proposed = os.path.join(self.root, "octopus_invaders", "js", "config.js")
86
+ path, changed, _ = norm.normalize_tool_path(proposed)
87
+ self.assertFalse(changed)
88
+ self.assertEqual(path, proposed)
89
+
90
+ def test_punctuation_only_directory_difference_is_not_crossed(self):
91
+ # 's-space-shooter' vs 's space-shooter' are DIFFERENT real dirs; the old
92
+ # squash() guard treated them as the same. Must not relocate.
93
+ _touch(os.path.join(self.root, "s space-shooter", "css", "styles.css"))
94
+ os.makedirs(os.path.join(self.root, "s-space-shooter", "css"))
95
+ proposed = os.path.join(self.root, "s-space-shooter", "css", "styles.css")
96
+ path, changed, _ = norm.normalize_tool_path(proposed)
97
+ self.assertFalse(changed)
98
+ self.assertEqual(path, proposed)
99
+
100
+ def test_ambiguous_match_is_left_alone(self):
101
+ # Two files squash-match the garbled basename -> ambiguous -> no-op.
102
+ _touch(os.path.join(self.root, "proj", "config.js"))
103
+ _touch(os.path.join(self.root, "proj", "config.ts"))
104
+ proposed = os.path.join(self.root, "proj", "configjs?") # squashes to 'configjs'/'configts'?
105
+ # Make it genuinely ambiguous: 'config' squashes both 'config.js' and 'config.ts'.
106
+ proposed = os.path.join(self.root, "proj", "config")
107
+ path, changed, _ = norm.normalize_tool_path(proposed)
108
+ self.assertFalse(changed)
109
+ self.assertEqual(path, proposed)
110
+
111
+ def test_legitimate_new_file_passes_through(self):
112
+ # Creating a brand-new file in a real dir with no sibling match -> unchanged.
113
+ os.makedirs(os.path.join(self.root, "proj"))
114
+ proposed = os.path.join(self.root, "proj", "brand_new_helper.js")
115
+ path, changed, _ = norm.normalize_tool_path(proposed)
116
+ self.assertFalse(changed)
117
+ self.assertEqual(path, proposed)
118
+
119
+ def test_already_correct_path_unchanged(self):
120
+ real = _touch(os.path.join(self.root, "proj", "a.js"))
121
+ path, changed, _ = norm.normalize_tool_path(real)
122
+ self.assertFalse(changed)
123
+ self.assertEqual(path, real)
124
+
125
+ def test_relative_path_is_not_touched(self):
126
+ # No reliable cwd at the proxy -> relative paths pass through untouched.
127
+ path, changed, _ = norm.normalize_tool_path("src/Config.js")
128
+ self.assertFalse(changed)
129
+ self.assertEqual(path, "src/Config.js")
130
+
131
+ # --- integration through the public entry point ---
132
+
133
+ def test_normalize_tool_uses_applies_and_reports(self):
134
+ real = _touch(os.path.join(self.root, "proj", "config.js"))
135
+ wrong = os.path.join(self.root, "proj", "CONFIG.JS")
136
+ tool_uses = [{"type": "tool_use", "id": "t1", "input": {"file_path": wrong, "content": "y"}}]
137
+ corrections = norm.normalize_tool_uses(tool_uses, known_paths=[])
138
+ self.assertEqual(tool_uses[0]["input"]["file_path"], real)
139
+ self.assertEqual(tool_uses[0]["input"]["content"], "y") # non-path arg untouched
140
+ self.assertEqual(len(corrections), 1)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ unittest.main()
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for the workdir-scope policy enforcer.
3
+
4
+ Invoked the way the policy gate invokes it: a subprocess given --operation and
5
+ --args, returning {"allowed": bool, "reason": str} on stdout and exit 0/2. The
6
+ project roots are supplied via UAP_REPO_ROOT / UAP_WORKTREE_ROOT (as the gate
7
+ does), pointed at a temp directory so tests are hermetic.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import unittest
16
+ from pathlib import Path
17
+
18
+ ENFORCER = (
19
+ Path(__file__).resolve().parents[3]
20
+ / "src" / "policies" / "enforcers" / "workdir_scope.py"
21
+ )
22
+
23
+
24
+ def run(op, args, root, env_extra=None):
25
+ env = dict(os.environ)
26
+ env["UAP_REPO_ROOT"] = str(root)
27
+ env["UAP_WORKTREE_ROOT"] = str(root)
28
+ env.pop("UAP_WORKDIR_SCOPE_OFF", None)
29
+ env.pop("UAP_WORKDIR_ALLOW", None)
30
+ if env_extra:
31
+ env.update(env_extra)
32
+ p = subprocess.run(
33
+ [sys.executable, str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
34
+ capture_output=True, text=True, env=env, cwd=str(root),
35
+ )
36
+ try:
37
+ out = json.loads(p.stdout)
38
+ except json.JSONDecodeError:
39
+ out = {"allowed": True, "reason": f"<unparseable: {p.stdout!r} {p.stderr!r}>"}
40
+ return out, p.returncode
41
+
42
+
43
+ class TestWorkdirScopeEnforcer(unittest.TestCase):
44
+ def setUp(self):
45
+ self._tmp = tempfile.TemporaryDirectory()
46
+ self.root = Path(self._tmp.name).resolve()
47
+ (self.root / ".worktrees" / "001-x").mkdir(parents=True)
48
+
49
+ def tearDown(self):
50
+ self._tmp.cleanup()
51
+
52
+ def _allow(self, out, code, msg=""):
53
+ self.assertTrue(out["allowed"], f"{msg}: {out}")
54
+ self.assertEqual(code, 0)
55
+
56
+ def _block(self, out, code, msg=""):
57
+ self.assertFalse(out["allowed"], f"{msg}: {out}")
58
+ self.assertEqual(code, 2)
59
+ self.assertIn("workdir-scope", out["reason"])
60
+
61
+ # --- file-write tools ---
62
+
63
+ def test_write_inside_root_allowed(self):
64
+ out, c = run("Write", {"file_path": str(self.root / "src/a.js")}, self.root)
65
+ self._allow(out, c, "in-root write")
66
+
67
+ def test_write_outside_root_blocked(self):
68
+ out, c = run("Write", {"file_path": "/home/cogtek/dev/octopusspace-shooter/x.js"}, self.root)
69
+ self._block(out, c, "out-of-root write")
70
+
71
+ def test_relative_path_allowed(self):
72
+ out, c = run("Edit", {"file_path": "src/a.js"}, self.root)
73
+ self._allow(out, c, "relative path")
74
+
75
+ def test_scratch_tmp_allowed(self):
76
+ out, c = run("Write", {"file_path": "/tmp/uap-scratch/x"}, self.root)
77
+ self._allow(out, c, "/tmp scratch")
78
+
79
+ def test_worktree_path_allowed(self):
80
+ out, c = run("Write", {"file_path": str(self.root / ".worktrees/001-x/f.ts")}, self.root)
81
+ self._allow(out, c, "worktree path")
82
+
83
+ def test_notebook_edit_outside_blocked(self):
84
+ out, c = run("NotebookEdit", {"notebook_path": "/etc/evil.ipynb"}, self.root)
85
+ self._block(out, c, "notebook outside")
86
+
87
+ # --- bash ---
88
+
89
+ def test_bash_mkdir_outside_blocked(self):
90
+ out, c = run("Bash", {"command": "mkdir -p /home/cogtek/dev/octopusspace-shooter/js"}, self.root)
91
+ self._block(out, c, "mkdir outside")
92
+
93
+ def test_bash_mkdir_inside_allowed(self):
94
+ out, c = run("Bash", {"command": "mkdir -p ./src/new"}, self.root)
95
+ self._allow(out, c, "mkdir inside")
96
+
97
+ def test_bash_read_only_allowed(self):
98
+ out, c = run("Bash", {"command": "cat /etc/hosts && ls /usr/bin"}, self.root)
99
+ self._allow(out, c, "read-only command")
100
+
101
+ def test_bash_cp_dest_outside_blocked(self):
102
+ out, c = run("Bash", {"command": "cp ./a.txt /var/tmp2/out/b.txt"}, self.root)
103
+ self._block(out, c, "cp dest outside")
104
+
105
+ def test_bash_cp_source_outside_dest_inside_allowed(self):
106
+ out, c = run("Bash", {"command": "cp /etc/hosts ./local-hosts"}, self.root)
107
+ self._allow(out, c, "read source outside, write inside")
108
+
109
+ def test_bash_redirect_outside_blocked(self):
110
+ out, c = run("Bash", {"command": "echo hi > /opt/somewhere/file"}, self.root)
111
+ self._block(out, c, "redirect outside")
112
+
113
+ # --- overrides / non-mutating ---
114
+
115
+ def test_scope_off_override_allows(self):
116
+ out, c = run("Write", {"file_path": "/anywhere/x"}, self.root, {"UAP_WORKDIR_SCOPE_OFF": "1"})
117
+ self._allow(out, c, "scope-off override")
118
+
119
+ def test_workdir_allow_widens(self):
120
+ out, c = run("Write", {"file_path": "/opt/extra/x"}, self.root, {"UAP_WORKDIR_ALLOW": "/opt/extra"})
121
+ self._allow(out, c, "allow-list widened")
122
+
123
+ def test_non_path_op_allowed(self):
124
+ out, c = run("Grep", {"pattern": "foo"}, self.root)
125
+ self._allow(out, c, "non-path op")
126
+
127
+ def test_read_op_outside_allowed(self):
128
+ # Reading outside the workdir is fine; only mutations are scoped.
129
+ out, c = run("Read", {"file_path": "/etc/hosts"}, self.root)
130
+ self._allow(out, c, "read outside allowed")
131
+
132
+
133
+ if __name__ == "__main__":
134
+ unittest.main()