gabriel / muse public
policies.py python
275 lines 8.8 KB
Raw
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40 docs: add | jq convention to --json section of agent-guide Sonnet 4.6 1 day ago
1 """Harmony policy persistence and matching — save, load, list, remove, match.
2
3 Single responsibility: CRUD for Policy objects in the harmony store plus the
4 _condition_matches / match_policy evaluation helpers.
5 """
6
7 from __future__ import annotations
8
9 import fnmatch
10 import json
11 import logging
12 import pathlib
13 from collections.abc import Mapping
14
15 from muse.core.types import JsonValue, load_json_file
16
17 from .fingerprint import _parse_dt
18 from .paths import policies_dir, _validate_policy_id
19 from .patterns import _write_atomic
20 from .types import (
21 ConflictPattern,
22 Policy,
23 PolicyAction,
24 PolicyCondition,
25 PolicyScope,
26 _PolicyConditionDict,
27 _PolicyDict,
28 )
29
30 logger = logging.getLogger(__name__)
31
32 #: Maximum bytes read from a policy JSON file.
33 _MAX_POLICY_BYTES: int = 8_192 # 8 KiB
34
35 #: Maximum policies loaded in a single :func:`list_policies` call.
36 _MAX_POLICIES: int = 1_000
37
38 # ---------------------------------------------------------------------------
39 # Serialisation helpers
40 # ---------------------------------------------------------------------------
41
42 def _policy_condition_to_dict(cond: PolicyCondition) -> _PolicyConditionDict:
43 return _PolicyConditionDict(
44 conflict_type=cond.conflict_type,
45 domain=cond.domain,
46 path_pattern=cond.path_pattern,
47 min_confidence=cond.min_confidence,
48 )
49
50 def _policy_to_dict(policy: Policy) -> _PolicyDict:
51 return _PolicyDict(
52 policy_id=policy.policy_id,
53 description=policy.description,
54 when=_policy_condition_to_dict(policy.when),
55 action=policy.action,
56 confidence=policy.confidence,
57 escalate_to=policy.escalate_to,
58 delegate_to=policy.delegate_to,
59 scope=policy.scope,
60 created_at=policy.created_at.isoformat(),
61 created_by=policy.created_by,
62 )
63
64 def _dict_to_policy(data: Mapping[str, JsonValue]) -> Policy | None:
65 """Deserialise a JSON dict to :class:`Policy`, returning ``None`` on error."""
66 try:
67 when_data = data.get("when") or {}
68 condition = PolicyCondition(
69 conflict_type=when_data.get("conflict_type") or None,
70 domain=when_data.get("domain") or None,
71 path_pattern=when_data.get("path_pattern") or None,
72 min_confidence=when_data.get("min_confidence"),
73 )
74 return Policy(
75 policy_id=str(data["policy_id"]),
76 description=str(data.get("description", "")),
77 when=condition,
78 action=str(data.get("action", PolicyAction.ESCALATE)),
79 confidence=float(data.get("confidence", 1.0)),
80 escalate_to=data.get("escalate_to") or None,
81 delegate_to=data.get("delegate_to") or None,
82 scope=str(data.get("scope", PolicyScope.REPO)),
83 created_at=_parse_dt(data.get("created_at")),
84 created_by=str(data.get("created_by", "unknown")),
85 )
86 except (KeyError, TypeError, ValueError) as exc:
87 logger.warning("⚠️ harmony: failed to deserialise policy: %s", exc)
88 return None
89
90 # ---------------------------------------------------------------------------
91 # Policy CRUD
92 # ---------------------------------------------------------------------------
93
94 def save_policy(root: pathlib.Path, policy: Policy) -> None:
95 """Persist a :class:`Policy` to the harmony policy store.
96
97 Stored at ``.muse/harmony/policies/<policy_id>.json``. **Overwrites**
98 any existing policy with the same ID — policies are versioned by
99 replacement, not by append.
100
101 Args:
102 root: Repository root.
103 policy: Policy to save.
104
105 Raises:
106 ValueError: If ``policy.policy_id`` is not URL-safe.
107 """
108 _validate_policy_id(policy.policy_id)
109 dest = policies_dir(root) / f"{policy.policy_id}.json"
110 _write_atomic(dest, json.dumps(_policy_to_dict(policy), indent=2))
111 logger.debug(
112 "harmony: saved policy %r (action=%s, scope=%s)",
113 policy.policy_id,
114 policy.action,
115 policy.scope,
116 )
117
118 def load_policy(root: pathlib.Path, policy_id: str) -> Policy | None:
119 """Load a single :class:`Policy` by ID.
120
121 Returns ``None`` when *policy_id* is invalid, the file does not exist,
122 it exceeds :data:`_MAX_POLICY_BYTES`, or JSON parsing fails.
123
124 Args:
125 root: Repository root.
126 policy_id: URL-safe policy identifier.
127 """
128 try:
129 _validate_policy_id(policy_id)
130 except ValueError:
131 return None
132
133 dest = policies_dir(root) / f"{policy_id}.json"
134 if not dest.exists():
135 return None
136
137 try:
138 size = dest.stat().st_size
139 except OSError as exc:
140 logger.warning("⚠️ harmony: failed to read policy %r: %s", policy_id, exc)
141 return None
142 if size > _MAX_POLICY_BYTES:
143 logger.warning(
144 "⚠️ harmony: policy %r is %d bytes — too large; skipping",
145 policy_id,
146 size,
147 )
148 return None
149 data = load_json_file(dest)
150 if data is None:
151 logger.warning(
152 "⚠️ harmony: failed to read policy %r: unreadable or invalid JSON", policy_id
153 )
154 return None
155
156 return _dict_to_policy(data)
157
158 def list_policies(root: pathlib.Path) -> list[Policy]:
159 """Return all :class:`Policy` entries from the harmony policy store.
160
161 Sorted by scope order (workspace → repo → domain → file) then by
162 ``created_at`` ascending within each scope, so earlier policies take
163 precedence over later ones at the same scope level.
164
165 Args:
166 root: Repository root.
167 """
168 _SCOPE_ORDER = {
169 PolicyScope.WORKSPACE: 0,
170 PolicyScope.REPO: 1,
171 PolicyScope.DOMAIN: 2,
172 PolicyScope.FILE: 3,
173 }
174
175 pdir = policies_dir(root)
176 if not pdir.exists():
177 return []
178
179 results: list[Policy] = []
180 count = 0
181
182 for f in pdir.iterdir():
183 if count >= _MAX_POLICIES:
184 logger.warning(
185 "⚠️ harmony: >%d policies — scan truncated", _MAX_POLICIES
186 )
187 break
188 count += 1
189 if f.is_symlink() or not f.is_file():
190 continue
191 if not f.name.endswith(".json"):
192 continue
193 pid = f.name[:-5]
194 p = load_policy(root, pid)
195 if p is not None:
196 results.append(p)
197
198 results.sort(key=lambda p: (_SCOPE_ORDER.get(p.scope, 99), p.created_at))
199 return results
200
201 def remove_policy(root: pathlib.Path, policy_id: str) -> bool:
202 """Remove a policy from the harmony store.
203
204 Args:
205 root: Repository root.
206 policy_id: URL-safe policy identifier.
207
208 Returns:
209 ``True`` if the policy existed and was removed, ``False`` otherwise.
210 """
211 try:
212 _validate_policy_id(policy_id)
213 except ValueError:
214 logger.warning(
215 "⚠️ harmony: invalid policy_id in remove_policy: %r", policy_id
216 )
217 return False
218
219 dest = policies_dir(root) / f"{policy_id}.json"
220 if not dest.exists():
221 return False
222
223 dest.unlink(missing_ok=True)
224 logger.debug("harmony: removed policy %r", policy_id)
225 return True
226
227 # ---------------------------------------------------------------------------
228 # Policy matching
229 # ---------------------------------------------------------------------------
230
231 def _condition_matches(condition: PolicyCondition, pattern: ConflictPattern) -> bool:
232 """Return ``True`` if *pattern* satisfies every non-``None`` field of *condition*.
233
234 ``None`` fields are wildcards and match any value. ``path_pattern`` uses
235 :func:`fnmatch.fnmatch` glob semantics (e.g. ``"*.mid"``, ``"src/**"``).
236
237 ``min_confidence`` is a proposal-time filter evaluated by the harmony
238 engine against the resolution confidence — not against any static field
239 of the pattern — so it is intentionally not checked here.
240 """
241 if condition.conflict_type is not None:
242 if pattern.conflict_type != condition.conflict_type:
243 return False
244
245 if condition.domain is not None:
246 if pattern.domain != condition.domain:
247 return False
248
249 if condition.path_pattern is not None:
250 if not fnmatch.fnmatch(pattern.path, condition.path_pattern):
251 return False
252
253 return True
254
255 def match_policy(
256 policies: list[Policy],
257 pattern: ConflictPattern,
258 ) -> Policy | None:
259 """Return the first :class:`Policy` whose condition matches *pattern*.
260
261 Evaluates policies in the order given. Callers should pass the list
262 returned by :func:`list_policies`, which is already sorted by scope
263 (workspace → repo → domain → file).
264
265 Args:
266 policies: Ordered list of active policies to evaluate.
267 pattern: Incoming conflict pattern to match against.
268
269 Returns:
270 The first matching :class:`Policy`, or ``None`` if no policy fires.
271 """
272 for policy in policies:
273 if _condition_matches(policy.when, pattern):
274 return policy
275 return None
File History 1 commit
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40 docs: add | jq convention to --json section of agent-guide Sonnet 4.6 1 day ago