@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
|
Binary file
|
|
@@ -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()
|