4646
4747import json
4848import logging
49+ import math
4950import shutil
5051import tempfile
5152from pathlib import Path
@@ -95,6 +96,55 @@ def _find_stock_rego_root() -> Path:
9596 )
9697
9798
99+ def _glob_to_re2 (value : str ) -> str :
100+ r"""Translate a shell glob to a Go RE2 regex anchored to the whole string.
101+
102+ Mirrors :func:`fnmatch.translate` semantics (``*`` / ``?`` / ``[seq]`` /
103+ ``[!seq]``) but emits only RE2-compatible syntax. ``fnmatch.translate``
104+ cannot be used directly: it produces Python-specific constructs — an inline
105+ ``(?s:...)`` flag group and the ``\Z`` end-of-string anchor — that Go RE2
106+ (the engine behind OPA's ``agt.patterns``) rejects, leaving the generated
107+ deny rule undefined so a ``GLOB`` blocked pattern silently fails open.
108+
109+ The result is a leading ``(?s)`` dot-all flag plus ``^``/``$`` anchors.
110+ Whole-string anchoring preserves v4 glob semantics (the pattern matches the
111+ entire policy text) under ``agt.patterns``' unanchored ``regex.match``.
112+ """
113+ import re
114+
115+ out : list [str ] = []
116+ i , n = 0 , len (value )
117+ while i < n :
118+ ch = value [i ]
119+ i += 1
120+ if ch == "*" :
121+ out .append (".*" )
122+ elif ch == "?" :
123+ out .append ("." )
124+ elif ch == "[" :
125+ j = i
126+ if j < n and value [j ] == "!" :
127+ j += 1
128+ if j < n and value [j ] == "]" :
129+ j += 1
130+ while j < n and value [j ] != "]" :
131+ j += 1
132+ if j >= n :
133+ # No closing bracket: treat '[' as a literal.
134+ out .append (r"\[" )
135+ else :
136+ inner = value [i :j ].replace ("\\ " , r"\\" )
137+ i = j + 1
138+ if inner .startswith ("!" ):
139+ inner = "^" + inner [1 :]
140+ elif inner .startswith ("^" ):
141+ inner = "\\ " + inner
142+ out .append ("[" + inner + "]" )
143+ else :
144+ out .append (re .escape (ch ))
145+ return "(?s)^" + "" .join (out ) + "$"
146+
147+
98148def _pattern_to_regex (pattern : Any ) -> str :
99149 """Normalise a v4 ``blocked_patterns`` entry to a Go RE2 regex string.
100150
@@ -119,9 +169,7 @@ def _pattern_to_regex(pattern: Any) -> str:
119169 if kind_name == "REGEX" :
120170 return value
121171 if kind_name == "GLOB" :
122- import fnmatch
123-
124- return fnmatch .translate (value )
172+ return _glob_to_re2 (value )
125173 raise ValueError (f"unsupported PatternType: { kind !r} " )
126174
127175 raise ValueError (f"unsupported blocked_patterns entry: { pattern !r} " )
@@ -180,12 +228,15 @@ def _render_rego(
180228 if budget_thresholds :
181229 branches .append (
182230 "v := budgets.deny_if_budget_exceeded("
183- f"{ json .dumps (budget_thresholds )} )"
231+ f"{ json .dumps (budget_thresholds , allow_nan = False )} )"
184232 )
185233
186234 if confidence_threshold is not None and confidence_threshold > 0.0 :
235+ # ``allow_nan=False`` is a defensive backstop; the caller
236+ # (governance_to_acs_manifest) rejects non-finite thresholds up front.
187237 branches .append (
188- f"v := confidence.deny_if_low_confidence({ json .dumps (confidence_threshold )} )"
238+ "v := confidence.deny_if_low_confidence("
239+ f"{ json .dumps (confidence_threshold , allow_nan = False )} )"
189240 )
190241
191242 if require_human_approval :
@@ -301,49 +352,77 @@ def governance_to_acs_manifest(
301352 ``policy.allowed_tools``. ``approval`` is set when
302353 ``require_human_approval`` is true.
303354 """
355+ created_bundle_dir = bundle_dir is None
304356 bundle_dir = (
305357 Path (bundle_dir ).resolve ()
306- if bundle_dir is not None
358+ if not created_bundle_dir
307359 else Path (tempfile .mkdtemp (prefix = "agt_bridge_" )).resolve ()
308360 )
309361 bundle_dir .mkdir (parents = True , exist_ok = True )
310362
311- stock_root = stock_rego_root or _find_stock_rego_root ()
312- for rego_file in stock_root .glob ("*.rego" ):
313- if rego_file .name .endswith ("_test.rego" ):
314- continue
315- shutil .copy (rego_file , bundle_dir / rego_file .name )
316-
317- blocked_pattern_regexes = [_pattern_to_regex (p ) for p in policy .blocked_patterns ]
318-
319- # AGT-M3 round-2 BLOCK A: ``max_tool_calls=0`` is the v4 sentinel for
320- # "deny every tool call", not "no constraint". Forward 0 through to the
321- # ``budgets.deny_if_budget_exceeded`` helper. The helper compares
322- # ``tool_call_count >= limit`` so with ``limit=0`` and the default
323- # ``tool_call_count=0`` the first call is denied with
324- # ``budget_tool_calls_exceeded``, which preserves the v4 contract end-to-end
325- # for any caller that loads the bridge manifest into AgtRuntime directly
326- # (not just through the AdapterRuntimeBridge host fallback). Previously a
327- # ``GovernancePolicy(max_tool_calls=0, confidence_threshold=0.0)`` slipped
328- # through to the default ``allow`` verdict because the budget rule was
329- # omitted and the fallback ``pre_tool_call`` binding (no ``tool_name_from``)
330- # never tripped any deny rule. Keep ``max_tokens`` at ``> 0`` because the v4
331- # dataclass validation rejects ``max_tokens <= 0`` and there is no v4 wire
332- # value to preserve there.
333- rego_source = _render_rego (
334- package = "agt.governance_policy" ,
335- max_tokens = policy .max_tokens if policy .max_tokens > 0 else None ,
336- max_tool_calls = policy .max_tool_calls if policy .max_tool_calls >= 0 else None ,
337- confidence_threshold = (
363+ try :
364+ stock_root = stock_rego_root or _find_stock_rego_root ()
365+ for rego_file in stock_root .glob ("*.rego" ):
366+ if rego_file .name .endswith ("_test.rego" ):
367+ continue
368+ shutil .copy (rego_file , bundle_dir / rego_file .name )
369+
370+ blocked_pattern_regexes = [
371+ _pattern_to_regex (p ) for p in policy .blocked_patterns
372+ ]
373+
374+ # A non-finite confidence_threshold is invalid input. ``inf`` would
375+ # otherwise slip past the ``> 0`` guard in _render_rego and render
376+ # ``deny_if_low_confidence(Infinity)`` (invalid Rego/JSON that fails to
377+ # compile); ``nan`` would be silently dropped, so a caller who set a
378+ # threshold would get no confidence deny at all. Fail loudly instead.
379+ if policy .confidence_threshold is not None and not math .isfinite (
338380 policy .confidence_threshold
339- if policy .confidence_threshold and policy .confidence_threshold > 0
340- else None
341- ),
342- blocked_patterns = blocked_pattern_regexes ,
343- require_human_approval = policy .require_human_approval ,
344- )
345- rego_path = bundle_dir / f"{ policy_id } .rego"
346- rego_path .write_text (rego_source , encoding = "utf-8" )
381+ ):
382+ raise ValueError (
383+ "confidence_threshold must be finite, got "
384+ f"{ policy .confidence_threshold !r} "
385+ )
386+
387+ # AGT-M3 round-2 BLOCK A: ``max_tool_calls=0`` is the v4 sentinel for
388+ # "deny every tool call", not "no constraint". Forward 0 through to the
389+ # ``budgets.deny_if_budget_exceeded`` helper. The helper compares
390+ # ``tool_call_count >= limit`` so with ``limit=0`` and the default
391+ # ``tool_call_count=0`` the first call is denied with
392+ # ``budget_tool_calls_exceeded``, which preserves the v4 contract
393+ # end-to-end for any caller that loads the bridge manifest into
394+ # AgtRuntime directly (not just through the AdapterRuntimeBridge host
395+ # fallback). Previously a ``GovernancePolicy(max_tool_calls=0,
396+ # confidence_threshold=0.0)`` slipped through to the default ``allow``
397+ # verdict because the budget rule was omitted and the fallback
398+ # ``pre_tool_call`` binding (no ``tool_name_from``) never tripped any
399+ # deny rule. Keep ``max_tokens`` at ``> 0`` because the v4 dataclass
400+ # validation rejects ``max_tokens <= 0`` and there is no v4 wire value
401+ # to preserve there.
402+ rego_source = _render_rego (
403+ package = "agt.governance_policy" ,
404+ max_tokens = policy .max_tokens if policy .max_tokens > 0 else None ,
405+ max_tool_calls = (
406+ policy .max_tool_calls if policy .max_tool_calls >= 0 else None
407+ ),
408+ confidence_threshold = (
409+ policy .confidence_threshold
410+ if policy .confidence_threshold and policy .confidence_threshold > 0
411+ else None
412+ ),
413+ blocked_patterns = blocked_pattern_regexes ,
414+ require_human_approval = policy .require_human_approval ,
415+ )
416+ rego_path = bundle_dir / f"{ policy_id } .rego"
417+ rego_path .write_text (rego_source , encoding = "utf-8" )
418+ except Exception :
419+ # Do not leave a half-built bundle behind (stock libs copied, generated
420+ # module missing) when pattern translation, rendering, or a write fails.
421+ # Only clean up a directory we created ourselves; a caller-supplied one
422+ # is theirs to manage.
423+ if created_bundle_dir :
424+ shutil .rmtree (bundle_dir , ignore_errors = True )
425+ raise
347426
348427 # bind_tools must also cover the case where the policy has a budget
349428 # or human-approval requirement but no explicit tool allowlist; without
0 commit comments