tweek 0.1.0__py3-none-any.whl → 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.
Files changed (85) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/_keygen.py +53 -0
  3. tweek/audit.py +288 -0
  4. tweek/cli.py +5303 -2396
  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.0.dist-info/METADATA +281 -0
  78. tweek-0.2.0.dist-info/RECORD +121 -0
  79. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/entry_points.txt +8 -1
  80. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/licenses/LICENSE +80 -0
  81. tweek/integrations/moltbot.py +0 -243
  82. tweek-0.1.0.dist-info/METADATA +0 -335
  83. tweek-0.1.0.dist-info/RECORD +0 -85
  84. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/WHEEL +0 -0
  85. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,548 @@
1
+ """
2
+ Tweek Project Sandbox
3
+
4
+ Per-project security state isolation manager. Creates and manages
5
+ a .tweek/ directory inside each project with project-scoped:
6
+ - security.db (event log)
7
+ - overrides.yaml (additive-only pattern overrides)
8
+ - fingerprints.json (skill fingerprint cache)
9
+ - config.yaml (project Tweek config)
10
+ - sandbox.yaml (sandbox layer config)
11
+
12
+ The additive-only model ensures project-level config can NEVER weaken
13
+ global security:
14
+ - Project can ADD patterns but not disable global patterns
15
+ - Project can RAISE severity thresholds but not lower them
16
+ - Project whitelists must be scoped to the project directory
17
+ """
18
+
19
+ import os
20
+ import sys
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+
25
+ import yaml
26
+
27
+ from .layers import IsolationLayer, stricter_severity
28
+ from .registry import get_registry
29
+
30
+
31
+ TWEEK_HOME = Path.home() / ".tweek"
32
+
33
+
34
+ @dataclass
35
+ class SandboxConfig:
36
+ """Configuration for a project's sandbox."""
37
+
38
+ layer: int = 2
39
+ inherit_global_patterns: bool = True
40
+ additive_only: bool = True
41
+ auto_init: bool = True
42
+ auto_gitignore: bool = True
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: dict) -> "SandboxConfig":
46
+ """Create from a dict (loaded from YAML)."""
47
+ return cls(
48
+ layer=data.get("layer", 2),
49
+ inherit_global_patterns=data.get("inherit_global_patterns", True),
50
+ additive_only=data.get("additive_only", True),
51
+ auto_init=data.get("auto_init", True),
52
+ auto_gitignore=data.get("auto_gitignore", True),
53
+ )
54
+
55
+ def to_dict(self) -> dict:
56
+ """Serialize to dict for YAML output."""
57
+ return {
58
+ "layer": self.layer,
59
+ "inherit_global_patterns": self.inherit_global_patterns,
60
+ "additive_only": self.additive_only,
61
+ "auto_init": self.auto_init,
62
+ "auto_gitignore": self.auto_gitignore,
63
+ }
64
+
65
+
66
+ def _get_global_sandbox_defaults() -> dict:
67
+ """Load sandbox defaults from global ~/.tweek/config.yaml."""
68
+ global_config = TWEEK_HOME / "config.yaml"
69
+ if not global_config.exists():
70
+ return {}
71
+ try:
72
+ with open(global_config) as f:
73
+ data = yaml.safe_load(f) or {}
74
+ return data.get("sandbox", {})
75
+ except Exception:
76
+ return {}
77
+
78
+
79
+ class ProjectSandbox:
80
+ """Per-project isolation manager.
81
+
82
+ Manages the .tweek/ directory inside a project for project-scoped
83
+ security state. Provides scoped logger, overrides, and fingerprints
84
+ that enforce the additive-only security model.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ project_dir: Path,
90
+ global_config_path: Optional[Path] = None,
91
+ ):
92
+ self.project_dir = project_dir.resolve()
93
+ self.tweek_dir = self.project_dir / ".tweek"
94
+ self._global_config_path = global_config_path
95
+ self.config = self._load_config()
96
+ self.layer = IsolationLayer.from_value(self.config.layer)
97
+
98
+ # Cached service instances
99
+ self._logger = None
100
+ self._overrides = None
101
+ self._fingerprints = None
102
+ self._memory_store = None
103
+
104
+ def _load_config(self) -> SandboxConfig:
105
+ """Load sandbox config from project .tweek/sandbox.yaml."""
106
+ sandbox_yaml = self.tweek_dir / "sandbox.yaml"
107
+ if sandbox_yaml.exists():
108
+ try:
109
+ with open(sandbox_yaml) as f:
110
+ data = yaml.safe_load(f) or {}
111
+ return SandboxConfig.from_dict(data)
112
+ except Exception:
113
+ pass
114
+
115
+ # Check registry for layer setting
116
+ registry = get_registry()
117
+ reg_layer = registry.get_layer(self.project_dir)
118
+ if reg_layer is not None:
119
+ return SandboxConfig(layer=reg_layer.value)
120
+
121
+ # Fall back to global defaults
122
+ defaults = _get_global_sandbox_defaults()
123
+ return SandboxConfig(
124
+ layer=defaults.get("default_layer", 2),
125
+ auto_init=defaults.get("auto_init", True),
126
+ auto_gitignore=defaults.get("auto_gitignore", True),
127
+ )
128
+
129
+ @property
130
+ def is_initialized(self) -> bool:
131
+ """Check if the project .tweek/ directory exists."""
132
+ return self.tweek_dir.is_dir()
133
+
134
+ def initialize(self) -> None:
135
+ """Create .tweek/ directory with default state files.
136
+
137
+ Creates:
138
+ - .tweek/sandbox.yaml (layer config)
139
+ - .tweek/overrides.yaml (empty, additive-only)
140
+ - .tweek/config.yaml (empty, inherits global)
141
+
142
+ Also adds .tweek/ to .gitignore if not present.
143
+ """
144
+ self.tweek_dir.mkdir(parents=True, exist_ok=True)
145
+
146
+ # Create sandbox.yaml
147
+ sandbox_yaml = self.tweek_dir / "sandbox.yaml"
148
+ if not sandbox_yaml.exists():
149
+ from datetime import datetime, timezone
150
+
151
+ data = self.config.to_dict()
152
+ data["created_at"] = datetime.now(timezone.utc).isoformat()
153
+ with open(sandbox_yaml, "w") as f:
154
+ yaml.safe_dump(data, f, default_flow_style=False)
155
+
156
+ # Create empty overrides.yaml
157
+ overrides_yaml = self.tweek_dir / "overrides.yaml"
158
+ if not overrides_yaml.exists():
159
+ with open(overrides_yaml, "w") as f:
160
+ f.write("# Project-scoped security overrides (additive-only)\n")
161
+ f.write("# Project overrides can ADD patterns/whitelists but NEVER disable global ones.\n")
162
+ f.write("# See: tweek sandbox config\n")
163
+
164
+ # Create empty config.yaml
165
+ config_yaml = self.tweek_dir / "config.yaml"
166
+ if not config_yaml.exists():
167
+ with open(config_yaml, "w") as f:
168
+ f.write("# Project-scoped Tweek configuration\n")
169
+ f.write("# Values here override global ~/.tweek/config.yaml for this project.\n")
170
+
171
+ # Auto-gitignore
172
+ if self.config.auto_gitignore:
173
+ self._ensure_gitignored()
174
+
175
+ # Register in the project registry
176
+ registry = get_registry()
177
+ registry.register(
178
+ self.project_dir,
179
+ layer=self.layer,
180
+ auto_initialized=True,
181
+ )
182
+
183
+ def _ensure_gitignored(self) -> None:
184
+ """Add .tweek/ to .gitignore if not already present."""
185
+ gitignore = self.project_dir / ".gitignore"
186
+ tweek_entry = ".tweek/"
187
+
188
+ if gitignore.exists():
189
+ try:
190
+ content = gitignore.read_text()
191
+ # Check if already gitignored (exact line match)
192
+ lines = content.splitlines()
193
+ for line in lines:
194
+ stripped = line.strip()
195
+ if stripped in (".tweek/", ".tweek", "/.tweek/", "/.tweek"):
196
+ return # Already present
197
+ # Append
198
+ if not content.endswith("\n"):
199
+ content += "\n"
200
+ content += f"\n# Tweek project sandbox state\n{tweek_entry}\n"
201
+ gitignore.write_text(content)
202
+ except (IOError, OSError):
203
+ pass
204
+ else:
205
+ # Only create .gitignore if .git/ exists (it's a git repo)
206
+ if (self.project_dir / ".git").exists():
207
+ try:
208
+ gitignore.write_text(
209
+ f"# Tweek project sandbox state\n{tweek_entry}\n"
210
+ )
211
+ except (IOError, OSError):
212
+ pass
213
+
214
+ def get_logger(self):
215
+ """Return project-scoped SecurityLogger.
216
+
217
+ Lazy import to avoid circular dependencies since security_log
218
+ is also imported by the hooks.
219
+ """
220
+ if self._logger is not None:
221
+ return self._logger
222
+
223
+ if self.layer.value < IsolationLayer.PROJECT.value:
224
+ from tweek.logging.security_log import get_logger
225
+ self._logger = get_logger()
226
+ return self._logger
227
+
228
+ from tweek.logging.security_log import SecurityLogger
229
+ self._logger = SecurityLogger(db_path=self.tweek_dir / "security.db")
230
+ return self._logger
231
+
232
+ def get_overrides(self):
233
+ """Return merged overrides (global + project, additive-only).
234
+
235
+ The merge enforces:
236
+ - Project cannot disable global patterns
237
+ - Project whitelist entries must be scoped to project directory
238
+ - Project severity threshold can only be raised (stricter), not lowered
239
+ - Project can force-enable additional patterns
240
+ """
241
+ if self._overrides is not None:
242
+ return self._overrides
243
+
244
+ from tweek.hooks.overrides import (
245
+ get_overrides as get_global_overrides,
246
+ SecurityOverrides,
247
+ )
248
+
249
+ global_ovr = get_global_overrides()
250
+
251
+ if self.layer.value < IsolationLayer.PROJECT.value:
252
+ self._overrides = global_ovr
253
+ return self._overrides
254
+
255
+ project_ovr_path = self.tweek_dir / "overrides.yaml"
256
+ if not project_ovr_path.exists():
257
+ self._overrides = global_ovr
258
+ return self._overrides
259
+
260
+ project_ovr = SecurityOverrides(config_path=project_ovr_path)
261
+ if not project_ovr.config:
262
+ self._overrides = global_ovr
263
+ return self._overrides
264
+
265
+ # Merge with additive-only enforcement
266
+ self._overrides = MergedOverrides(
267
+ global_ovr=global_ovr,
268
+ project_ovr=project_ovr,
269
+ project_dir=self.project_dir,
270
+ )
271
+ return self._overrides
272
+
273
+ def get_memory_store(self):
274
+ """Return project-scoped MemoryStore.
275
+
276
+ Uses the project's .tweek/memory.db for project-scoped memory.
277
+ Falls back to global memory for layers below PROJECT.
278
+ """
279
+ if self._memory_store is not None:
280
+ return self._memory_store
281
+
282
+ from tweek.memory.store import MemoryStore, get_memory_store
283
+
284
+ if self.layer.value < IsolationLayer.PROJECT.value:
285
+ self._memory_store = get_memory_store()
286
+ return self._memory_store
287
+
288
+ self._memory_store = MemoryStore(
289
+ db_path=self.tweek_dir / "memory.db"
290
+ )
291
+ return self._memory_store
292
+
293
+ def get_fingerprints(self):
294
+ """Return project-scoped fingerprint cache."""
295
+ if self._fingerprints is not None:
296
+ return self._fingerprints
297
+
298
+ if self.layer.value < IsolationLayer.PROJECT.value:
299
+ from tweek.skills.fingerprints import get_fingerprints
300
+ self._fingerprints = get_fingerprints()
301
+ return self._fingerprints
302
+
303
+ from tweek.skills.fingerprints import SkillFingerprints
304
+ self._fingerprints = SkillFingerprints(
305
+ cache_path=self.tweek_dir / "fingerprints.json"
306
+ )
307
+ return self._fingerprints
308
+
309
+ def reset(self) -> None:
310
+ """Remove project .tweek/ directory and deregister."""
311
+ import shutil
312
+
313
+ if self.tweek_dir.exists():
314
+ shutil.rmtree(self.tweek_dir)
315
+
316
+ registry = get_registry()
317
+ registry.deregister(self.project_dir)
318
+
319
+ # Clear cached services
320
+ self._logger = None
321
+ self._overrides = None
322
+ self._fingerprints = None
323
+ if self._memory_store is not None:
324
+ self._memory_store.close()
325
+ self._memory_store = None
326
+
327
+
328
+ class MergedOverrides:
329
+ """Wrapper that merges global and project overrides with additive-only enforcement.
330
+
331
+ Implements the same interface as SecurityOverrides so it can be used
332
+ as a drop-in replacement in the hooks.
333
+ """
334
+
335
+ def __init__(self, global_ovr, project_ovr, project_dir: Path):
336
+ self.global_ovr = global_ovr
337
+ self.project_ovr = project_ovr
338
+ self.project_dir = project_dir.resolve()
339
+
340
+ # Merge the config dicts
341
+ self.config = self._merge_configs()
342
+ self._whitelist_rules = self.config.get("whitelist", [])
343
+ self._pattern_config = self.config.get("patterns", {})
344
+ self._trust_config = self.config.get("trust", {})
345
+
346
+ def _merge_configs(self) -> dict:
347
+ """Merge global and project configs with additive-only enforcement."""
348
+ global_cfg = self.global_ovr.config if self.global_ovr else {}
349
+ project_cfg = self.project_ovr.config if self.project_ovr else {}
350
+
351
+ merged = {}
352
+
353
+ # --- Whitelist: project can add, but only for project-scoped paths ---
354
+ global_whitelist = global_cfg.get("whitelist", [])
355
+ project_whitelist = project_cfg.get("whitelist", [])
356
+ scoped_project_whitelist = [
357
+ rule for rule in project_whitelist
358
+ if self._is_project_scoped_rule(rule)
359
+ ]
360
+ merged["whitelist"] = global_whitelist + scoped_project_whitelist
361
+
362
+ # --- Patterns: additive-only ---
363
+ global_patterns = global_cfg.get("patterns", {})
364
+ project_patterns = project_cfg.get("patterns", {})
365
+
366
+ merged_patterns = {}
367
+
368
+ # Disabled patterns: ONLY from global (project cannot disable)
369
+ merged_patterns["disabled"] = global_patterns.get("disabled", [])
370
+
371
+ # Force-enabled: union of global and project
372
+ global_force = set(global_patterns.get("force_enabled", []))
373
+ project_force = set(project_patterns.get("force_enabled", []))
374
+ merged_patterns["force_enabled"] = list(global_force | project_force)
375
+
376
+ # Scoped disables: ONLY from global
377
+ merged_patterns["scoped_disables"] = global_patterns.get("scoped_disables", [])
378
+
379
+ merged["patterns"] = merged_patterns
380
+
381
+ # --- Trust: project can raise threshold (stricter) but not lower ---
382
+ global_trust = global_cfg.get("trust", {})
383
+ project_trust = project_cfg.get("trust", {})
384
+ merged_trust = dict(global_trust)
385
+
386
+ for mode in ("interactive", "automated"):
387
+ global_mode = global_trust.get(mode, {})
388
+ project_mode = project_trust.get(mode, {})
389
+
390
+ if project_mode.get("min_severity") and global_mode.get("min_severity"):
391
+ # Keep the stricter of the two
392
+ merged_sev = stricter_severity(
393
+ global_mode["min_severity"],
394
+ project_mode["min_severity"],
395
+ )
396
+ if mode not in merged_trust:
397
+ merged_trust[mode] = {}
398
+ merged_trust[mode]["min_severity"] = merged_sev
399
+
400
+ merged["trust"] = merged_trust
401
+
402
+ return merged
403
+
404
+ def _is_project_scoped_rule(self, rule: dict) -> bool:
405
+ """Check if a whitelist rule is scoped to the project directory."""
406
+ rule_path = rule.get("path")
407
+ if not rule_path:
408
+ # Rules without a path (tool-only rules) are allowed from project
409
+ # only if they specify a tool filter
410
+ return bool(rule.get("tool") or rule.get("tools"))
411
+
412
+ try:
413
+ resolved = Path(rule_path).expanduser().resolve()
414
+ resolved.relative_to(self.project_dir)
415
+ return True
416
+ except (ValueError, OSError):
417
+ return False
418
+
419
+ # === SecurityOverrides-compatible interface ===
420
+
421
+ def check_whitelist(self, tool_name, tool_input, content):
422
+ """Check if invocation matches a whitelist rule."""
423
+ if self.global_ovr:
424
+ match = self.global_ovr.check_whitelist(tool_name, tool_input, content)
425
+ if match:
426
+ return match
427
+ if self.project_ovr:
428
+ match = self.project_ovr.check_whitelist(tool_name, tool_input, content)
429
+ if match and self._is_project_scoped_rule(match):
430
+ return match
431
+ return None
432
+
433
+ def filter_patterns(self, matches, working_path):
434
+ """Filter patterns using merged config."""
435
+ if self.global_ovr:
436
+ matches = self.global_ovr.filter_patterns(matches, working_path)
437
+ # Project force-enabled patterns are already merged — no additional filtering
438
+ return matches
439
+
440
+ def get_min_severity(self, trust_mode):
441
+ """Get minimum severity threshold from merged config."""
442
+ mode_config = self._trust_config.get(trust_mode, {})
443
+ return mode_config.get("min_severity", "low")
444
+
445
+ def get_trust_default(self):
446
+ """Get default trust mode from merged config."""
447
+ return self._trust_config.get("default_mode", "interactive")
448
+
449
+ def should_skip_llm_for_default_tier(self, trust_mode):
450
+ """Check if LLM review should be skipped for default-tier tools."""
451
+ mode_config = self._trust_config.get(trust_mode, {})
452
+ return mode_config.get("skip_llm_for_default_tier", False)
453
+
454
+ def get_enforcement_policy(self):
455
+ """Get merged enforcement policy (additive-only: project can only escalate).
456
+
457
+ Uses EnforcementPolicy.merge_additive_only to ensure the project
458
+ can escalate decisions (log→ask, ask→deny) but never downgrade them.
459
+ """
460
+ from tweek.hooks.overrides import EnforcementPolicy
461
+
462
+ global_policy = EnforcementPolicy(
463
+ self.global_ovr.config.get("enforcement", {}) if self.global_ovr else {}
464
+ )
465
+ project_policy = EnforcementPolicy(
466
+ self.project_ovr.config.get("enforcement", {}) if self.project_ovr else {}
467
+ )
468
+ return EnforcementPolicy.merge_additive_only(global_policy, project_policy)
469
+
470
+
471
+ # ==========================================================================
472
+ # Module-level singleton cache (keyed by resolved project path)
473
+ # ==========================================================================
474
+
475
+ _sandboxes: Dict[str, ProjectSandbox] = {}
476
+
477
+
478
+ def _detect_project_dir(working_dir: str) -> Optional[Path]:
479
+ """Detect a project directory by looking for .git/ or .claude/.
480
+
481
+ Walks upward from working_dir to find the project root.
482
+ """
483
+ current = Path(working_dir).resolve()
484
+ # Walk up at most 10 levels
485
+ for _ in range(10):
486
+ if (current / ".git").exists() or (current / ".claude").exists():
487
+ return current
488
+ parent = current.parent
489
+ if parent == current:
490
+ break
491
+ current = parent
492
+ return None
493
+
494
+
495
+ def get_project_sandbox(
496
+ working_dir: Optional[str],
497
+ ) -> Optional[ProjectSandbox]:
498
+ """Get the ProjectSandbox for the given working directory.
499
+
500
+ Returns None if:
501
+ - working_dir is None
502
+ - No project root is found (no .git/ or .claude/)
503
+ - The project's layer is < PROJECT (bypass or skills-only)
504
+
505
+ Uses a singleton cache keyed by resolved project path for performance.
506
+ """
507
+ if not working_dir:
508
+ return None
509
+
510
+ project_dir = _detect_project_dir(working_dir)
511
+ if project_dir is None:
512
+ return None
513
+
514
+ key = str(project_dir)
515
+ if key in _sandboxes:
516
+ sandbox = _sandboxes[key]
517
+ # Update last used in registry periodically (not on every call)
518
+ return sandbox
519
+
520
+ sandbox = ProjectSandbox(project_dir)
521
+
522
+ # Check global config for auto_init
523
+ if sandbox.config.auto_init and sandbox.layer >= IsolationLayer.PROJECT:
524
+ if not sandbox.is_initialized:
525
+ try:
526
+ sandbox.initialize()
527
+ except (IOError, OSError) as e:
528
+ # Fall back to global state if we can't create .tweek/
529
+ print(
530
+ f"WARNING: Could not initialize project sandbox at "
531
+ f"{sandbox.tweek_dir}: {e}",
532
+ file=sys.stderr,
533
+ )
534
+ return None
535
+
536
+ # Only return sandbox for Layer 2+
537
+ if sandbox.layer < IsolationLayer.PROJECT:
538
+ _sandboxes[key] = sandbox # Cache even non-PROJECT layers
539
+ return None
540
+
541
+ _sandboxes[key] = sandbox
542
+ return sandbox
543
+
544
+
545
+ def reset_sandboxes() -> None:
546
+ """Reset the singleton cache (for testing)."""
547
+ global _sandboxes
548
+ _sandboxes = {}
@@ -0,0 +1,149 @@
1
+ """
2
+ Tweek Project Registry
3
+
4
+ Tracks known projects and their sandbox configurations.
5
+ Persisted at ~/.tweek/projects/registry.json.
6
+ """
7
+
8
+ import json
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional
12
+
13
+ from .layers import IsolationLayer
14
+
15
+
16
+ TWEEK_HOME = Path.home() / ".tweek"
17
+ REGISTRY_DIR = TWEEK_HOME / "projects"
18
+ REGISTRY_PATH = REGISTRY_DIR / "registry.json"
19
+
20
+
21
+ class ProjectRegistry:
22
+ """Manages the registry of known projects and their sandbox layers."""
23
+
24
+ def __init__(self, registry_path: Optional[Path] = None):
25
+ self.registry_path = registry_path or REGISTRY_PATH
26
+ self._data = self._load()
27
+
28
+ def _load(self) -> dict:
29
+ """Load registry from disk."""
30
+ if not self.registry_path.exists():
31
+ return {"schema_version": 1, "projects": {}}
32
+ try:
33
+ data = json.loads(self.registry_path.read_text())
34
+ if not isinstance(data, dict) or "projects" not in data:
35
+ return {"schema_version": 1, "projects": {}}
36
+ return data
37
+ except (json.JSONDecodeError, IOError):
38
+ return {"schema_version": 1, "projects": {}}
39
+
40
+ def _save(self) -> None:
41
+ """Persist registry to disk."""
42
+ self.registry_path.parent.mkdir(parents=True, exist_ok=True)
43
+ self.registry_path.write_text(json.dumps(self._data, indent=2))
44
+
45
+ def register(
46
+ self,
47
+ project_dir: Path,
48
+ layer: IsolationLayer = IsolationLayer.PROJECT,
49
+ auto_initialized: bool = False,
50
+ ) -> None:
51
+ """Register a project with its sandbox layer."""
52
+ key = str(project_dir.resolve())
53
+ now = datetime.now(timezone.utc).isoformat()
54
+
55
+ existing = self._data["projects"].get(key)
56
+ if existing:
57
+ existing["last_used"] = now
58
+ existing["layer"] = layer.value
59
+ else:
60
+ self._data["projects"][key] = {
61
+ "layer": layer.value,
62
+ "created_at": now,
63
+ "last_used": now,
64
+ "auto_initialized": auto_initialized,
65
+ }
66
+ self._save()
67
+
68
+ def update_last_used(self, project_dir: Path) -> None:
69
+ """Update the last_used timestamp for a project."""
70
+ key = str(project_dir.resolve())
71
+ entry = self._data["projects"].get(key)
72
+ if entry:
73
+ entry["last_used"] = datetime.now(timezone.utc).isoformat()
74
+ self._save()
75
+
76
+ def get_layer(self, project_dir: Path) -> Optional[IsolationLayer]:
77
+ """Get the configured layer for a project. Returns None if not registered."""
78
+ key = str(project_dir.resolve())
79
+ entry = self._data["projects"].get(key)
80
+ if entry is None:
81
+ return None
82
+ return IsolationLayer.from_value(entry.get("layer", 2))
83
+
84
+ def set_layer(self, project_dir: Path, layer: IsolationLayer) -> None:
85
+ """Set the isolation layer for a registered project."""
86
+ key = str(project_dir.resolve())
87
+ entry = self._data["projects"].get(key)
88
+ if entry is None:
89
+ self.register(project_dir, layer)
90
+ else:
91
+ entry["layer"] = layer.value
92
+ self._save()
93
+
94
+ def deregister(self, project_dir: Path) -> bool:
95
+ """Remove a project from the registry. Returns True if it existed."""
96
+ key = str(project_dir.resolve())
97
+ if key in self._data["projects"]:
98
+ del self._data["projects"][key]
99
+ self._save()
100
+ return True
101
+ return False
102
+
103
+ def is_registered(self, project_dir: Path) -> bool:
104
+ """Check if a project is registered."""
105
+ key = str(project_dir.resolve())
106
+ return key in self._data["projects"]
107
+
108
+ def list_projects(self) -> List[Dict]:
109
+ """List all registered projects with their info."""
110
+ results = []
111
+ for path_str, info in self._data["projects"].items():
112
+ results.append({
113
+ "path": path_str,
114
+ "layer": IsolationLayer.from_value(info.get("layer", 2)),
115
+ "created_at": info.get("created_at", ""),
116
+ "last_used": info.get("last_used", ""),
117
+ "auto_initialized": info.get("auto_initialized", False),
118
+ })
119
+ return results
120
+
121
+ def cleanup_stale(self) -> int:
122
+ """Remove entries for project directories that no longer exist."""
123
+ stale = [
124
+ key for key in self._data["projects"]
125
+ if not Path(key).exists()
126
+ ]
127
+ for key in stale:
128
+ del self._data["projects"][key]
129
+ if stale:
130
+ self._save()
131
+ return len(stale)
132
+
133
+
134
+ # Module-level singleton
135
+ _registry: Optional[ProjectRegistry] = None
136
+
137
+
138
+ def get_registry(registry_path: Optional[Path] = None) -> ProjectRegistry:
139
+ """Get the singleton ProjectRegistry instance."""
140
+ global _registry
141
+ if _registry is None:
142
+ _registry = ProjectRegistry(registry_path)
143
+ return _registry
144
+
145
+
146
+ def reset_registry() -> None:
147
+ """Reset the singleton (for testing)."""
148
+ global _registry
149
+ _registry = None