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.
- plug_play_memory/__init__.py +23 -0
- plug_play_memory/audit.py +209 -0
- plug_play_memory/auth.py +155 -0
- plug_play_memory/cli.py +413 -0
- plug_play_memory/consolidation.py +172 -0
- plug_play_memory/emitter.py +337 -0
- plug_play_memory/events.py +36 -0
- plug_play_memory/judge.py +461 -0
- plug_play_memory/policies/builtin/_base.yaml +18 -0
- plug_play_memory/policies/builtin/coding.yaml +53 -0
- plug_play_memory/policies/builtin/financial.yaml +50 -0
- plug_play_memory/policies/builtin/study.yaml +50 -0
- plug_play_memory/policy.py +353 -0
- plug_play_memory/server.py +374 -0
- plug_play_memory/storage.py +495 -0
- plug_play_memory-0.2.0.dist-info/METADATA +487 -0
- plug_play_memory-0.2.0.dist-info/RECORD +21 -0
- plug_play_memory-0.2.0.dist-info/WHEEL +5 -0
- plug_play_memory-0.2.0.dist-info/entry_points.txt +2 -0
- plug_play_memory-0.2.0.dist-info/licenses/LICENSE +201 -0
- plug_play_memory-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|