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.
- tweek/__init__.py +2 -2
- tweek/_keygen.py +53 -0
- tweek/audit.py +288 -0
- tweek/cli.py +5303 -2396
- tweek/cli_model.py +380 -0
- tweek/config/families.yaml +609 -0
- tweek/config/manager.py +42 -5
- tweek/config/patterns.yaml +1510 -8
- tweek/config/tiers.yaml +161 -11
- tweek/diagnostics.py +71 -2
- tweek/hooks/break_glass.py +163 -0
- tweek/hooks/feedback.py +223 -0
- tweek/hooks/overrides.py +531 -0
- tweek/hooks/post_tool_use.py +472 -0
- tweek/hooks/pre_tool_use.py +1024 -62
- tweek/integrations/openclaw.py +443 -0
- tweek/integrations/openclaw_server.py +385 -0
- tweek/licensing.py +14 -54
- tweek/logging/bundle.py +2 -2
- tweek/logging/security_log.py +56 -13
- tweek/mcp/approval.py +57 -16
- tweek/mcp/proxy.py +18 -0
- tweek/mcp/screening.py +5 -5
- tweek/mcp/server.py +4 -1
- tweek/memory/__init__.py +24 -0
- tweek/memory/queries.py +223 -0
- tweek/memory/safety.py +140 -0
- tweek/memory/schemas.py +80 -0
- tweek/memory/store.py +989 -0
- tweek/platform/__init__.py +4 -4
- tweek/plugins/__init__.py +40 -24
- tweek/plugins/base.py +1 -1
- tweek/plugins/detectors/__init__.py +3 -3
- tweek/plugins/detectors/{moltbot.py → openclaw.py} +30 -27
- tweek/plugins/git_discovery.py +16 -4
- tweek/plugins/git_registry.py +8 -2
- tweek/plugins/git_security.py +21 -9
- tweek/plugins/screening/__init__.py +10 -1
- tweek/plugins/screening/heuristic_scorer.py +477 -0
- tweek/plugins/screening/llm_reviewer.py +14 -6
- tweek/plugins/screening/local_model_reviewer.py +161 -0
- tweek/proxy/__init__.py +38 -37
- tweek/proxy/addon.py +22 -3
- tweek/proxy/interceptor.py +1 -0
- tweek/proxy/server.py +4 -2
- tweek/sandbox/__init__.py +11 -0
- tweek/sandbox/docker_bridge.py +143 -0
- tweek/sandbox/executor.py +9 -6
- tweek/sandbox/layers.py +97 -0
- tweek/sandbox/linux.py +1 -0
- tweek/sandbox/project.py +548 -0
- tweek/sandbox/registry.py +149 -0
- tweek/security/__init__.py +9 -0
- tweek/security/language.py +250 -0
- tweek/security/llm_reviewer.py +1146 -60
- tweek/security/local_model.py +331 -0
- tweek/security/local_reviewer.py +146 -0
- tweek/security/model_registry.py +371 -0
- tweek/security/rate_limiter.py +11 -6
- tweek/security/secret_scanner.py +70 -4
- tweek/security/session_analyzer.py +26 -2
- tweek/skill_template/SKILL.md +200 -0
- tweek/skill_template/__init__.py +0 -0
- tweek/skill_template/cli-reference.md +331 -0
- tweek/skill_template/overrides-reference.md +184 -0
- tweek/skill_template/scripts/__init__.py +0 -0
- tweek/skill_template/scripts/check_installed.py +170 -0
- tweek/skills/__init__.py +38 -0
- tweek/skills/config.py +150 -0
- tweek/skills/fingerprints.py +198 -0
- tweek/skills/guard.py +293 -0
- tweek/skills/isolation.py +469 -0
- tweek/skills/scanner.py +715 -0
- tweek/vault/__init__.py +0 -1
- tweek/vault/cross_platform.py +12 -1
- tweek/vault/keychain.py +87 -29
- tweek-0.2.0.dist-info/METADATA +281 -0
- tweek-0.2.0.dist-info/RECORD +121 -0
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/entry_points.txt +8 -1
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/licenses/LICENSE +80 -0
- tweek/integrations/moltbot.py +0 -243
- tweek-0.1.0.dist-info/METADATA +0 -335
- tweek-0.1.0.dist-info/RECORD +0 -85
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/WHEEL +0 -0
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/top_level.txt +0 -0
tweek/sandbox/project.py
ADDED
|
@@ -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
|