@miller-tech/uap 1.172.13 → 1.172.15

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.172.13",
3
+ "version": "1.172.15",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,6 +34,7 @@ Scope (Bash/bash/run_bash commands only):
34
34
  Killing a SPECIFIC process pattern (e.g. `pkill -f "python3 -m http.server
35
35
  8765"`) and serving on non-infra ports stay allowed.
36
36
  """
37
+ import os
37
38
  import re
38
39
  import sys
39
40
  from pathlib import Path
@@ -94,7 +95,8 @@ REASON = (
94
95
  "session runs on (llama-server :8080 / UAP proxy :4000 / embeddings :8081). "
95
96
  "Kill only your own processes by SPECIFIC pattern (e.g. pkill -f 'python3 -m "
96
97
  "http.server 8765') and serve your app on a port other than 8080/4000/8081 "
97
- "(e.g. 8765)."
98
+ "(e.g. 8765). Operator override: set UAP_INFRA_PROTECT_OFF=1 in the launch "
99
+ "environment (not inline on the command)."
98
100
  )
99
101
 
100
102
 
@@ -140,8 +142,31 @@ def _referenced_script_bodies(cmd: str, limit: int = 4) -> list[tuple[str, str]]
140
142
  return found
141
143
 
142
144
 
145
+ # Operator escape hatch — ENVIRONMENT ONLY, deliberately.
146
+ #
147
+ # This is the only enforcer that had no override, which is why every restart of
148
+ # the inference stack required the operator to run the command by hand. That is
149
+ # defensible for a control whose purpose is to stop the agent cycling the stack
150
+ # it runs on, but it also meant an operator who WANTED the agent to restart a
151
+ # service had no way to say so.
152
+ #
153
+ # It reads os.environ and nothing else. It does NOT honour an inline
154
+ # `UAP_INFRA_PROTECT_OFF=1 systemctl ...` assignment, unlike expert-review's
155
+ # override, because the agent composes its own command strings: an inline form is
156
+ # self-grantable and would delete the control rather than delegate it. An
157
+ # environment variable is set by whoever launched the session, so honouring only
158
+ # that keeps the decision with the operator.
159
+ #
160
+ # enforcement-self-protect additionally lists this flag among the bypasses the
161
+ # agent may not set, so an inline attempt is refused with an explicit message
162
+ # instead of silently doing nothing.
163
+ OVERRIDE = os.environ.get("UAP_INFRA_PROTECT_OFF") == "1"
164
+
165
+
143
166
  def main() -> None:
144
167
  operation, args = parse_cli()
168
+ if OVERRIDE:
169
+ emit(True, "UAP_INFRA_PROTECT_OFF=1 set in the launch environment (operator)")
145
170
  if operation not in BASH_OPS:
146
171
  emit(True, "not a shell operation")
147
172
  cmd = str(args.get("command") or "")
@@ -51,6 +51,14 @@ BYPASS_PATTERNS = (
51
51
  re.compile(r"UAP_SELF_PROTECT_OFF\s*=\s*['\"]?1", re.I),
52
52
  re.compile(r"UAP_NO_WORKTREE\s*=\s*['\"]?1", re.I),
53
53
  re.compile(r"UAP_WORKDIR_SCOPE_OFF\s*=\s*['\"]?1", re.I),
54
+ # infra-protect's hatch is environment-only by design; an inline assignment
55
+ # would be self-grantable, so refuse it here with a clear message rather than
56
+ # letting it look like it worked.
57
+ re.compile(r"UAP_INFRA_PROTECT_OFF\s*=\s*['\"]?1", re.I),
58
+ # expert-review's override is environment-only too, for the same reason: the
59
+ # agent writes its own command strings, so an inline form waives its own
60
+ # review. Refuse it here so the attempt is visible rather than silent.
61
+ re.compile(r"UAP_NO_REVIEW\s*=\s*['\"]?1", re.I),
54
62
  re.compile(r"UAP_USER_VALIDATION\s*=\s*['\"]?0", re.I),
55
63
  )
56
64
  # Destructive ops against the enforcer/policy surface.
@@ -131,13 +131,25 @@ def main() -> None:
131
131
  emit(True, "not a ship operation")
132
132
 
133
133
  cmd = args.get("command") or args.get("cmd") or ""
134
- # The policy-gate hook runs in the harness env, not the inline command env,
135
- # so `UAP_NO_REVIEW=1 git commit ...` never reaches os.environ above. Honor
136
- # an inline assignment parsed from the command string too.
137
- # Anchored to a LEADING env-assignment run so an incidental mention in a
138
- # quoted arg / commit message does NOT silently skip review.
139
- if re.search(r"^\s*(?:[A-Za-z_]\w*=\S*\s+)*UAP_NO_REVIEW=['\"]?1\b", cmd):
140
- emit(True, "UAP_NO_REVIEW inline override set")
134
+ # The inline override is GONE, deliberately.
135
+ #
136
+ # It used to parse a leading UAP_NO_REVIEW=1 assignment out of the command
137
+ # string, on the reasoning that the policy-gate hook runs in the harness
138
+ # environment and so cannot see one. That reasoning was correct — but the
139
+ # agent composes its own command strings, which made the override
140
+ # SELF-GRANTABLE. In practice an agent session waived expert review on every
141
+ # ship action it performed. That is not delegating the decision, it is
142
+ # removing the gate.
143
+ #
144
+ # Now environment-only: os.environ is set by whoever launched the session, so
145
+ # the decision stays with the operator. Two env-free routes remain for
146
+ # harnesses that strip the environment, and both are deliberate acts visible
147
+ # in the tree rather than a per-command flag:
148
+ # - policies/waivers/*expert-review*.md
149
+ # - .uap/reviews/WAIVER
150
+ # enforcement-self-protect also lists this flag among the bypasses the agent
151
+ # may not set, so an inline attempt is refused with an explicit message
152
+ # instead of appearing to work.
141
153
  if not any(p.search(cmd) for p in SHIP_PATTERNS):
142
154
  emit(True, "not a ship action")
143
155
 
@@ -175,7 +187,8 @@ def main() -> None:
175
187
  f"expert-review-required: no review artifact at .uap/reviews/{slug}.json. "
176
188
  "Run the parallel-expert-review skill (code-quality, security, performance, "
177
189
  "docs, test-coverage reviewers) and record the consolidated verdict before "
178
- "shipping. Override for one-off meta-work: UAP_NO_REVIEW=1.",
190
+ "shipping. Operator override: UAP_NO_REVIEW=1 in the launch environment "
191
+ "(no longer honoured inline), or a waiver file.",
179
192
  )
180
193
 
181
194
  head = head_sha(root)
@@ -192,7 +205,7 @@ def main() -> None:
192
205
  False,
193
206
  f"expert-review-required: review at .uap/reviews/{slug}.json covers branch "
194
207
  f"'{artifact_branch}', not '{branch}'. Re-run the parallel expert review on "
195
- "this branch. Override: UAP_NO_REVIEW=1.",
208
+ "this branch. Operator override: UAP_NO_REVIEW=1 in the launch environment.",
196
209
  )
197
210
 
198
211
  # Stale check relative to current HEAD.
@@ -2970,9 +2970,30 @@ _current_request_session: contextvars.ContextVar[str | None] = contextvars.Conte
2970
2970
  #
2971
2971
  # Same ContextVar trick as the session above: request-local without threading a
2972
2972
  # Request through every signature. Holds Starlette's `request.is_disconnected`.
2973
- _current_client_gone: contextvars.ContextVar[
2974
- Callable[[], Awaitable[bool]] | None
2975
- ] = contextvars.ContextVar("uap_current_client_gone", default=None)
2973
+ # Is the DOWNSTREAM client still there?
2974
+ #
2975
+ # Nothing used to ask. Once a turn was accepted, the guardrail loops kept
2976
+ # retrying and re-POSTing upstream on their own — so when the client died the
2977
+ # work carried on regardless. Observed live: two `uap deliver` runs were killed,
2978
+ # no socket remained on :4000, and the proxy still drove llama for minutes,
2979
+ # generating 32k tokens per orphaned turn that nobody would ever read.
2980
+ #
2981
+ # WHY NOT request.is_disconnected(). That was tried and MEASURED not to work
2982
+ # here: it returned False 16 times across 30s while the caller was demonstrably
2983
+ # gone. The cause is `@app.middleware("http")` — Starlette's BaseHTTPMiddleware
2984
+ # runs the endpoint in a separate anyio task behind its own receive channel, so
2985
+ # the endpoint polls a channel that never delivers http.disconnect. It works
2986
+ # fine in an app with no middleware, which is exactly why three rounds of
2987
+ # isolated testing said it was sound.
2988
+ #
2989
+ # So the signal is captured by a pure-ASGI middleware OUTSIDE all of that (see
2990
+ # DisconnectWatcherMiddleware) and published here as a mutable holder. A dict
2991
+ # rather than a bool because the middleware's poller task and the endpoint must
2992
+ # see the same object: the ContextVar is set before the app is invoked, so every
2993
+ # downstream task inherits the reference, and mutations are visible across them.
2994
+ _disconnect_holder: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
2995
+ "uap_disconnect_holder", default=None
2996
+ )
2976
2997
 
2977
2998
 
2978
2999
  class ClientGoneError(Exception):
@@ -2986,19 +3007,17 @@ class ClientGoneError(Exception):
2986
3007
 
2987
3008
 
2988
3009
  async def _client_gone() -> bool:
2989
- """True when the caller has hung up. Never raises, never blocks meaningfully.
3010
+ """True when the caller has hung up.
2990
3011
 
2991
- A disconnect check that can itself fail or stall would be worse than no
2992
- check: it sits in front of every upstream call. Any error means "assume the
2993
- client is still there" the pre-existing behaviour.
3012
+ Now a flag read rather than a call into Starlette, so there is nothing to
3013
+ raise and nothing for a fail-safe except to swallow. The previous version
3014
+ wrapped request.is_disconnected() in `except Exception: return False`, which
3015
+ would have hidden a failing probe as "client still present" — it turned out
3016
+ the probe was not raising, but the shape was wrong regardless.
2994
3017
  """
2995
- probe = _current_client_gone.get()
2996
- if probe is None:
2997
- return False
2998
- try:
2999
- return bool(await probe())
3000
- except Exception: # noqa: BLE001
3001
- return False
3018
+ holder = _disconnect_holder.get()
3019
+ return bool(holder and holder.get("gone"))
3020
+
3002
3021
 
3003
3022
  # Session admission state. _admitted_sessions maps session_id -> last-seen
3004
3023
  # monotonic ts; OrderedDict insertion order is the LRU (oldest = front).
@@ -3385,8 +3404,8 @@ class DisconnectAwareClient(httpx.AsyncClient):
3385
3404
  """
3386
3405
 
3387
3406
  async def send(self, request, **kwargs): # type: ignore[override]
3388
- probe = _current_client_gone.get()
3389
- if probe is None:
3407
+ holder = _disconnect_holder.get()
3408
+ if holder is None:
3390
3409
  return await super().send(request, **kwargs)
3391
3410
 
3392
3411
  task = asyncio.ensure_future(super().send(request, **kwargs))
@@ -3635,6 +3654,73 @@ async def lifespan(app: FastAPI):
3635
3654
  logger.info("Proxy shut down")
3636
3655
 
3637
3656
 
3657
+ class DisconnectWatcherMiddleware:
3658
+ """Pure-ASGI middleware that records when the caller hangs up.
3659
+
3660
+ Sits OUTERMOST, above the auth middleware, and is deliberately pure ASGI:
3661
+ Starlette's BaseHTTPMiddleware (`@app.middleware("http")`) re-tasks the
3662
+ endpoint behind its own receive channel, which is precisely why
3663
+ request.is_disconnected() reported False 16 times across 30s while the
3664
+ caller was gone. Being outside that, this sees the real channel.
3665
+
3666
+ THE HARD PART is that wrapping receive() is not enough on its own. After the
3667
+ request body is fully read nothing calls receive() again, so an
3668
+ http.disconnect just sits in the channel unobserved. So once the body is
3669
+ complete this starts polling the channel itself — safe precisely because the
3670
+ app has no more body to ask for, and the only remaining message type is the
3671
+ disconnect.
3672
+
3673
+ The flag lives in a mutable dict published on a ContextVar before the app is
3674
+ invoked: downstream tasks inherit the reference when their context is copied,
3675
+ so a mutation made by the poller is visible to the endpoint and to every
3676
+ upstream call it makes.
3677
+ """
3678
+
3679
+ def __init__(self, app):
3680
+ self.app = app
3681
+
3682
+ async def __call__(self, scope, receive, send):
3683
+ if scope.get("type") != "http":
3684
+ return await self.app(scope, receive, send)
3685
+
3686
+ holder: dict = {"gone": False}
3687
+ _disconnect_holder.set(holder)
3688
+ poller: asyncio.Task | None = None
3689
+
3690
+ async def poll_for_disconnect() -> None:
3691
+ # Only reached once the body is complete, so this cannot steal a
3692
+ # message the app still needs.
3693
+ try:
3694
+ while not holder["gone"]:
3695
+ msg = await receive()
3696
+ if msg.get("type") == "http.disconnect":
3697
+ holder["gone"] = True
3698
+ return
3699
+ except asyncio.CancelledError:
3700
+ raise
3701
+ except Exception: # noqa: BLE001
3702
+ return # channel closed/erroring: stop watching, never crash
3703
+
3704
+ async def watched_receive():
3705
+ nonlocal poller
3706
+ msg = await receive()
3707
+ if msg.get("type") == "http.disconnect":
3708
+ holder["gone"] = True
3709
+ elif msg.get("type") == "http.request" and not msg.get("more_body", False):
3710
+ # Body complete — nobody will call receive() again, so take over.
3711
+ if poller is None:
3712
+ poller = asyncio.ensure_future(poll_for_disconnect())
3713
+ return msg
3714
+
3715
+ try:
3716
+ await self.app(scope, watched_receive, send)
3717
+ finally:
3718
+ if poller is not None and not poller.done():
3719
+ poller.cancel()
3720
+
3721
+
3722
+
3723
+
3638
3724
  app = FastAPI(
3639
3725
  title="UAP Anthropic Proxy",
3640
3726
  description="Translates Anthropic Messages API to OpenAI Chat Completions API",
@@ -3642,6 +3728,7 @@ app = FastAPI(
3642
3728
  lifespan=lifespan,
3643
3729
  )
3644
3730
 
3731
+
3645
3732
  @app.exception_handler(ClientGoneError)
3646
3733
  async def _client_gone_handler(request: Request, exc: ClientGoneError):
3647
3734
  """The caller hung up mid-turn: stop, and say so at INFO rather than ERROR.
@@ -3696,6 +3783,18 @@ async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
3696
3783
  _PROXY_AUTH_OPEN_PATHS = frozenset({"/health", "/", "/v1/models"})
3697
3784
 
3698
3785
 
3786
+ # OUTERMOST — and it must be registered AFTER the auth middleware below to BE
3787
+ # outermost: Starlette's add_middleware inserts at position 0, so the last one
3788
+ # registered is the outermost wrapper. (Registered before auth first time round,
3789
+ # which would have put it INSIDE the very BaseHTTPMiddleware that hides
3790
+ # http.disconnect — the exact bug it exists to work around.)
3791
+ #
3792
+ # The registration therefore trails the auth definition on purpose. Auth itself
3793
+ # is deliberately untouched.
3794
+ def _install_disconnect_watcher() -> None:
3795
+ app.add_middleware(DisconnectWatcherMiddleware)
3796
+
3797
+
3699
3798
  @app.middleware("http")
3700
3799
  async def _shared_secret_auth(request: Request, call_next):
3701
3800
  """Gate every request behind PROXY_AUTH_TOKEN when it is set.
@@ -3754,6 +3853,10 @@ async def _shared_secret_auth(request: Request, call_next):
3754
3853
  # ===========================================================================
3755
3854
 
3756
3855
 
3856
+
3857
+ # Auth is now defined, so this lands OUTSIDE it (add_middleware inserts at 0).
3858
+ _install_disconnect_watcher()
3859
+
3757
3860
  def _image_block_to_openai(block: dict) -> dict | None:
3758
3861
  """Anthropic image block → OpenAI image_url part (data URI or URL)."""
3759
3862
  src = block.get("source") or {}
@@ -10908,10 +11011,6 @@ async def messages(request: Request):
10908
11011
  """
10909
11012
  global last_session_id
10910
11013
 
10911
- # Publish this request's disconnect probe for the whole call tree. Set before
10912
- # ANY upstream work so an already-dead client is caught on the first call.
10913
- _current_client_gone.set(request.is_disconnected)
10914
-
10915
11014
  body = await request.json()
10916
11015
  is_stream = body.get("stream", False)
10917
11016
  model = body.get("model", "default")
@@ -28,33 +28,32 @@ proxy = _load_proxy()
28
28
 
29
29
 
30
30
  class ClientGoneProbeTest(unittest.TestCase):
31
- def test_absent_probe_means_client_is_present(self):
32
- """No probe set (background task, test harness) must not read as gone —
33
- that would abandon perfectly live turns."""
34
- proxy._current_client_gone.set(None)
31
+ """The probe is now a FLAG READ, not a call into Starlette.
32
+
33
+ It used to call request.is_disconnected() wrapped in `except Exception:
34
+ return False`. That was measured not to work at all behind
35
+ BaseHTTPMiddleware (False 16x across 30s while the caller was gone), and the
36
+ swallow-everything shape would have hidden a failing probe as "client still
37
+ present". Both are gone: there is nothing left to raise.
38
+ """
39
+
40
+ def test_no_holder_means_client_is_present(self):
41
+ """Background tasks and health checks have no request context. They must
42
+ not read as disconnected."""
43
+ proxy._disconnect_holder.set(None)
35
44
  self.assertFalse(asyncio.run(proxy._client_gone()))
36
45
 
37
46
  def test_reports_disconnect(self):
38
- async def gone() -> bool:
39
- return True
40
-
41
- proxy._current_client_gone.set(gone)
47
+ proxy._disconnect_holder.set({"gone": True})
42
48
  self.assertTrue(asyncio.run(proxy._client_gone()))
43
49
 
44
50
  def test_reports_connected(self):
45
- async def here() -> bool:
46
- return False
47
-
48
- proxy._current_client_gone.set(here)
51
+ proxy._disconnect_holder.set({"gone": False})
49
52
  self.assertFalse(asyncio.run(proxy._client_gone()))
50
53
 
51
- def test_a_failing_probe_never_breaks_the_request(self):
52
- """This check sits in front of every upstream call. A probe that can
53
- raise would be worse than no probe, so failure means 'still there'."""
54
- async def broken() -> bool:
55
- raise RuntimeError("receive channel exploded")
56
-
57
- proxy._current_client_gone.set(broken)
54
+ def test_a_malformed_holder_reads_as_present(self):
55
+ """Defensive: an empty dict must not be mistaken for a disconnect."""
56
+ proxy._disconnect_holder.set({})
58
57
  self.assertFalse(asyncio.run(proxy._client_gone()))
59
58
 
60
59
 
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python3
2
+ """Pure-ASGI disconnect watcher — the thing that finally made the abort work.
3
+
4
+ Four attempts failed before this. The measured cause: `@app.middleware("http")`
5
+ is Starlette's BaseHTTPMiddleware, which runs the endpoint in a separate anyio
6
+ task behind its own receive channel, so request.is_disconnected() in the endpoint
7
+ polls a channel that never delivers http.disconnect. Instrumented in production
8
+ it returned False 16 times across 30s while the caller was demonstrably gone —
9
+ and it worked perfectly in an app with no middleware, which is exactly why three
10
+ rounds of isolated testing said the approach was sound.
11
+
12
+ Two properties are load-bearing and each has a test that fails if it breaks:
13
+
14
+ 1. ORDER. The watcher must be OUTERMOST. Starlette's add_middleware inserts at
15
+ position 0, so it must be registered LAST. The first implementation
16
+ registered it before auth, putting it inside the very middleware whose
17
+ behaviour it exists to work around.
18
+ 2. POLLING. Wrapping receive() is not enough. Once the body is fully read
19
+ nothing calls receive() again, so an http.disconnect sits in the channel
20
+ unobserved. The watcher must take over polling after the final body message.
21
+
22
+ Verified end to end after these were in place: the llama slot freed within 5s of
23
+ the client hanging up, against 30s+ of continued generation before.
24
+ """
25
+
26
+ import ast
27
+ import asyncio
28
+ import importlib.util
29
+ import unittest
30
+ from pathlib import Path
31
+
32
+ PROXY_PATH = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
33
+
34
+
35
+ def _load_proxy():
36
+ spec = importlib.util.spec_from_file_location("anthropic_proxy_watcher", PROXY_PATH)
37
+ assert spec is not None and spec.loader is not None
38
+ m = importlib.util.module_from_spec(spec)
39
+ spec.loader.exec_module(m)
40
+ return m
41
+
42
+
43
+ proxy = _load_proxy()
44
+
45
+
46
+ class MiddlewareOrderTest(unittest.TestCase):
47
+ def test_the_watcher_is_outermost(self):
48
+ """If it ends up inside BaseHTTPMiddleware it cannot see http.disconnect,
49
+ which is the whole failure this replaced."""
50
+ names = [getattr(mw.cls, "__name__", str(mw.cls)) for mw in proxy.app.user_middleware]
51
+ self.assertTrue(names, "no middleware registered at all")
52
+ self.assertEqual(
53
+ names[0],
54
+ "DisconnectWatcherMiddleware",
55
+ f"watcher is not outermost; stack is {names}",
56
+ )
57
+
58
+ def test_auth_middleware_is_still_installed(self):
59
+ """The brief was to leave auth alone. Losing it would silently open the
60
+ LAN token gate — a far worse outcome than the bug being fixed."""
61
+ names = [getattr(mw.cls, "__name__", str(mw.cls)) for mw in proxy.app.user_middleware]
62
+ self.assertIn("BaseHTTPMiddleware", names)
63
+
64
+ def test_probe_is_a_flag_read_not_a_starlette_call(self):
65
+ """_client_gone must not call back into the request object: that is the
66
+ path that measured False-forever behind BaseHTTPMiddleware.
67
+
68
+ Checked with ast rather than substring matching — the first version of
69
+ this test failed on the word appearing in the function's own docstring,
70
+ which is a false positive, not a finding.
71
+ """
72
+ tree = ast.parse(PROXY_PATH.read_text())
73
+ fn = next(
74
+ n for n in ast.walk(tree)
75
+ if isinstance(n, ast.AsyncFunctionDef) and n.name == "_client_gone"
76
+ )
77
+ attrs = {
78
+ n.attr for n in ast.walk(fn) if isinstance(n, ast.Attribute)
79
+ }
80
+ self.assertNotIn("is_disconnected", attrs)
81
+ # And it must read the holder, so the test fails if the body is gutted.
82
+ self.assertIn("get", attrs)
83
+
84
+
85
+ class WatcherBehaviourTest(unittest.TestCase):
86
+ """Drive the middleware directly with an ASGI receive/send pair."""
87
+
88
+ def _run(self, messages, app_body):
89
+ """messages: what receive() yields, in order. app_body: coroutine fn(scope, receive, send)."""
90
+ sent = []
91
+ queue = list(messages)
92
+
93
+ async def receive():
94
+ if queue:
95
+ return queue.pop(0)
96
+ await asyncio.sleep(3600) # channel idle, like a live connection
97
+
98
+ async def send(msg):
99
+ sent.append(msg)
100
+
101
+ mw = proxy.DisconnectWatcherMiddleware(app_body)
102
+ asyncio.run(mw({"type": "http"}, receive, send))
103
+ return sent
104
+
105
+ def test_records_a_disconnect_the_app_itself_reads(self):
106
+ seen = {}
107
+
108
+ async def app(scope, receive, send):
109
+ await receive() # body
110
+ msg = await receive() # disconnect
111
+ seen["type"] = msg["type"]
112
+ seen["gone"] = await proxy._client_gone()
113
+
114
+ self._run(
115
+ [{"type": "http.request", "body": b"{}", "more_body": False},
116
+ {"type": "http.disconnect"}],
117
+ app,
118
+ )
119
+ self.assertEqual(seen["type"], "http.disconnect")
120
+ self.assertTrue(seen["gone"], "watcher did not record the disconnect")
121
+
122
+ def test_records_a_disconnect_the_app_NEVER_reads(self):
123
+ """The real case. After the body is consumed the app never calls receive()
124
+ again, so the disconnect is only ever seen by the watcher's own poller.
125
+ Wrapping receive() alone would leave `gone` False here forever."""
126
+ seen = {}
127
+
128
+ async def app(scope, receive, send):
129
+ await receive() # body only — then just "work", like a generation
130
+ for _ in range(40):
131
+ await asyncio.sleep(0.02)
132
+ if await proxy._client_gone():
133
+ seen["gone"] = True
134
+ return
135
+ seen["gone"] = False
136
+
137
+ self._run(
138
+ [{"type": "http.request", "body": b"{}", "more_body": False},
139
+ {"type": "http.disconnect"}],
140
+ app,
141
+ )
142
+ self.assertTrue(seen.get("gone"), "poller did not observe the unread disconnect")
143
+
144
+ def test_a_live_request_is_never_marked_gone(self):
145
+ """A false positive would abort healthy turns — worse than the bug."""
146
+ seen = {}
147
+
148
+ async def app(scope, receive, send):
149
+ await receive()
150
+ for _ in range(10):
151
+ await asyncio.sleep(0.02)
152
+ seen["gone"] = await proxy._client_gone()
153
+
154
+ self._run([{"type": "http.request", "body": b"{}", "more_body": False}], app)
155
+ self.assertFalse(seen["gone"])
156
+
157
+ def test_non_http_scopes_pass_straight_through(self):
158
+ """Lifespan and websocket scopes have no disconnect semantics here and
159
+ must not be wrapped."""
160
+ called = {}
161
+
162
+ async def app(scope, receive, send):
163
+ called["type"] = scope["type"]
164
+
165
+ async def go():
166
+ mw = proxy.DisconnectWatcherMiddleware(app)
167
+ await mw({"type": "lifespan"}, None, None)
168
+
169
+ asyncio.run(go())
170
+ self.assertEqual(called["type"], "lifespan")
171
+
172
+
173
+ if __name__ == "__main__":
174
+ unittest.main()
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Operator escape hatches must be operator-only — not self-grantable.
3
+
4
+ Two changes, one principle. The agent composes its own command strings, so any
5
+ override parsed OUT OF a command string is one the agent can grant itself. An
6
+ override read from os.environ is set by whoever launched the session.
7
+
8
+ - infra-protect had NO hatch at all, so every restart of the inference stack
9
+ needed the operator to type it. Now UAP_INFRA_PROTECT_OFF=1, environment only.
10
+ - expert-review HAD an inline hatch, and it was self-granted on all eleven
11
+ commits of one agent session. That is not delegating the decision, it is
12
+ removing the gate. Now UAP_NO_REVIEW=1, environment only.
13
+
14
+ self-protect additionally refuses inline attempts at either flag, so trying reads
15
+ as an explicit refusal rather than appearing to work.
16
+
17
+ NOTE ON THE HARNESS: every case runs with cwd inside a real git repo. Outside
18
+ one, expert-review fail-opens on an unresolvable branch and EVERY result reads
19
+ "allowed" — the first version of this verification was run from a temp dir and
20
+ reported the self-grant case as passing when it was not.
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import subprocess
26
+ import sys
27
+ import unittest
28
+ from pathlib import Path
29
+
30
+ # parents: [0]=tests [1]=agents [2]=tools [3]=repo root. Off-by-one here made
31
+ # every enforcer path invalid and the suite fail for the wrong reason.
32
+ REPO = Path(__file__).resolve().parents[3]
33
+ ENFORCERS = REPO / "src" / "policies" / "enforcers"
34
+
35
+ # Assembled rather than written literally: these enforcers scan command strings
36
+ # for ship/infra verbs, so a literal here trips the gate on this file's own test
37
+ # run. (infra-protect refused its own comment text for exactly this reason.)
38
+ SHIP = "git " + "commit -m x"
39
+ RESTART = "systemctl --user " + "rest" + "art " + "uap-" + "llama-server.service"
40
+
41
+
42
+ def verdict(enforcer: str, cmd: str, env: dict | None = None):
43
+ e = dict(os.environ)
44
+ for k in ("UAP_NO_REVIEW", "UAP_INFRA_PROTECT_OFF", "UAP_SELF_PROTECT_OFF"):
45
+ e.pop(k, None)
46
+ e.update(env or {})
47
+ p = subprocess.run(
48
+ [sys.executable, str(ENFORCERS / enforcer),
49
+ "--operation", "Bash", "--args", json.dumps({"command": cmd})],
50
+ capture_output=True, text=True, cwd=str(REPO), env=e,
51
+ )
52
+ try:
53
+ return json.loads(p.stdout or "{}").get("allowed")
54
+ except Exception: # noqa: BLE001
55
+ return f"ERR {(p.stderr or '')[:80]}"
56
+
57
+
58
+ class InfraProtectHatchTest(unittest.TestCase):
59
+ E = "enforcement_infra_protect.py"
60
+
61
+ def test_blocks_a_stack_restart_by_default(self):
62
+ self.assertFalse(verdict(self.E, RESTART))
63
+
64
+ def test_env_override_allows_it(self):
65
+ self.assertTrue(verdict(self.E, RESTART, {"UAP_INFRA_PROTECT_OFF": "1"}))
66
+
67
+ def test_inline_override_is_NOT_honoured(self):
68
+ """The load-bearing case. An inline form would be self-grantable, which
69
+ would delete the control rather than delegate it."""
70
+ self.assertFalse(verdict(self.E, f"UAP_INFRA_PROTECT_OFF=1 {RESTART}"))
71
+
72
+ def test_harmless_commands_are_untouched(self):
73
+ self.assertTrue(verdict(self.E, "echo hi"))
74
+
75
+ def test_the_refusal_tells_the_operator_the_hatch_exists(self):
76
+ """An undiscoverable hatch is not much better than none: the whole
77
+ problem was an operator with no way to say yes."""
78
+ src = (ENFORCERS / self.E).read_text()
79
+ self.assertIn("UAP_INFRA_PROTECT_OFF", src.split("REASON")[1] if "REASON" in src else src)
80
+
81
+
82
+ class ExpertReviewHatchTest(unittest.TestCase):
83
+ E = "expert_review_required.py"
84
+
85
+ def test_inline_override_is_NOT_honoured(self):
86
+ """This is the regression that mattered: it used to return True here, and
87
+ an agent session used it to waive review on every commit it made."""
88
+ self.assertFalse(verdict(self.E, f"UAP_NO_REVIEW=1 {SHIP}"))
89
+
90
+ def test_env_override_allows_it(self):
91
+ self.assertTrue(verdict(self.E, SHIP, {"UAP_NO_REVIEW": "1"}))
92
+
93
+ def test_non_ship_commands_are_untouched(self):
94
+ self.assertTrue(verdict(self.E, "echo hi"))
95
+
96
+ def test_the_inline_parser_is_gone_from_source(self):
97
+ """Behavioural tests could pass while a dormant parser waits to be
98
+ re-enabled; assert the code path is actually removed."""
99
+ src = (ENFORCERS / self.E).read_text()
100
+ self.assertNotIn("inline override set", src)
101
+
102
+
103
+ class SelfProtectRefusesInlineAttemptsTest(unittest.TestCase):
104
+ E = "enforcement_self_protect.py"
105
+
106
+ def test_refuses_inline_no_review(self):
107
+ self.assertFalse(verdict(self.E, f"UAP_NO_REVIEW=1 {SHIP}"))
108
+
109
+ def test_refuses_inline_infra_protect_off(self):
110
+ self.assertFalse(verdict(self.E, f"UAP_INFRA_PROTECT_OFF=1 {RESTART}"))
111
+
112
+ def test_still_refuses_the_pre_existing_bypasses(self):
113
+ """Guards against a regression that drops the older patterns while adding
114
+ the new ones."""
115
+ self.assertFalse(verdict(self.E, "UAP_DELIVER_BYPASS=1 " + SHIP))
116
+ self.assertFalse(verdict(self.E, "UAP_WORKDIR_SCOPE_OFF=1 touch /tmp/x"))
117
+
118
+ def test_harmless_commands_are_untouched(self):
119
+ self.assertTrue(verdict(self.E, "echo hi"))
120
+
121
+
122
+ if __name__ == "__main__":
123
+ unittest.main()
@@ -115,9 +115,9 @@ class ChokePointBehaviourTest(unittest.TestCase):
115
115
  return client, state, (lambda: setattr(httpx.AsyncClient, "send", original))
116
116
 
117
117
  def _set_probe(self, gone: bool):
118
- async def probe() -> bool:
119
- return gone
120
- proxy._current_client_gone.set(probe)
118
+ # The guard now reads a shared mutable holder published by the ASGI
119
+ # watcher, rather than calling back into the request object.
120
+ proxy._disconnect_holder.set({"gone": gone})
121
121
 
122
122
  def test_cancels_upstream_when_the_caller_leaves(self):
123
123
  client, state, restore = self._client_with_send("never")
@@ -147,7 +147,7 @@ class ChokePointBehaviourTest(unittest.TestCase):
147
147
  keep working rather than acquiring surprise cancellation."""
148
148
  client, state, restore = self._client_with_send("fast")
149
149
  try:
150
- proxy._current_client_gone.set(None)
150
+ proxy._disconnect_holder.set(None)
151
151
  self.assertEqual(asyncio.run(client.send(self._Req())), "RESPONSE")
152
152
  finally:
153
153
  restore()