tweek 0.1.0__py3-none-any.whl → 0.2.1__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.
Files changed (87) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/_keygen.py +53 -0
  3. tweek/audit.py +288 -0
  4. tweek/cli.py +5398 -2392
  5. tweek/cli_model.py +380 -0
  6. tweek/config/families.yaml +609 -0
  7. tweek/config/manager.py +42 -5
  8. tweek/config/patterns.yaml +1510 -8
  9. tweek/config/tiers.yaml +161 -11
  10. tweek/diagnostics.py +71 -2
  11. tweek/hooks/break_glass.py +163 -0
  12. tweek/hooks/feedback.py +223 -0
  13. tweek/hooks/overrides.py +531 -0
  14. tweek/hooks/post_tool_use.py +472 -0
  15. tweek/hooks/pre_tool_use.py +1024 -62
  16. tweek/integrations/openclaw.py +443 -0
  17. tweek/integrations/openclaw_server.py +385 -0
  18. tweek/licensing.py +14 -54
  19. tweek/logging/bundle.py +2 -2
  20. tweek/logging/security_log.py +56 -13
  21. tweek/mcp/approval.py +57 -16
  22. tweek/mcp/proxy.py +18 -0
  23. tweek/mcp/screening.py +5 -5
  24. tweek/mcp/server.py +4 -1
  25. tweek/memory/__init__.py +24 -0
  26. tweek/memory/queries.py +223 -0
  27. tweek/memory/safety.py +140 -0
  28. tweek/memory/schemas.py +80 -0
  29. tweek/memory/store.py +989 -0
  30. tweek/platform/__init__.py +4 -4
  31. tweek/plugins/__init__.py +40 -24
  32. tweek/plugins/base.py +1 -1
  33. tweek/plugins/detectors/__init__.py +3 -3
  34. tweek/plugins/detectors/{moltbot.py → openclaw.py} +30 -27
  35. tweek/plugins/git_discovery.py +16 -4
  36. tweek/plugins/git_registry.py +8 -2
  37. tweek/plugins/git_security.py +21 -9
  38. tweek/plugins/screening/__init__.py +10 -1
  39. tweek/plugins/screening/heuristic_scorer.py +477 -0
  40. tweek/plugins/screening/llm_reviewer.py +14 -6
  41. tweek/plugins/screening/local_model_reviewer.py +161 -0
  42. tweek/proxy/__init__.py +38 -37
  43. tweek/proxy/addon.py +22 -3
  44. tweek/proxy/interceptor.py +1 -0
  45. tweek/proxy/server.py +4 -2
  46. tweek/sandbox/__init__.py +11 -0
  47. tweek/sandbox/docker_bridge.py +143 -0
  48. tweek/sandbox/executor.py +9 -6
  49. tweek/sandbox/layers.py +97 -0
  50. tweek/sandbox/linux.py +1 -0
  51. tweek/sandbox/project.py +548 -0
  52. tweek/sandbox/registry.py +149 -0
  53. tweek/security/__init__.py +9 -0
  54. tweek/security/language.py +250 -0
  55. tweek/security/llm_reviewer.py +1146 -60
  56. tweek/security/local_model.py +331 -0
  57. tweek/security/local_reviewer.py +146 -0
  58. tweek/security/model_registry.py +371 -0
  59. tweek/security/rate_limiter.py +11 -6
  60. tweek/security/secret_scanner.py +70 -4
  61. tweek/security/session_analyzer.py +26 -2
  62. tweek/skill_template/SKILL.md +200 -0
  63. tweek/skill_template/__init__.py +0 -0
  64. tweek/skill_template/cli-reference.md +331 -0
  65. tweek/skill_template/overrides-reference.md +184 -0
  66. tweek/skill_template/scripts/__init__.py +0 -0
  67. tweek/skill_template/scripts/check_installed.py +170 -0
  68. tweek/skills/__init__.py +38 -0
  69. tweek/skills/config.py +150 -0
  70. tweek/skills/fingerprints.py +198 -0
  71. tweek/skills/guard.py +293 -0
  72. tweek/skills/isolation.py +469 -0
  73. tweek/skills/scanner.py +715 -0
  74. tweek/vault/__init__.py +0 -1
  75. tweek/vault/cross_platform.py +12 -1
  76. tweek/vault/keychain.py +87 -29
  77. tweek-0.2.1.dist-info/METADATA +281 -0
  78. tweek-0.2.1.dist-info/RECORD +122 -0
  79. {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/entry_points.txt +8 -1
  80. {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/licenses/LICENSE +80 -0
  81. tweek-0.2.1.dist-info/top_level.txt +2 -0
  82. tweek-openclaw-plugin/node_modules/flatted/python/flatted.py +149 -0
  83. tweek/integrations/moltbot.py +0 -243
  84. tweek-0.1.0.dist-info/METADATA +0 -335
  85. tweek-0.1.0.dist-info/RECORD +0 -85
  86. tweek-0.1.0.dist-info/top_level.txt +0 -1
  87. {tweek-0.1.0.dist-info → tweek-0.2.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,531 @@
1
+ """
2
+ Tweek Security Overrides
3
+
4
+ Loads and applies human-only security overrides from ~/.tweek/overrides.yaml.
5
+ Used by both PreToolUse and PostToolUse hooks.
6
+
7
+ Features:
8
+ - Whitelist: Exempt specific paths/tools/URLs from screening
9
+ - Pattern Toggles: Enable/disable individual detection patterns
10
+ - Trust Levels: Different severity thresholds for interactive vs automated sessions
11
+
12
+ IMPORTANT: The overrides.yaml file is protected from AI modification.
13
+ The PreToolUse hook blocks Write/Edit/Bash commands that target this file.
14
+ Only a human editing the file directly can change security overrides.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ import yaml
26
+
27
+
28
+ # The canonical location of the overrides config file
29
+ OVERRIDES_PATH = Path.home() / ".tweek" / "overrides.yaml"
30
+
31
+ # Files protected from AI modification
32
+ PROTECTED_CONFIG_FILES = [
33
+ OVERRIDES_PATH,
34
+ Path.home() / ".tweek" / "skills", # Entire skills management directory
35
+ Path.home() / ".tweek" / "projects", # Project registry
36
+ Path.home() / ".tweek" / "memory.db", # Agentic memory database
37
+ ]
38
+
39
+
40
+ class SecurityOverrides:
41
+ """Loads and queries the ~/.tweek/overrides.yaml configuration."""
42
+
43
+ def __init__(self, config_path: Optional[Path] = None):
44
+ self.config_path = config_path or OVERRIDES_PATH
45
+ self.config = self._load()
46
+ self._whitelist_rules: List[Dict] = self.config.get("whitelist", [])
47
+ self._pattern_config: Dict = self.config.get("patterns", {})
48
+ self._trust_config: Dict = self.config.get("trust", {})
49
+
50
+ def _load(self) -> dict:
51
+ """Load overrides YAML. Returns empty dict if file missing (backward compatible)."""
52
+ if not self.config_path.exists():
53
+ return {}
54
+ self._check_permissions()
55
+ try:
56
+ with open(self.config_path) as f:
57
+ return yaml.safe_load(f) or {}
58
+ except Exception:
59
+ return {}
60
+
61
+ def _check_permissions(self):
62
+ """Warn via stderr if overrides file has insecure permissions."""
63
+ try:
64
+ import stat
65
+ mode = self.config_path.stat().st_mode
66
+ if mode & (stat.S_IWGRP | stat.S_IWOTH):
67
+ print(
68
+ f"WARNING: {self.config_path} has group/other write permissions. "
69
+ "Run: chmod 600 ~/.tweek/overrides.yaml",
70
+ file=sys.stderr,
71
+ )
72
+ except OSError:
73
+ pass
74
+
75
+ def check_whitelist(
76
+ self, tool_name: str, tool_input: Dict[str, Any], content: str
77
+ ) -> Optional[Dict]:
78
+ """
79
+ Check if this invocation matches a whitelist rule.
80
+
81
+ Returns the matching rule dict if whitelisted, None otherwise.
82
+ """
83
+ for rule in self._whitelist_rules:
84
+ if self._matches_rule(rule, tool_name, tool_input, content):
85
+ return rule
86
+ return None
87
+
88
+ def _matches_rule(
89
+ self, rule: Dict, tool_name: str, tool_input: Dict[str, Any], content: str
90
+ ) -> bool:
91
+ """Check if a single whitelist rule matches the current invocation."""
92
+ # If rule specifies tools, tool_name must be in that list
93
+ rule_tools = rule.get("tools")
94
+ if rule_tools and tool_name not in rule_tools:
95
+ return False
96
+
97
+ # If rule specifies a specific tool (singular), check that
98
+ rule_tool = rule.get("tool")
99
+ if rule_tool and tool_name != rule_tool:
100
+ return False
101
+
102
+ # Path matching
103
+ rule_path = rule.get("path")
104
+ if rule_path:
105
+ target_path = tool_input.get("file_path", "")
106
+ if not target_path:
107
+ return False
108
+ if not self._path_matches(rule_path, target_path):
109
+ return False
110
+
111
+ # URL prefix matching
112
+ url_prefix = rule.get("url_prefix")
113
+ if url_prefix:
114
+ target_url = tool_input.get("url", "")
115
+ if not target_url or not target_url.startswith(url_prefix):
116
+ return False
117
+
118
+ # Command prefix matching (for Bash)
119
+ cmd_prefix = rule.get("command_prefix")
120
+ if cmd_prefix:
121
+ command = tool_input.get("command", "")
122
+ if not command or not command.strip().startswith(cmd_prefix):
123
+ return False
124
+
125
+ # If no path/url/command_prefix specified, the rule matches on tool alone
126
+ # (or matches everything if no tool filter either)
127
+ has_filter = rule_path or url_prefix or cmd_prefix
128
+ has_tool_filter = rule_tools or rule_tool
129
+ if not has_filter and not has_tool_filter:
130
+ # Rule with no filters matches nothing (safety)
131
+ return False
132
+
133
+ return True
134
+
135
+ def _path_matches(self, rule_path: str, target_path: str) -> bool:
136
+ """Check if target_path matches a rule path (prefix match with resolution)."""
137
+ try:
138
+ rule_resolved = Path(rule_path).expanduser().resolve()
139
+ target_resolved = Path(target_path).expanduser().resolve()
140
+
141
+ # Exact match
142
+ if target_resolved == rule_resolved:
143
+ return True
144
+
145
+ # Prefix match (target is inside the rule directory)
146
+ try:
147
+ target_resolved.relative_to(rule_resolved)
148
+ return True
149
+ except ValueError:
150
+ pass
151
+ except (OSError, ValueError):
152
+ pass
153
+ return False
154
+
155
+ def filter_patterns(
156
+ self, matches: List[Dict], working_path: str
157
+ ) -> List[Dict]:
158
+ """
159
+ Remove disabled and scoped-disabled patterns from a match list.
160
+
161
+ Args:
162
+ matches: List of pattern match dicts (each has 'name' key)
163
+ working_path: The file path or working directory for scoped checks
164
+
165
+ Returns:
166
+ Filtered list with disabled patterns removed
167
+ """
168
+ disabled_names = {
169
+ p["name"] for p in self._pattern_config.get("disabled", [])
170
+ if "name" in p
171
+ }
172
+ scoped_disables = self._pattern_config.get("scoped_disables", [])
173
+ force_enabled = set(self._pattern_config.get("force_enabled", []))
174
+
175
+ filtered = []
176
+ for match in matches:
177
+ name = match.get("name", "")
178
+
179
+ # Force-enabled patterns are never filtered
180
+ if name in force_enabled:
181
+ filtered.append(match)
182
+ continue
183
+
184
+ # Globally disabled
185
+ if name in disabled_names:
186
+ continue
187
+
188
+ # Scoped disables
189
+ scoped_disabled = False
190
+ for scope in scoped_disables:
191
+ if scope.get("name") != name:
192
+ continue
193
+ for scope_path in scope.get("paths", []):
194
+ if self._path_matches(scope_path, working_path):
195
+ scoped_disabled = True
196
+ break
197
+ if scoped_disabled:
198
+ break
199
+
200
+ if not scoped_disabled:
201
+ filtered.append(match)
202
+
203
+ return filtered
204
+
205
+ def get_min_severity(self, trust_mode: str) -> str:
206
+ """Get minimum severity threshold for the given trust mode."""
207
+ mode_config = self._trust_config.get(trust_mode, {})
208
+ return mode_config.get("min_severity", "low")
209
+
210
+ def get_trust_default(self) -> str:
211
+ """Get default trust mode from config."""
212
+ return self._trust_config.get("default_mode", "interactive")
213
+
214
+ def should_skip_llm_for_default_tier(self, trust_mode: str) -> bool:
215
+ """Check if LLM review should be skipped for default-tier tools."""
216
+ mode_config = self._trust_config.get(trust_mode, {})
217
+ return mode_config.get("skip_llm_for_default_tier", False)
218
+
219
+ def get_enforcement_policy(self) -> "EnforcementPolicy":
220
+ """Get the enforcement policy from the config's 'enforcement' section.
221
+
222
+ Returns an EnforcementPolicy initialized from the config, or the
223
+ default policy if no enforcement section exists.
224
+ """
225
+ enforcement_config = self.config.get("enforcement", {})
226
+ return EnforcementPolicy(enforcement_config)
227
+
228
+
229
+ # =========================================================================
230
+ # Module-level singleton
231
+ # =========================================================================
232
+
233
+ _overrides: Optional[SecurityOverrides] = None
234
+
235
+
236
+ def get_overrides(config_path: Optional[Path] = None) -> Optional[SecurityOverrides]:
237
+ """
238
+ Get the singleton SecurityOverrides instance.
239
+
240
+ Returns None if no config file exists (backward compatible -- all
241
+ screening runs as before).
242
+ """
243
+ global _overrides
244
+ if _overrides is None:
245
+ _overrides = SecurityOverrides(config_path)
246
+ if not _overrides.config:
247
+ return None
248
+ return _overrides
249
+
250
+
251
+ def reset_overrides():
252
+ """Reset the singleton (for testing)."""
253
+ global _overrides
254
+ _overrides = None
255
+
256
+
257
+ # =========================================================================
258
+ # Trust Level Detection
259
+ # =========================================================================
260
+
261
+
262
+ def get_trust_mode(overrides: Optional[SecurityOverrides] = None) -> str:
263
+ """
264
+ Determine whether the current session is interactive or automated.
265
+
266
+ Detection hierarchy (first match wins):
267
+ 1. Explicit env var: TWEEK_TRUST_LEVEL=interactive|automated
268
+ 2. Parent process check: launchd/cron/systemd → automated
269
+ 3. CI environment variables → automated
270
+ 4. Default from overrides.yaml config
271
+ 5. Fallback: interactive
272
+
273
+ Returns: "interactive" or "automated"
274
+ """
275
+ # 1. Explicit environment variable (highest priority)
276
+ env_trust = os.environ.get("TWEEK_TRUST_LEVEL", "").lower().strip()
277
+ if env_trust in ("interactive", "automated"):
278
+ return env_trust
279
+
280
+ # 2. Parent process heuristic (macOS/Linux)
281
+ try:
282
+ ppid = os.getppid()
283
+ result = subprocess.run(
284
+ ["ps", "-p", str(ppid), "-o", "comm="],
285
+ capture_output=True, text=True, timeout=1,
286
+ )
287
+ parent_name = result.stdout.strip().lower()
288
+ automated_parents = {
289
+ "launchd", "cron", "crond", "systemd", "atd",
290
+ "supervisord", "init",
291
+ }
292
+ if parent_name in automated_parents:
293
+ return "automated"
294
+ except Exception:
295
+ pass
296
+
297
+ # 3. CI environment variables
298
+ ci_vars = [
299
+ "CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
300
+ "JENKINS_HOME", "GITLAB_CI", "CIRCLECI", "BUILDKITE",
301
+ ]
302
+ for var in ci_vars:
303
+ if os.environ.get(var):
304
+ return "automated"
305
+
306
+ # 4. Config default
307
+ if overrides:
308
+ return overrides.get_trust_default()
309
+
310
+ # 5. Fallback
311
+ return "interactive"
312
+
313
+
314
+ # =========================================================================
315
+ # Self-Protection: prevent AI from modifying the overrides file
316
+ # =========================================================================
317
+
318
+
319
+ def is_protected_config_file(file_path: str) -> bool:
320
+ """
321
+ Check if a file path points to a human-only config file.
322
+
323
+ Used by PreToolUse to block Write/Edit targeting overrides.yaml,
324
+ project sandbox config, and other protected paths.
325
+ """
326
+ if not file_path:
327
+ return False
328
+ try:
329
+ resolved = Path(file_path).expanduser().resolve()
330
+
331
+ # Check explicit protected paths
332
+ for protected in PROTECTED_CONFIG_FILES:
333
+ protected_resolved = protected.resolve()
334
+ if resolved == protected_resolved:
335
+ return True
336
+ # Check if target is inside a protected directory
337
+ try:
338
+ resolved.relative_to(protected_resolved)
339
+ return True
340
+ except ValueError:
341
+ pass
342
+
343
+ # Protect project-level .tweek/ directories (sandbox state)
344
+ # Any file inside a .tweek/ directory is protected
345
+ for part in resolved.parts:
346
+ if part == ".tweek":
347
+ return True
348
+
349
+ except (OSError, ValueError):
350
+ pass
351
+ return False
352
+
353
+
354
+ def bash_targets_protected_config(command: str) -> bool:
355
+ """
356
+ Check if a bash command would modify the protected config files.
357
+
358
+ Catches common shell patterns: redirects, cp, mv, sed -i, rm, tee, etc.
359
+ """
360
+ if not command:
361
+ return False
362
+
363
+ # Patterns that indicate writing to the overrides file
364
+ write_patterns = [
365
+ r'(>|>>)\s*.*overrides\.yaml',
366
+ r'(cp|mv|rsync)\s+.*overrides\.yaml',
367
+ r'tee\s+.*overrides\.yaml',
368
+ r'sed\s+-i.*overrides\.yaml',
369
+ r'perl\s+-[pi].*overrides\.yaml',
370
+ r'(echo|cat|printf)\s+.*>\s*.*overrides\.yaml',
371
+ r'rm\s+.*overrides\.yaml',
372
+ r'unlink\s+.*overrides\.yaml',
373
+ r'truncate\s+.*overrides\.yaml',
374
+ r'python[3]?\s+.*overrides\.yaml',
375
+ r'ruby\s+.*overrides\.yaml',
376
+ r'node\s+.*overrides\.yaml',
377
+ ]
378
+
379
+ for pattern in write_patterns:
380
+ if re.search(pattern, command, re.IGNORECASE):
381
+ return True
382
+
383
+ # Also check for the full path
384
+ overrides_str = str(OVERRIDES_PATH)
385
+ full_path_patterns = [
386
+ rf'(>|>>)\s*.*{re.escape(overrides_str)}',
387
+ rf'(cp|mv|rsync)\s+.*{re.escape(overrides_str)}',
388
+ rf'rm\s+.*{re.escape(overrides_str)}',
389
+ rf'tee\s+.*{re.escape(overrides_str)}',
390
+ ]
391
+ for pattern in full_path_patterns:
392
+ if re.search(pattern, command, re.IGNORECASE):
393
+ return True
394
+
395
+ # Protect project-level .tweek/ directories (sandbox state)
396
+ tweek_dir_patterns = [
397
+ r'(>|>>)\s*.*\.tweek/',
398
+ r'(cp|mv|rsync)\s+.*\.tweek/',
399
+ r'rm\s+(-rf?\s+)?.*\.tweek/',
400
+ r'tee\s+.*\.tweek/',
401
+ r'sed\s+-i.*\.tweek/',
402
+ r'(echo|cat|printf)\s+.*>\s*.*\.tweek/',
403
+ ]
404
+ for pattern in tweek_dir_patterns:
405
+ if re.search(pattern, command, re.IGNORECASE):
406
+ return True
407
+
408
+ return False
409
+
410
+
411
+ # =========================================================================
412
+ # Severity Filtering
413
+ # =========================================================================
414
+
415
+ SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
416
+
417
+ # Decision escalation order: log < ask < deny
418
+ DECISION_RANK = {"log": 0, "ask": 1, "deny": 2}
419
+
420
+
421
+ def filter_by_severity(
422
+ matches: List[Dict], min_severity: str
423
+ ) -> tuple[List[Dict], List[Dict]]:
424
+ """
425
+ Filter pattern matches by severity threshold.
426
+
427
+ Returns (kept, suppressed) -- two lists.
428
+ 'kept' contains matches at or above the threshold.
429
+ 'suppressed' contains matches below the threshold.
430
+ """
431
+ min_rank = SEVERITY_RANK.get(min_severity, 3)
432
+ kept = []
433
+ suppressed = []
434
+ for match in matches:
435
+ match_rank = SEVERITY_RANK.get(match.get("severity", "medium"), 2)
436
+ if match_rank <= min_rank:
437
+ kept.append(match)
438
+ else:
439
+ suppressed.append(match)
440
+ return kept, suppressed
441
+
442
+
443
+ # =========================================================================
444
+ # Enforcement Policy
445
+ # =========================================================================
446
+
447
+
448
+ class EnforcementPolicy:
449
+ """Configurable severity+confidence → decision matrix.
450
+
451
+ Determines the enforcement decision (deny/ask/log) for a given
452
+ pattern match based on its severity and confidence level.
453
+
454
+ Default matrix:
455
+ CRITICAL + deterministic → deny (hard block)
456
+ CRITICAL + heuristic/contextual → ask
457
+ HIGH → ask
458
+ MEDIUM → ask
459
+ LOW → log (silent allow with logging)
460
+ """
461
+
462
+ DEFAULT_MATRIX = {
463
+ "critical": {"deterministic": "deny", "heuristic": "ask", "contextual": "ask"},
464
+ "high": {"deterministic": "ask", "heuristic": "ask", "contextual": "ask"},
465
+ "medium": {"deterministic": "ask", "heuristic": "ask", "contextual": "ask"},
466
+ "low": {"deterministic": "log", "heuristic": "log", "contextual": "log"},
467
+ }
468
+
469
+ VALID_DECISIONS = {"deny", "ask", "log"}
470
+ VALID_SEVERITIES = {"critical", "high", "medium", "low"}
471
+ VALID_CONFIDENCES = {"deterministic", "heuristic", "contextual"}
472
+
473
+ def __init__(self, config: Optional[Dict] = None):
474
+ self.matrix = self._merge_with_defaults(config or {})
475
+
476
+ def resolve(self, severity: str, confidence: str) -> str:
477
+ """Determine the enforcement decision for a severity+confidence pair.
478
+
479
+ Args:
480
+ severity: Pattern severity (critical/high/medium/low)
481
+ confidence: Pattern confidence (deterministic/heuristic/contextual)
482
+
483
+ Returns:
484
+ Decision string: "deny", "ask", or "log"
485
+ """
486
+ row = self.matrix.get(severity, self.matrix.get("medium", {}))
487
+ return row.get(confidence, "ask")
488
+
489
+ def _merge_with_defaults(self, config: Dict) -> Dict:
490
+ """Merge user config with defaults, validating all values."""
491
+ merged = {}
492
+ for severity in self.VALID_SEVERITIES:
493
+ default_row = self.DEFAULT_MATRIX[severity]
494
+ user_row = config.get(severity, {})
495
+ merged_row = {}
496
+ for confidence in self.VALID_CONFIDENCES:
497
+ user_decision = user_row.get(confidence)
498
+ if user_decision and user_decision in self.VALID_DECISIONS:
499
+ merged_row[confidence] = user_decision
500
+ else:
501
+ merged_row[confidence] = default_row[confidence]
502
+ merged[severity] = merged_row
503
+ return merged
504
+
505
+ @staticmethod
506
+ def stricter_decision(a: str, b: str) -> str:
507
+ """Return the stricter of two decisions (deny > ask > log)."""
508
+ rank_a = DECISION_RANK.get(a, 1)
509
+ rank_b = DECISION_RANK.get(b, 1)
510
+ if rank_a >= rank_b:
511
+ return a
512
+ return b
513
+
514
+ @staticmethod
515
+ def merge_additive_only(global_policy: "EnforcementPolicy",
516
+ project_policy: "EnforcementPolicy") -> "EnforcementPolicy":
517
+ """Merge project policy with global, enforcing additive-only (escalation only).
518
+
519
+ Project can escalate decisions (log→ask, ask→deny) but never downgrade.
520
+ """
521
+ merged_config = {}
522
+ for severity in EnforcementPolicy.VALID_SEVERITIES:
523
+ merged_row = {}
524
+ for confidence in EnforcementPolicy.VALID_CONFIDENCES:
525
+ global_decision = global_policy.resolve(severity, confidence)
526
+ project_decision = project_policy.resolve(severity, confidence)
527
+ merged_row[confidence] = EnforcementPolicy.stricter_decision(
528
+ global_decision, project_decision
529
+ )
530
+ merged_config[severity] = merged_row
531
+ return EnforcementPolicy(merged_config)