@miller-tech/uap 1.184.4 → 1.184.6

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.
@@ -8,7 +8,7 @@ import time
8
8
  from pathlib import Path
9
9
 
10
10
  sys.path.insert(0, str(Path(__file__).parent))
11
- from _common import arg_str, emit, parse_cli, repo_root # noqa: E402
11
+ from _common import arg_str, emit, parse_cli, repo_root, recent_evidence # noqa: E402
12
12
 
13
13
  PLAN_OPS = {"ExitPlanMode", "Plan", "TodoWrite"}
14
14
  PLAN_WORD_RE = re.compile(r"(?<![-\w/])(plan the|design the|architect the|propose a plan|spec the)", re.I)
@@ -66,9 +66,16 @@ def main() -> None:
66
66
  if op not in PLAN_OPS and not PLAN_WORD_RE.search(blob):
67
67
  emit(True, "not a plan op")
68
68
 
69
+ # Prefer the protected evidence log; fall back to the legacy read_log while
70
+ # installs catch up. The legacy file is shell-writable, so it is accepted
71
+ # but no longer the only source.
72
+ trusted = recent_evidence("reads", RECENT_SEC, repo_root())
73
+ if trusted:
74
+ emit(True, f"{trusted} recent codebase reads on record (evidence)")
75
+
69
76
  reads = recent_reads()
70
77
  if reads:
71
- emit(True, f"{len(reads)} recent codebase reads on record")
78
+ emit(True, f"{len(reads)} recent codebase reads on record (legacy log)")
72
79
 
73
80
  if not writer_installed():
74
81
  emit(True, "read-log writer hook not installed — gate advisory (run `uap hooks install`)")
@@ -112,6 +112,12 @@ PROTECTED_TARGETS = (
112
112
  "src/policies",
113
113
  "policies/",
114
114
  ".uap/interaction",
115
+ # Gate evidence. The rest of .uap/ stays permissive on purpose (the tooling
116
+ # writes runtime state there constantly), but these records are what the
117
+ # plan-time gates accept as proof a required action happened. Leaving them
118
+ # shell-writable meant a single append could satisfy a gate — which is
119
+ # exactly how one was satisfied during development.
120
+ ".uap/evidence",
115
121
  ".uap.json",
116
122
  "anthropic-proxy.env",
117
123
  )
@@ -43,7 +43,24 @@ SHIP_PATTERNS = (
43
43
  # Additional, command-position detection: `git -C <path> push` is a real ship
44
44
  # action that the patterns above never matched (they require git and the
45
45
  # subcommand to be adjacent). Unioned with them, so it only ever ADDS coverage.
46
- GIT_SHIP_SUBCOMMANDS = frozenset({"commit", "push", "merge"})
46
+ # Porcelain verbs, plus the plumbing that does the same job under another
47
+ # name. `git send-pack` IS a push (push is a wrapper around it) and
48
+ # `commit-tree` + `update-ref` is a commit; neither says "push" or "commit".
49
+ GIT_SHIP_SUBCOMMANDS = frozenset({
50
+ "commit", "push", "merge",
51
+ "send-pack", "commit-tree", "update-ref", "fast-import",
52
+ })
53
+
54
+ # `gh api` reaches the same endpoints without ever saying "pr merge":
55
+ # gh api -X PUT repos/o/r/pulls/1/merge
56
+ # gh api graphql -f query=mutation{mergePullRequest(...)}
57
+ GH_API_SHIP_RE = re.compile(
58
+ r"pulls/[^/\s]+/merge"
59
+ r"|/merges\b"
60
+ r"|mergePullRequest"
61
+ r"|createPullRequest",
62
+ re.I,
63
+ )
47
64
  GIT_VALUE_OPTIONS = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace"})
48
65
  WRAPPER_VERBS = frozenset({
49
66
  "rtk", "env", "nohup", "sudo", "time", "command", "timeout", "stdbuf",
@@ -100,7 +117,28 @@ def _mask_prose(text: str) -> str:
100
117
  return text if quote is not None else "".join(out)
101
118
 
102
119
 
103
- def _ships_at_command_position(text: str) -> bool:
120
+ def _git_ship_aliases(root: Path) -> frozenset[str]:
121
+ """Alias names that expand to a ship subcommand.
122
+
123
+ `git p` is a push when the user's gitconfig says so, and a scanner that
124
+ only knows the porcelain verbs never sees it. git itself is the authority,
125
+ so ask it. Empty on any failure — this only ever ADDS coverage.
126
+ """
127
+ rc, out, _ = run(["git", "config", "--get-regexp", r"^alias" + chr(92) + "."], cwd=root)
128
+ if rc != 0 or not out.strip():
129
+ return frozenset()
130
+
131
+ found = set()
132
+ for line in out.splitlines():
133
+ name, _, expansion = line.partition(" ")
134
+ alias = name.split(".", 1)[1] if "." in name else ""
135
+ first = expansion.strip().lstrip("!").split()
136
+ if alias and first and first[0].lower() in GIT_SHIP_SUBCOMMANDS:
137
+ found.add(alias.lower())
138
+ return frozenset(found)
139
+
140
+
141
+ def _ships_at_command_position(text: str, root: Path | None = None) -> bool:
104
142
  """True when a segment's VERB is git with a ship subcommand.
105
143
 
106
144
  Only used to add `git -C <path> push`; the patterns carry the rest.
@@ -118,8 +156,12 @@ def _ships_at_command_position(text: str) -> bool:
118
156
  rest, i = tokens[1:], 0
119
157
  while i < len(rest) and rest[i].startswith("-"):
120
158
  i += 2 if rest[i] in GIT_VALUE_OPTIONS else 1
121
- if i < len(rest) and rest[i].lower() in GIT_SHIP_SUBCOMMANDS:
122
- return True
159
+ if i < len(rest):
160
+ sub = rest[i].lower()
161
+ if sub in GIT_SHIP_SUBCOMMANDS:
162
+ return True
163
+ if root is not None and sub in _git_ship_aliases(root):
164
+ return True
123
165
  return False
124
166
 
125
167
 
@@ -146,7 +188,7 @@ def _only_inert_verbs(text: str) -> bool:
146
188
  return saw
147
189
 
148
190
 
149
- def is_ship_action(command: str) -> bool:
191
+ def is_ship_action(command: str, root: Path | None = None) -> bool:
150
192
  """True when the command line PERFORMS a ship action.
151
193
 
152
194
  The patterns used to run against the raw string, so any text that merely
@@ -165,7 +207,14 @@ def is_ship_action(command: str) -> bool:
165
207
  scan = _mask_prose(text) if _only_inert_verbs(text) else text
166
208
  if any(p.search(scan) for p in SHIP_PATTERNS):
167
209
  return True
168
- return _ships_at_command_position(text)
210
+
211
+ # `gh api` hitting a merge/create endpoint is a ship action however it is
212
+ # spelled. Checked on the same `scan` text, so quoted prose describing an
213
+ # endpoint is still prose.
214
+ if "gh" in scan and GH_API_SHIP_RE.search(scan):
215
+ return True
216
+
217
+ return _ships_at_command_position(text, root)
169
218
 
170
219
  # A ship action that NAMES a pull request. The review that matters is the one
171
220
  # for that PR's head branch, which is usually not the branch the shell is on.
@@ -351,7 +400,7 @@ def main() -> None:
351
400
  # enforcement-self-protect also lists this flag among the bypasses the agent
352
401
  # may not set, so an inline attempt is refused with an explicit message
353
402
  # instead of appearing to work.
354
- if not is_ship_action(cmd):
403
+ if not is_ship_action(cmd, worktree_root()):
355
404
  emit(True, "not a ship action")
356
405
 
357
406
  # Resolve against the WORKING TREE the operation runs in (the worktree when a
@@ -8,7 +8,7 @@ import time
8
8
  from pathlib import Path
9
9
 
10
10
  sys.path.insert(0, str(Path(__file__).parent))
11
- from _common import arg_str, emit, parse_cli, repo_root, worktree_root # noqa: E402
11
+ from _common import arg_str, emit, parse_cli, repo_root, worktree_root, recent_evidence # noqa: E402
12
12
 
13
13
  PLAN_OPS = {"ExitPlanMode", "Plan", "TodoWrite", "plan", "design"}
14
14
  # Only match standalone words, not compounds like 'validate-plan-on-change'
@@ -66,9 +66,14 @@ def main() -> None:
66
66
  if op not in PLAN_OPS and not PLAN_WORD_RE.search(blob):
67
67
  emit(True, "not a plan operation")
68
68
 
69
+ # Protected evidence first; the DB row is still accepted, but it is an
70
+ # ordinary row in a database the agent writes to constantly.
71
+ if recent_evidence("memory-queries", RECENT_SEC, repo_root()):
72
+ emit(True, "recent uap memory query on record (evidence)")
73
+
69
74
  for db in candidate_dbs():
70
75
  if recent_memory_query(db):
71
- emit(True, "recent uap memory query on record")
76
+ emit(True, "recent uap memory query on record (db row)")
72
77
 
73
78
  emit(
74
79
  False,
@@ -19,6 +19,25 @@ Allowed targets:
19
19
  UAP_WORKDIR_ALLOW.
20
20
 
21
21
  Escape hatch: UAP_WORKDIR_SCOPE_OFF=1 allows everything (operator override).
22
+
23
+ SCOPE — read this before trusting it as a boundary.
24
+
25
+ The Write/Edit path IS a boundary: it receives a concrete file path and
26
+ checks it, with no parsing involved.
27
+
28
+ The Bash path is DEFENCE IN DEPTH, not a boundary. It pattern-matches shell
29
+ text rather than parsing shell, and three separate review rounds each found
30
+ another construct the patterns missed: an escaped quote desyncing the mask,
31
+ command substitution executing inside double quotes, a tilde- or
32
+ variable-prefixed redirect target, a line continuation splitting a verb from
33
+ its destination, a process substitution hiding its destination in parens.
34
+ Each was fixed; the pattern is that there is always another one, because a
35
+ regex over command text cannot know what a shell will do with it.
36
+
37
+ So it raises the cost of an accidental escape and catches the naive forms —
38
+ which is what was actually observed in the wild. It does not stop a
39
+ determined one. The real containment is the tool-level path check above,
40
+ plus the fact that the agent cannot edit this file (enforcement-self-protect).
22
41
  """
23
42
  from __future__ import annotations
24
43
 
@@ -52,6 +52,16 @@ REL="${REL//$'\r'/ }"
52
52
  printf -v NOW '%(%s)T' -1 # bash builtin; no `date` fork
53
53
  printf '%s\t%s\n' "$NOW" "$REL" >> "$LOG" 2>/dev/null || exit 0
54
54
 
55
+ # Same record, in the protected evidence directory. This is the copy the gate
56
+ # trusts: self-protect refuses agent writes to .uap/evidence/, while the log
57
+ # above sits in the permissive part of .uap/ where a shell append is allowed.
58
+ # Written here because a hook is not an agent tool call, so it is not gated.
59
+ EVIDENCE_DIR="${STATE_DIR}/evidence"
60
+ if [ -d "$EVIDENCE_DIR" ] || mkdir -p "$EVIDENCE_DIR" 2>/dev/null; then
61
+ EVIDENCE="${EVIDENCE_DIR}/reads.log"
62
+ [ -L "$EVIDENCE" ] || printf '%s\t%s\n' "$NOW" "$REL" >> "$EVIDENCE" 2>/dev/null || true
63
+ fi
64
+
55
65
  # Bound the file. The enforcer only ever looks at a 30-minute window, so old
56
66
  # lines are dead weight. Sampled rather than checked every call: reading the log
57
67
  # on every Read to decide whether to trim costs more than the occasional
@@ -378,6 +378,34 @@ class TestShipDetectionIsCommandPosition(unittest.TestCase):
378
378
  self.assert_not_ship("printf " + SQ + "run gh pr merge later" + SQ)
379
379
  self.assert_not_ship("cat docs/merge-strategy.md")
380
380
 
381
+ # --- verbs that ship without saying "push" or "merge" ---
382
+ #
383
+ # Catalogued as gaps that pattern-matching could not reach. Three of the
384
+ # four turned out to be reachable; the fourth (a variable holding the
385
+ # binary name, `G=git; $G push`) is not, because knowing what $G contains
386
+ # means running the shell — it is documented rather than half-solved.
387
+
388
+ def test_plumbing_push_is_a_ship_action(self):
389
+ # `git push` is a wrapper around send-pack. Same effect, different verb.
390
+ self.assert_ship("git send-pack origin HEAD:refs/heads/main")
391
+
392
+ def test_plumbing_commit_is_a_ship_action(self):
393
+ self.assert_ship("git commit-tree abc123 -m msg")
394
+
395
+ def test_gh_api_rest_merge_is_a_ship_action(self):
396
+ # Merges the PR without the string "pr merge" appearing anywhere.
397
+ self.assert_ship("gh api -X PUT repos/owner/repo/pulls/1/merge")
398
+
399
+ def test_gh_api_graphql_merge_is_a_ship_action(self):
400
+ self.assert_ship(
401
+ "gh api graphql -f query=" + SQ + "mutation{mergePullRequest(input:{})}" + SQ
402
+ )
403
+
404
+ def test_prose_naming_a_merge_endpoint_is_still_prose(self):
405
+ # The endpoint patterns must not resurrect the false positives #649
406
+ # removed: naming an endpoint is not calling it.
407
+ self.assert_not_ship("echo " + SQ + "see repos/owner/repo/pulls/1/merge" + SQ)
408
+
381
409
  def test_read_only_git_is_not_a_ship_action(self):
382
410
  self.assert_not_ship("git diff --merge-base main")
383
411
  self.assert_not_ship("git status --short")
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """Gate evidence must not be writable by the agent.
3
+
4
+ Both plan-time gates accept "a record exists" as proof a required action
5
+ happened. Those records used to sit where an agent could write them: a shell
6
+ append to .uap/read_log.state was allowed (self-protect keeps its Bash scan
7
+ deliberately permissive inside .uap/, which is mostly runtime state), and the
8
+ memory_query record was an ordinary row in a database the agent writes to
9
+ constantly. During development a gate was in fact satisfied by exactly such an
10
+ append.
11
+
12
+ Evidence now lives in .uap/evidence/, listed in self-protect's
13
+ PROTECTED_TARGETS. Written only by the PostToolUse hook and the CLI — neither
14
+ is an agent tool call, so neither is intercepted.
15
+
16
+ This is not cryptographic and does not claim to be: forging evidence now costs
17
+ the same as defeating self-protect for any other purpose, rather than one echo.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import time
26
+ import unittest
27
+ from pathlib import Path
28
+
29
+ ENFORCERS = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers"
30
+ SELF_PROTECT = ENFORCERS / "enforcement_self_protect.py"
31
+ READ_GATE = ENFORCERS / "codebase_read_before_plan.py"
32
+ MEM_GATE = ENFORCERS / "memory_before_plan.py"
33
+ GT = chr(62)
34
+
35
+
36
+ def run_enforcer(enforcer, op, args, root):
37
+ env = dict(os.environ)
38
+ env["UAP_REPO_ROOT"] = str(root)
39
+ env["UAP_WORKTREE_ROOT"] = str(root)
40
+ env["PYTHONPATH"] = str(ENFORCERS)
41
+ env.pop("UAP_SELF_PROTECT_OFF", None)
42
+ p = subprocess.run(
43
+ [sys.executable, str(enforcer), "--operation", op, "--args", json.dumps(args)],
44
+ capture_output=True, text=True, env=env, cwd=str(root),
45
+ )
46
+ try:
47
+ return json.loads(p.stdout)
48
+ except json.JSONDecodeError:
49
+ return {"allowed": True, "reason": f"<unparseable {p.stdout!r} {p.stderr!r}>"}
50
+
51
+
52
+ class TestEvidenceIsNotAgentWritable(unittest.TestCase):
53
+ def setUp(self):
54
+ self._tmp = tempfile.TemporaryDirectory()
55
+ self.root = Path(self._tmp.name)
56
+ (self.root / ".uap" / "evidence").mkdir(parents=True)
57
+
58
+ def tearDown(self):
59
+ self._tmp.cleanup()
60
+
61
+ def _bash_blocked(self, cmd, label):
62
+ out = run_enforcer(SELF_PROTECT, "Bash", {"command": cmd}, self.root)
63
+ self.assertFalse(out.get("allowed"), f"{label}: {cmd!r} should be refused")
64
+
65
+ def test_shell_append_to_evidence_is_refused(self):
66
+ # The exact forgery that worked before.
67
+ self._bash_blocked("echo hi " + GT * 2 + " .uap/evidence/reads.log", "append")
68
+ self._bash_blocked(
69
+ "echo hi " + GT * 2 + " .uap/evidence/memory-queries.log", "append")
70
+
71
+ def test_other_writes_into_evidence_are_refused(self):
72
+ self._bash_blocked("echo hi | tee .uap/evidence/reads.log", "tee")
73
+ self._bash_blocked("cp /etc/hostname .uap/evidence/reads.log", "cp")
74
+ self._bash_blocked("rm -f .uap/evidence/reads.log", "rm")
75
+
76
+ def test_write_tool_into_evidence_is_refused(self):
77
+ out = run_enforcer(
78
+ SELF_PROTECT, "Write",
79
+ {"file_path": str(self.root / ".uap/evidence/reads.log")}, self.root)
80
+ self.assertFalse(out.get("allowed"), "Write into evidence should be refused")
81
+
82
+ def test_ordinary_uap_runtime_writes_still_work(self):
83
+ # The rest of .uap/ is runtime state the tooling writes constantly.
84
+ # Over-blocking it would break the project's own plumbing — the reason
85
+ # that scan was narrow to begin with.
86
+ for cmd in ["echo 0 " + GT + " .uap/verify-cadence",
87
+ "echo x " + GT * 2 + " .uap/pending-deliver.jsonl"]:
88
+ out = run_enforcer(SELF_PROTECT, "Bash", {"command": cmd}, self.root)
89
+ self.assertTrue(out.get("allowed"), f"{cmd!r} must stay allowed")
90
+
91
+
92
+ class TestGatesReadProtectedEvidence(unittest.TestCase):
93
+ def setUp(self):
94
+ self._tmp = tempfile.TemporaryDirectory()
95
+ self.root = Path(self._tmp.name)
96
+ (self.root / ".uap" / "evidence").mkdir(parents=True)
97
+ # Writer hook present, so the read gate enforces instead of degrading
98
+ # to advisory.
99
+ hooks = self.root / ".claude" / "hooks"
100
+ hooks.mkdir(parents=True)
101
+ (hooks / "post-tool-use-read.sh").write_text("#!/bin/sh\n")
102
+
103
+ def tearDown(self):
104
+ self._tmp.cleanup()
105
+
106
+ def _evidence(self, kind, age=0, detail="src/x.ts"):
107
+ path = self.root / ".uap" / "evidence" / f"{kind}.log"
108
+ with path.open("a") as f:
109
+ f.write(f"{int(time.time()) - age}\t{detail}\n")
110
+
111
+ def test_read_gate_accepts_fresh_evidence_and_blocks_without(self):
112
+ out = run_enforcer(READ_GATE, "ExitPlanMode", {}, self.root)
113
+ self.assertFalse(out.get("allowed"), "no evidence -> blocked")
114
+
115
+ self._evidence("reads")
116
+ out = run_enforcer(READ_GATE, "ExitPlanMode", {}, self.root)
117
+ self.assertTrue(out.get("allowed"), out.get("reason"))
118
+ self.assertIn("evidence", out.get("reason", ""))
119
+
120
+ def test_read_gate_still_blocks_on_stale_evidence(self):
121
+ self._evidence("reads", age=4000) # past the 30-minute window
122
+ out = run_enforcer(READ_GATE, "ExitPlanMode", {}, self.root)
123
+ self.assertFalse(out.get("allowed"), "stale evidence must not satisfy the gate")
124
+
125
+ def test_memory_gate_accepts_fresh_evidence_and_blocks_without(self):
126
+ out = run_enforcer(MEM_GATE, "ExitPlanMode", {}, self.root)
127
+ self.assertFalse(out.get("allowed"), "no evidence -> blocked")
128
+
129
+ self._evidence("memory-queries", detail="a topic")
130
+ out = run_enforcer(MEM_GATE, "ExitPlanMode", {}, self.root)
131
+ self.assertTrue(out.get("allowed"), out.get("reason"))
132
+ self.assertIn("evidence", out.get("reason", ""))
133
+
134
+ def test_memory_gate_still_blocks_on_stale_evidence(self):
135
+ self._evidence("memory-queries", age=1000, detail="old") # past 300s
136
+ out = run_enforcer(MEM_GATE, "ExitPlanMode", {}, self.root)
137
+ self.assertFalse(out.get("allowed"), "stale evidence must not satisfy the gate")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ unittest.main()