@miller-tech/uap 1.61.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.61.3",
3
+ "version": "1.62.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """workdir-scope enforcer: file-mutating tool calls must stay within the project
3
+ working directory (the repo root + its worktrees + a small scratch allow-list).
4
+
5
+ Creating/writing/moving a path OUTSIDE the workdir is blocked by default — it
6
+ requires explicit operator approval. This is the policy-engine enforcement of the
7
+ operator rule "never step outside the current path without explicit permission":
8
+ agents running with --dangerously-skip-permissions emit absolute paths that can
9
+ escape the project (e.g. a sibling at ~/dev, or a garbled `octopusspace-shooter`),
10
+ silently creating directories outside the intended workspace.
11
+
12
+ Allowed targets:
13
+ * anything under the current working tree (UAP_WORKTREE_ROOT) or the main
14
+ checkout (UAP_REPO_ROOT) — worktrees included;
15
+ * relative paths (they resolve under the project root);
16
+ * a scratch allow-list: /tmp, $TMPDIR, ~/.cache/uap, ~/.config/uap, plus any
17
+ colon-separated prefixes in UAP_WORKDIR_ALLOW.
18
+
19
+ Escape hatch: UAP_WORKDIR_SCOPE_OFF=1 allows everything (operator override).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ import shlex
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).parent))
30
+ from _common import emit, parse_cli, repo_root, worktree_root # noqa: E402
31
+
32
+ # Tools that create/modify a file at an explicit path argument.
33
+ PATH_WRITE_OPS = {
34
+ "Write", "Edit", "MultiEdit", "NotebookEdit",
35
+ "write", "edit", "multiedit", "notebookedit",
36
+ }
37
+ PATH_ARG_KEYS = ("file_path", "path", "notebook_path", "filePath", "target")
38
+
39
+ # Bash verbs that CREATE/MOVE filesystem entries (pure-read commands are ignored).
40
+ _BASH_CREATE = ("mkdir", "touch", "install", "tee")
41
+ _BASH_DEST_LAST = ("cp", "mv", "rsync") # destination is the final argument
42
+
43
+
44
+ def _expand(p: str) -> Path:
45
+ return Path(os.path.expanduser(os.path.expandvars(p)))
46
+
47
+
48
+ def _allowed_roots() -> list[Path]:
49
+ roots: list[Path] = []
50
+
51
+ def add(p: Path) -> None:
52
+ try:
53
+ r = p.resolve()
54
+ except Exception: # noqa: BLE001
55
+ r = p
56
+ if r not in roots:
57
+ roots.append(r)
58
+
59
+ add(worktree_root())
60
+ add(repo_root())
61
+ for p in ("/tmp", os.environ.get("TMPDIR", "/tmp"), "~/.cache/uap", "~/.config/uap"):
62
+ add(_expand(p))
63
+ for p in os.environ.get("UAP_WORKDIR_ALLOW", "").split(":"):
64
+ if p.strip():
65
+ add(_expand(p))
66
+ return roots
67
+
68
+
69
+ def _inside(target: Path, roots: list[Path]) -> bool:
70
+ try:
71
+ t = target.resolve()
72
+ except Exception: # noqa: BLE001
73
+ t = target
74
+ for root in roots:
75
+ try:
76
+ t.relative_to(root)
77
+ return True
78
+ except ValueError:
79
+ continue
80
+ return False
81
+
82
+
83
+ def _check_path(target: str, roots: list[Path]) -> str:
84
+ """Return the offending absolute path if out of scope, else ''."""
85
+ if not target:
86
+ return ""
87
+ p = _expand(target)
88
+ if not p.is_absolute():
89
+ # Relative paths resolve under the enforcer cwd (the project root).
90
+ return ""
91
+ return "" if _inside(p, roots) else str(p)
92
+
93
+
94
+ def _scan_bash(cmd: str, roots: list[Path]) -> str:
95
+ """Best-effort: flag an out-of-scope absolute path that a CREATE/MOVE command
96
+ would write. Conservative — only inspects the destinations of known
97
+ create/move verbs and output redirections, and ignores read sources."""
98
+ if not cmd:
99
+ return ""
100
+ try:
101
+ tokens = shlex.split(cmd, comments=True)
102
+ except ValueError:
103
+ tokens = cmd.split()
104
+
105
+ candidates: list[str] = []
106
+
107
+ # Output redirections: > /abs, >> /abs (also 2>/abs).
108
+ for m in re.finditer(r'(?:\d*>>?|&>)\s*("?)(/[^\s"\';|&)]+)\1', cmd):
109
+ candidates.append(m.group(2))
110
+
111
+ # Split into pipeline/sequence segments so we read each command's own verb.
112
+ segments = re.split(r'\|\||&&|[;|&\n]', cmd)
113
+ for seg in segments:
114
+ try:
115
+ parts = shlex.split(seg, comments=True)
116
+ except ValueError:
117
+ parts = seg.split()
118
+ if not parts:
119
+ continue
120
+ verb = os.path.basename(parts[0])
121
+ argv = [a for a in parts[1:] if not a.startswith("-")]
122
+ if verb in _BASH_CREATE:
123
+ candidates.extend(argv) # all targets are created
124
+ elif verb in _BASH_DEST_LAST and argv:
125
+ candidates.append(argv[-1]) # only the destination is written
126
+
127
+ for c in candidates:
128
+ bad = _check_path(c, roots)
129
+ if bad:
130
+ return bad
131
+ return ""
132
+
133
+
134
+ def main() -> None:
135
+ op, args = parse_cli()
136
+
137
+ if os.environ.get("UAP_WORKDIR_SCOPE_OFF") == "1":
138
+ emit(True, "UAP_WORKDIR_SCOPE_OFF override set")
139
+
140
+ roots = _allowed_roots()
141
+
142
+ if op in PATH_WRITE_OPS:
143
+ for key in PATH_ARG_KEYS:
144
+ bad = _check_path(args.get(key) or "", roots)
145
+ if bad:
146
+ _deny(bad)
147
+ emit(True, "target within workdir scope")
148
+
149
+ if op in {"Bash", "bash"}:
150
+ bad = _scan_bash(args.get("command") or "", roots)
151
+ if bad:
152
+ _deny(bad, bash=True)
153
+ emit(True, "no out-of-scope write target in command")
154
+
155
+ emit(True, "not a path-mutating operation")
156
+
157
+
158
+ def _deny(path: str, bash: bool = False) -> None:
159
+ where = "command writes to" if bash else "target"
160
+ emit(
161
+ False,
162
+ f"workdir-scope: {where} '{path}' is OUTSIDE the project working directory. "
163
+ "Stepping outside the current path requires explicit permission. "
164
+ "Write inside the project, or — if this is intended — re-run with "
165
+ "UAP_WORKDIR_SCOPE_OFF=1 (or add the prefix to UAP_WORKDIR_ALLOW).",
166
+ )
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
@@ -0,0 +1,38 @@
1
+ # workdir-scope
2
+
3
+ **Category**: safety
4
+ **Level**: REQUIRED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: filesystem, scope, safety, permission
7
+
8
+ ## Rule
9
+
10
+ File-mutating tool calls MUST stay within the project working directory. A
11
+ `Write`, `Edit`, `MultiEdit`, or `NotebookEdit` whose target — or a `Bash`
12
+ command whose create/move destination (`mkdir`, `touch`, `cp`, `mv`, `install`,
13
+ `tee`, output redirection) — resolves OUTSIDE the working tree is **blocked**.
14
+
15
+ In scope (allowed): the current working tree and main checkout (worktrees
16
+ included), relative paths, and a scratch allow-list (`/tmp`, `$TMPDIR`,
17
+ `~/.cache/uap`, `~/.config/uap`, plus `UAP_WORKDIR_ALLOW` prefixes).
18
+
19
+ ## Why
20
+
21
+ Agents running with `--dangerously-skip-permissions` emit absolute paths that can
22
+ escape the project (a sibling at `~/dev`, or a garbled name like
23
+ `octopusspace-shooter`), silently creating directories and writing files outside
24
+ the intended workspace. Stepping outside the current path must require explicit
25
+ operator permission — not happen silently.
26
+
27
+ ## Enforcement
28
+
29
+ Python enforcer `workdir_scope.py` resolves each target/destination against the
30
+ working-tree roots and rejects any that fall outside (minus the scratch
31
+ allow-list). Escape hatch: `UAP_WORKDIR_SCOPE_OFF=1` allows everything;
32
+ `UAP_WORKDIR_ALLOW=/extra/prefix:...` widens the allow-list.
33
+
34
+ ```rules
35
+ - title: "File writes must stay inside the project working directory"
36
+ keywords: [write, edit, multiedit, notebookedit, mkdir, create-file, bash]
37
+ antiPatterns: []
38
+ ```
@@ -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()