@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/memory.d.ts.map +1 -1
- package/dist/cli/memory.js +14 -1
- package/dist/cli/memory.js.map +1 -1
- package/dist/cli/policy.d.ts +9 -0
- package/dist/cli/policy.d.ts.map +1 -1
- package/dist/cli/policy.js +30 -3
- package/dist/cli/policy.js.map +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/_common.py +48 -0
- package/src/policies/enforcers/codebase_read_before_plan.py +9 -2
- package/src/policies/enforcers/enforcement_self_protect.py +6 -0
- package/src/policies/enforcers/expert_review_required.py +56 -7
- package/src/policies/enforcers/memory_before_plan.py +7 -2
- package/src/policies/enforcers/workdir_scope.py +19 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/post-tool-use-read.sh +10 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_expert_review_pr_scope.py +28 -0
- package/tools/agents/tests/test_gate_evidence.py +141 -0
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
|
Binary file
|
|
@@ -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
|
|
Binary file
|
|
@@ -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()
|