plug-play-memory 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,353 @@
1
+ """
2
+ Layered policy engine for plug-play-memory — security-hardened version.
3
+
4
+ Changes from v0.1:
5
+ - Action whitelist per agent_type (C3 fix) — unknown actions default-deny
6
+ - Nested `when` syntax: {amount: {le: 1000}} (M2 fix) — no more suffix collision
7
+ - agent_type passed as parameter to check(), no shared-state mutation (M5 fix)
8
+ - Reserved agent_id validation happens in storage layer (M4 fix)
9
+ - Backwards-compatible with old suffix form (deprecation warning)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import warnings
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import yaml
20
+
21
+
22
+ @dataclass
23
+ class PolicyDecision:
24
+ allowed: bool
25
+ reason: str
26
+ layers_evaluated: list[str] = field(default_factory=list)
27
+
28
+
29
+ @dataclass
30
+ class Rule:
31
+ action: str
32
+ allow: bool
33
+ requires: list[str] = field(default_factory=list)
34
+ when: dict[str, Any] = field(default_factory=dict)
35
+
36
+
37
+ @dataclass
38
+ class PolicyLayer:
39
+ name: str
40
+ source: str
41
+ default_allow: bool
42
+ deny_actions: set[str]
43
+ rules: dict[str, list[Rule]]
44
+
45
+
46
+ # Whitelist of actions allowed per agent_type (C3 fix).
47
+ # Any action NOT in this list for the given agent_type is denied at the gate.
48
+ ACTION_WHITELIST: dict[str, set[str]] = {
49
+ "coding": {
50
+ "read_memory", "write_memory", "delete_memory", "recall_memory",
51
+ "file_edited", "file_read", "git_pushed", "git_committed",
52
+ "test_run", "build_run", "lint_run", "dependency_installed",
53
+ "deployed", "migration_run",
54
+ # Explicitly denied (in denylist) but listed so policy can evaluate
55
+ "modify_ci_config", "access_production_database",
56
+ "send_email", "make_purchase",
57
+ },
58
+ "study": {
59
+ "read_memory", "write_memory", "delete_memory", "recall_memory",
60
+ "question_asked", "answer_attempted", "concept_mastered",
61
+ "knowledge_gap_found", "goal_set", "review_scheduled",
62
+ "fact_observed",
63
+ "sync_to_flashcard_app", "sync_to_notes_app",
64
+ # Explicitly denied but listed
65
+ "send_email", "make_purchase", "access_internet", "execute_code",
66
+ },
67
+ "financial": {
68
+ "read_memory", "write_memory", "delete_memory", "recall_memory",
69
+ "read_account_balance", "read_transaction_history",
70
+ "categorize_transaction", "generate_budget_report",
71
+ "set_budget_limit", "make_payment", "fact_observed",
72
+ # Explicitly denied but listed
73
+ "share_data_with_third_party", "execute_trade",
74
+ },
75
+ "default": {
76
+ # Minimal set — only memory ops, no side-effecting actions
77
+ "read_memory", "write_memory", "recall_memory",
78
+ },
79
+ }
80
+
81
+ # Absolute denylist from _base — always denied regardless of agent_type
82
+ BASE_DENYLIST = {
83
+ "delete_database", "drop_table", "format_disk", "rce",
84
+ "exfiltrate_data", "modify_audit_log", "access_credentials", "modify_secrets",
85
+ }
86
+
87
+
88
+ class PolicyEngine:
89
+ BUILTIN_DIR = Path(__file__).parent / "policies" / "builtin"
90
+
91
+ def __init__(
92
+ self,
93
+ custom_dir: str | Path | None = None,
94
+ agent_types: dict[str, str] | None = None,
95
+ ):
96
+ self.custom_dir = Path(custom_dir) if custom_dir else None
97
+ self._agent_types = dict(agent_types or {})
98
+ self._layers_cache: dict[str, PolicyLayer] = {}
99
+ self._load_layers()
100
+
101
+ def set_agent_type(self, agent_id: str, agent_type: str) -> None:
102
+ """Bind an agent to a built-in policy pack."""
103
+ from .storage import validate_agent_id
104
+ validate_agent_id(agent_id)
105
+ if agent_type not in ACTION_WHITELIST and agent_type != "default":
106
+ warnings.warn(
107
+ f"Unknown agent_type '{agent_type}'. Falling back to 'default' "
108
+ f"whitelist (memory ops only)."
109
+ )
110
+ self._agent_types[agent_id] = agent_type
111
+
112
+ def get_agent_type(self, agent_id: str) -> str:
113
+ return self._agent_types.get(agent_id, "default")
114
+
115
+ def check(
116
+ self,
117
+ agent_id: str,
118
+ action: str,
119
+ context: dict[str, Any] | None = None,
120
+ agent_type: str | None = None,
121
+ ) -> PolicyDecision:
122
+ """
123
+ Check whether an action is allowed for an agent.
124
+
125
+ Args:
126
+ agent_id: The agent's identity
127
+ action: The action to check
128
+ context: Optional context for `when` conditions and `requires`
129
+ agent_type: Override agent_type for this check (M5 fix).
130
+ If None, uses the bound agent_type.
131
+ """
132
+ from .storage import validate_agent_id
133
+ try:
134
+ validate_agent_id(agent_id)
135
+ except ValueError as e:
136
+ return PolicyDecision(
137
+ allowed=False, reason=f"invalid agent_id: {e}",
138
+ layers_evaluated=[],
139
+ )
140
+
141
+ context = context or {}
142
+ if agent_type is None:
143
+ agent_type = self.get_agent_type(agent_id)
144
+
145
+ # ─── GATE 0: Action whitelist (C3 fix) ───
146
+ # Any action not in the agent_type's whitelist is denied.
147
+ # This blocks the "lie about category" attack — unknown actions
148
+ # can't reach the policy layer at all.
149
+ whitelist = ACTION_WHITELIST.get(agent_type, ACTION_WHITELIST["default"])
150
+ if action not in whitelist:
151
+ return PolicyDecision(
152
+ allowed=False,
153
+ reason=f"action '{action}' not in whitelist for agent_type '{agent_type}'",
154
+ layers_evaluated=[],
155
+ )
156
+
157
+ # ─── GATE 1: Base denylist (always denied) ───
158
+ if action in BASE_DENYLIST:
159
+ return PolicyDecision(
160
+ allowed=False,
161
+ reason=f"absolute deny in builtin:_base",
162
+ layers_evaluated=["builtin:_base"],
163
+ )
164
+
165
+ # ─── GATE 2: Layered policy evaluation ───
166
+ layers = self._resolve_layers(agent_id, agent_type)
167
+ evaluated = [l.source for l in layers]
168
+
169
+ for layer in layers:
170
+ if action in layer.deny_actions:
171
+ return PolicyDecision(
172
+ allowed=False,
173
+ reason=f"absolute deny in {layer.source}",
174
+ layers_evaluated=evaluated,
175
+ )
176
+
177
+ matched_rule: Rule | None = None
178
+ matched_layer: PolicyLayer | None = None
179
+ for layer in layers:
180
+ layer_match: Rule | None = None
181
+ for rule in layer.rules.get(action, []):
182
+ if not self._rule_matches(rule, context):
183
+ continue
184
+ if rule.allow:
185
+ missing = [r for r in rule.requires if not context.get(r)]
186
+ if missing:
187
+ layer_match = rule # tentatively
188
+ continue
189
+ layer_match = rule
190
+ break
191
+ else:
192
+ layer_match = rule
193
+ break
194
+ if layer_match is not None:
195
+ matched_rule = layer_match
196
+ matched_layer = layer
197
+
198
+ if matched_rule is None:
199
+ default_allow = layers[-1].default_allow if layers else False
200
+ return PolicyDecision(
201
+ allowed=default_allow,
202
+ reason=f"no matching rule; default={'allow' if default_allow else 'deny'} ({layers[-1].source if layers else 'none'})",
203
+ layers_evaluated=evaluated,
204
+ )
205
+
206
+ if matched_rule.allow:
207
+ missing = [r for r in matched_rule.requires if not context.get(r)]
208
+ if missing:
209
+ return PolicyDecision(
210
+ allowed=False,
211
+ reason=f"missing required context: {missing} (from {matched_layer.source})",
212
+ layers_evaluated=evaluated,
213
+ )
214
+
215
+ return PolicyDecision(
216
+ allowed=matched_rule.allow,
217
+ reason=f"matched rule in {matched_layer.source}",
218
+ layers_evaluated=evaluated,
219
+ )
220
+
221
+ def list_layers(self) -> list[str]:
222
+ return list(self._layers_cache.keys())
223
+
224
+ def list_allowed_actions(self, agent_type: str) -> set[str]:
225
+ """Return the action whitelist for an agent_type (C3 fix)."""
226
+ return ACTION_WHITELIST.get(agent_type, ACTION_WHITELIST["default"]).copy()
227
+
228
+ # -- layer resolution ----------------------------------------------------
229
+
230
+ def _resolve_layers(self, agent_id: str, agent_type: str) -> list[PolicyLayer]:
231
+ candidates = [
232
+ "builtin:_base",
233
+ f"builtin:{agent_type}",
234
+ f"custom:{agent_type}",
235
+ f"custom:{agent_id}",
236
+ ]
237
+ layers = []
238
+ for source in candidates:
239
+ layer = self._layers_cache.get(source)
240
+ if layer is None:
241
+ continue
242
+ if source.startswith("custom:") and not layer.rules and not layer.deny_actions:
243
+ continue
244
+ layers.append(layer)
245
+ return layers
246
+
247
+ def _load_layers(self) -> None:
248
+ self._load_layer("builtin", "_base")
249
+ if self.BUILTIN_DIR.exists():
250
+ for path in sorted(self.BUILTIN_DIR.glob("*.yaml")):
251
+ if path.stem != "_base":
252
+ self._load_layer("builtin", path.stem)
253
+ if self.custom_dir and self.custom_dir.exists():
254
+ for path in sorted(self.custom_dir.glob("*.yaml")):
255
+ self._load_layer("custom", path.stem)
256
+
257
+ def _load_layer(self, kind: str, name: str) -> None:
258
+ source = f"{kind}:{name}"
259
+ if kind == "builtin":
260
+ yaml_path = self.BUILTIN_DIR / f"{name}.yaml"
261
+ else:
262
+ yaml_path = self.custom_dir / f"{name}.yaml"
263
+ if not yaml_path.exists():
264
+ return
265
+
266
+ spec = yaml.safe_load(yaml_path.read_text()) or {}
267
+ deny_actions: set[str] = set()
268
+ rules: dict[str, list[Rule]] = {}
269
+
270
+ for r in spec.get("deny_rules", []) or []:
271
+ deny_actions.add(r["action"])
272
+
273
+ for r in spec.get("rules", []) or []:
274
+ action = r.get("action")
275
+ if not action:
276
+ continue
277
+ rule = Rule(
278
+ action=action,
279
+ allow=bool(r.get("allow", False)),
280
+ requires=r.get("requires", []) or [],
281
+ when=r.get("when", {}) or {},
282
+ )
283
+ rules.setdefault(action, []).append(rule)
284
+
285
+ self._layers_cache[source] = PolicyLayer(
286
+ name=name, source=source,
287
+ default_allow=(spec.get("default", "deny") == "allow"),
288
+ deny_actions=deny_actions, rules=rules,
289
+ )
290
+
291
+ def _rule_matches(self, rule: Rule, context: dict[str, Any]) -> bool:
292
+ """
293
+ `when` conditions support (M2 fix):
294
+ - {key: value} — equality
295
+ - {key: [v1, v2]} — value in list
296
+ - {key: {le: N}} — context[key] <= N (nested form, PREFERRED)
297
+ - {key_le: N} — DEPRECATED suffix form, still works but warns
298
+ """
299
+ if not rule.when:
300
+ return True
301
+ for cond_key, expected in rule.when.items():
302
+ # Detect operator suffix (deprecated form — M2 fix)
303
+ for op_suffix in ("_le", "_ge", "_lt", "_gt"):
304
+ if cond_key.endswith(op_suffix):
305
+ warnings.warn(
306
+ f"Policy uses deprecated suffix form '{cond_key}'. "
307
+ f"Use nested form: {{{cond_key[:-3]}: {{{op_suffix[1:]}: {expected}}}}}",
308
+ DeprecationWarning, stacklevel=3,
309
+ )
310
+ ctx_key = cond_key[: -len(op_suffix)]
311
+ actual = context.get(ctx_key)
312
+ if isinstance(actual, str) and actual.replace(".", "").replace("-", "").isdigit():
313
+ try:
314
+ actual = float(actual)
315
+ except ValueError:
316
+ pass
317
+ op_fn = {
318
+ "_le": lambda a, b: a is not None and a <= b,
319
+ "_ge": lambda a, b: a is not None and a >= b,
320
+ "_lt": lambda a, b: a is not None and a < b,
321
+ "_gt": lambda a, b: a is not None and a > b,
322
+ }[op_suffix]
323
+ if not op_fn(actual, expected):
324
+ return False
325
+ break
326
+ else:
327
+ # No operator suffix — check nested dict form first (M2 fix)
328
+ if isinstance(expected, dict):
329
+ ctx_key = cond_key
330
+ actual = context.get(ctx_key)
331
+ if isinstance(actual, str) and actual.replace(".", "").replace("-", "").isdigit():
332
+ try:
333
+ actual = float(actual)
334
+ except ValueError:
335
+ pass
336
+ for op, threshold in expected.items():
337
+ op_fns = {
338
+ "le": lambda a, b: a is not None and a <= b,
339
+ "ge": lambda a, b: a is not None and a >= b,
340
+ "lt": lambda a, b: a is not None and a < b,
341
+ "gt": lambda a, b: a is not None and a > b,
342
+ }
343
+ if op not in op_fns:
344
+ continue
345
+ if not op_fns[op](actual, threshold):
346
+ return False
347
+ elif isinstance(expected, list):
348
+ if context.get(cond_key) not in expected:
349
+ return False
350
+ else:
351
+ if context.get(cond_key) != expected:
352
+ return False
353
+ return True