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
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek OpenClaw Scanning Server
|
|
4
|
+
|
|
5
|
+
HTTP server exposing Tweek's security scanning pipeline for the OpenClaw
|
|
6
|
+
Gateway plugin. Runs on localhost and provides endpoints for skill scanning,
|
|
7
|
+
tool screening, output scanning, and fingerprint management.
|
|
8
|
+
|
|
9
|
+
Endpoints:
|
|
10
|
+
POST /scan — Run 7-layer SkillScanner on a skill directory
|
|
11
|
+
POST /screen — Screen a tool call (pre-execution)
|
|
12
|
+
POST /output — Scan tool output (post-execution)
|
|
13
|
+
POST /fingerprint/check — Check if skill is known/approved
|
|
14
|
+
POST /fingerprint/register — Register approved skill hash
|
|
15
|
+
GET /health — Server health + scanner status
|
|
16
|
+
GET /report/<skill> — Retrieve scan report
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python -m tweek.integrations.openclaw_server [--port 9878]
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, Optional
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
# Default port for the OpenClaw scanning server
|
|
31
|
+
DEFAULT_PORT = 9878
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _scan_skill(skill_dir: str) -> Dict[str, Any]:
|
|
35
|
+
"""
|
|
36
|
+
Run the 7-layer SkillScanner on a skill directory.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
skill_dir: Path to the directory containing SKILL.md
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Scan report as a JSON-serializable dict
|
|
43
|
+
"""
|
|
44
|
+
from tweek.skills.scanner import SkillScanner
|
|
45
|
+
|
|
46
|
+
scanner = SkillScanner()
|
|
47
|
+
report = scanner.scan(Path(skill_dir))
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"verdict": report.verdict,
|
|
51
|
+
"risk_level": report.risk_level,
|
|
52
|
+
"skill_name": report.skill_name,
|
|
53
|
+
"layers_passed": report.layers_passed,
|
|
54
|
+
"layers_total": report.layers_total,
|
|
55
|
+
"findings": [
|
|
56
|
+
{
|
|
57
|
+
"layer": f.layer,
|
|
58
|
+
"severity": f.severity,
|
|
59
|
+
"description": f.description,
|
|
60
|
+
"matched_text": getattr(f, "matched_text", ""),
|
|
61
|
+
}
|
|
62
|
+
for f in report.findings
|
|
63
|
+
],
|
|
64
|
+
"severity_counts": {
|
|
65
|
+
"critical": report.critical_count,
|
|
66
|
+
"high": report.high_count,
|
|
67
|
+
"medium": report.medium_count,
|
|
68
|
+
"low": report.low_count,
|
|
69
|
+
},
|
|
70
|
+
"report_path": str(report.report_path) if report.report_path else None,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _screen_tool(tool: str, input_data: Dict, tier: str = "default") -> Dict[str, Any]:
|
|
75
|
+
"""
|
|
76
|
+
Screen a tool call through Tweek's pattern matcher and LLM reviewer.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
tool: Tool name (e.g., "bash", "file_write", "web_fetch")
|
|
80
|
+
input_data: Tool input parameters
|
|
81
|
+
tier: Security tier ("safe", "default", "risky", "dangerous")
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Screening decision dict
|
|
85
|
+
"""
|
|
86
|
+
from tweek.hooks.pre_tool_use import process_hook
|
|
87
|
+
from unittest.mock import MagicMock
|
|
88
|
+
|
|
89
|
+
logger = MagicMock()
|
|
90
|
+
logger.log_quick = MagicMock()
|
|
91
|
+
|
|
92
|
+
# Build the hook input matching Tweek's expected format
|
|
93
|
+
command = ""
|
|
94
|
+
if tool == "bash":
|
|
95
|
+
command = input_data.get("command", "")
|
|
96
|
+
elif tool in ("file_write", "Write"):
|
|
97
|
+
command = input_data.get("content", "") or input_data.get("file_path", "")
|
|
98
|
+
elif tool in ("web_fetch", "WebFetch"):
|
|
99
|
+
command = input_data.get("url", "") or input_data.get("prompt", "")
|
|
100
|
+
else:
|
|
101
|
+
command = json.dumps(input_data)[:500]
|
|
102
|
+
|
|
103
|
+
hook_input = {
|
|
104
|
+
"tool_name": tool,
|
|
105
|
+
"tool_input": input_data,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
result = process_hook(hook_input, logger)
|
|
110
|
+
decision = result.get("hookSpecificOutput", {}).get("permissionDecision", "allow")
|
|
111
|
+
reason = result.get("hookSpecificOutput", {}).get("permissionDecisionReason", "")
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"decision": decision,
|
|
115
|
+
"reason": reason,
|
|
116
|
+
"tool": tool,
|
|
117
|
+
"tier": tier,
|
|
118
|
+
}
|
|
119
|
+
except Exception as e:
|
|
120
|
+
return {
|
|
121
|
+
"decision": "allow",
|
|
122
|
+
"reason": f"Screening error: {e}",
|
|
123
|
+
"tool": tool,
|
|
124
|
+
"tier": tier,
|
|
125
|
+
"error": str(e),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _scan_output(content: str) -> Dict[str, Any]:
|
|
130
|
+
"""
|
|
131
|
+
Scan tool output for credential leakage and exfiltration attempts.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
content: Tool output text to scan
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Scanning result dict
|
|
138
|
+
"""
|
|
139
|
+
from tweek.hooks.post_tool_use import process_hook
|
|
140
|
+
|
|
141
|
+
input_data = {
|
|
142
|
+
"tool_name": "Read",
|
|
143
|
+
"tool_input": {"file_path": "/virtual/openclaw-output.txt"},
|
|
144
|
+
"tool_response": content,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
result = process_hook(input_data)
|
|
149
|
+
if result.get("decision") == "block":
|
|
150
|
+
return {
|
|
151
|
+
"blocked": True,
|
|
152
|
+
"reason": result.get("reason", ""),
|
|
153
|
+
}
|
|
154
|
+
except Exception as e:
|
|
155
|
+
return {"blocked": False, "error": f"Output scanning error: {e}"}
|
|
156
|
+
|
|
157
|
+
return {"blocked": False}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _check_fingerprint(skill_path: str) -> Dict[str, Any]:
|
|
161
|
+
"""
|
|
162
|
+
Check if a skill is known/approved via fingerprint.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
skill_path: Absolute path to the SKILL.md file
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Fingerprint status dict
|
|
169
|
+
"""
|
|
170
|
+
from tweek.skills.fingerprints import get_fingerprints
|
|
171
|
+
|
|
172
|
+
fps = get_fingerprints()
|
|
173
|
+
known = fps.is_known(Path(skill_path))
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"known": known,
|
|
177
|
+
"path": skill_path,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _register_fingerprint(
|
|
182
|
+
skill_path: str, verdict: str, report_path: Optional[str] = None
|
|
183
|
+
) -> Dict[str, Any]:
|
|
184
|
+
"""
|
|
185
|
+
Register a skill's fingerprint after approval.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
skill_path: Absolute path to the SKILL.md file
|
|
189
|
+
verdict: Scan verdict ("pass", "manual_review", "fail")
|
|
190
|
+
report_path: Optional path to the scan report
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Registration result dict
|
|
194
|
+
"""
|
|
195
|
+
from tweek.skills.fingerprints import get_fingerprints
|
|
196
|
+
|
|
197
|
+
fps = get_fingerprints()
|
|
198
|
+
fps.register(Path(skill_path), verdict=verdict, report_path=report_path)
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"registered": True,
|
|
202
|
+
"path": skill_path,
|
|
203
|
+
"verdict": verdict,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _get_report(skill_name: str) -> Dict[str, Any]:
|
|
208
|
+
"""
|
|
209
|
+
Retrieve the most recent scan report for a skill.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
skill_name: Name of the skill
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Report dict or error
|
|
216
|
+
"""
|
|
217
|
+
from tweek.skills import REPORTS_DIR
|
|
218
|
+
|
|
219
|
+
# Find the most recent report for this skill
|
|
220
|
+
reports = sorted(
|
|
221
|
+
REPORTS_DIR.glob(f"{skill_name}-*.json"),
|
|
222
|
+
key=lambda p: p.stat().st_mtime,
|
|
223
|
+
reverse=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if not reports:
|
|
227
|
+
return {"error": f"No report found for skill: {skill_name}"}
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
with open(reports[0]) as f:
|
|
231
|
+
report_data = json.load(f)
|
|
232
|
+
report_data["report_path"] = str(reports[0])
|
|
233
|
+
return report_data
|
|
234
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
235
|
+
return {"error": f"Failed to read report: {e}"}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class OpenClawScanHandler(BaseHTTPRequestHandler):
|
|
239
|
+
"""HTTP request handler for the OpenClaw scanning server."""
|
|
240
|
+
|
|
241
|
+
def do_GET(self):
|
|
242
|
+
parsed = urlparse(self.path)
|
|
243
|
+
|
|
244
|
+
if parsed.path == "/health":
|
|
245
|
+
self._respond(200, {
|
|
246
|
+
"status": "ok",
|
|
247
|
+
"service": "tweek-openclaw-scanner",
|
|
248
|
+
"port": self.server.server_address[1],
|
|
249
|
+
})
|
|
250
|
+
elif parsed.path.startswith("/report/"):
|
|
251
|
+
skill_name = parsed.path[len("/report/"):]
|
|
252
|
+
if not skill_name:
|
|
253
|
+
self._respond(400, {"error": "Missing skill name"})
|
|
254
|
+
return
|
|
255
|
+
result = _get_report(skill_name)
|
|
256
|
+
status = 200 if "error" not in result else 404
|
|
257
|
+
self._respond(status, result)
|
|
258
|
+
else:
|
|
259
|
+
self._respond(404, {"error": "Not found"})
|
|
260
|
+
|
|
261
|
+
def do_POST(self):
|
|
262
|
+
parsed = urlparse(self.path)
|
|
263
|
+
data = self._read_json()
|
|
264
|
+
if data is None:
|
|
265
|
+
return # Error already sent
|
|
266
|
+
|
|
267
|
+
if parsed.path == "/scan":
|
|
268
|
+
skill_dir = data.get("skill_dir", "")
|
|
269
|
+
if not skill_dir:
|
|
270
|
+
self._respond(400, {"error": "Missing 'skill_dir' field"})
|
|
271
|
+
return
|
|
272
|
+
if not Path(skill_dir).exists():
|
|
273
|
+
self._respond(400, {"error": f"Directory not found: {skill_dir}"})
|
|
274
|
+
return
|
|
275
|
+
result = _scan_skill(skill_dir)
|
|
276
|
+
self._respond(200, result)
|
|
277
|
+
|
|
278
|
+
elif parsed.path == "/screen":
|
|
279
|
+
tool = data.get("tool", "")
|
|
280
|
+
input_data = data.get("input", {})
|
|
281
|
+
tier = data.get("tier", "default")
|
|
282
|
+
if not tool:
|
|
283
|
+
self._respond(400, {"error": "Missing 'tool' field"})
|
|
284
|
+
return
|
|
285
|
+
result = _screen_tool(tool, input_data, tier)
|
|
286
|
+
self._respond(200, result)
|
|
287
|
+
|
|
288
|
+
elif parsed.path == "/output":
|
|
289
|
+
content = data.get("content", "")
|
|
290
|
+
if not content:
|
|
291
|
+
self._respond(400, {"error": "Missing 'content' field"})
|
|
292
|
+
return
|
|
293
|
+
result = _scan_output(content)
|
|
294
|
+
self._respond(200, result)
|
|
295
|
+
|
|
296
|
+
elif parsed.path == "/fingerprint/check":
|
|
297
|
+
path = data.get("path", "")
|
|
298
|
+
if not path:
|
|
299
|
+
self._respond(400, {"error": "Missing 'path' field"})
|
|
300
|
+
return
|
|
301
|
+
result = _check_fingerprint(path)
|
|
302
|
+
self._respond(200, result)
|
|
303
|
+
|
|
304
|
+
elif parsed.path == "/fingerprint/register":
|
|
305
|
+
path = data.get("path", "")
|
|
306
|
+
verdict = data.get("verdict", "")
|
|
307
|
+
if not path or not verdict:
|
|
308
|
+
self._respond(400, {"error": "Missing 'path' or 'verdict' field"})
|
|
309
|
+
return
|
|
310
|
+
report_path = data.get("report_path")
|
|
311
|
+
result = _register_fingerprint(path, verdict, report_path)
|
|
312
|
+
self._respond(200, result)
|
|
313
|
+
|
|
314
|
+
else:
|
|
315
|
+
self._respond(404, {"error": "Not found"})
|
|
316
|
+
|
|
317
|
+
def _read_json(self) -> Optional[Dict]:
|
|
318
|
+
"""Read and parse JSON from request body."""
|
|
319
|
+
try:
|
|
320
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
321
|
+
body = self.rfile.read(content_length)
|
|
322
|
+
return json.loads(body)
|
|
323
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
324
|
+
self._respond(400, {"error": f"Invalid JSON: {e}"})
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
def _respond(self, status: int, data: dict):
|
|
328
|
+
"""Send a JSON response."""
|
|
329
|
+
self.send_response(status)
|
|
330
|
+
self.send_header("Content-Type", "application/json")
|
|
331
|
+
self.send_header("Access-Control-Allow-Origin", "http://127.0.0.1")
|
|
332
|
+
self.end_headers()
|
|
333
|
+
self.wfile.write(json.dumps(data).encode())
|
|
334
|
+
|
|
335
|
+
def log_message(self, format, *args):
|
|
336
|
+
"""Custom log format for the scanning server."""
|
|
337
|
+
sys.stderr.write(
|
|
338
|
+
f"[Tweek Scanner] {self.address_string()} - {format % args}\n"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def run_server(port: int = DEFAULT_PORT):
|
|
343
|
+
"""Start the OpenClaw scanning server."""
|
|
344
|
+
# Load .env if available
|
|
345
|
+
try:
|
|
346
|
+
from tweek.utils.env import load_env
|
|
347
|
+
load_env()
|
|
348
|
+
except ImportError:
|
|
349
|
+
# Manual fallback
|
|
350
|
+
env_path = Path.home() / ".tweek" / ".env"
|
|
351
|
+
if not env_path.exists():
|
|
352
|
+
env_path = Path(__file__).parent.parent.parent / ".env"
|
|
353
|
+
if env_path.exists():
|
|
354
|
+
for line in env_path.read_text().splitlines():
|
|
355
|
+
line = line.strip()
|
|
356
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
357
|
+
continue
|
|
358
|
+
key, value = line.split("=", 1)
|
|
359
|
+
os.environ.setdefault(key.strip(), value.strip().strip("'\""))
|
|
360
|
+
|
|
361
|
+
# Bind to loopback only — never expose to the network
|
|
362
|
+
server = HTTPServer(("127.0.0.1", port), OpenClawScanHandler)
|
|
363
|
+
print(f"Tweek OpenClaw Scanning Server running on http://127.0.0.1:{port}")
|
|
364
|
+
print(f" POST /scan — Scan a skill directory (7-layer)")
|
|
365
|
+
print(f" POST /screen — Screen a tool call")
|
|
366
|
+
print(f" POST /output — Scan tool output")
|
|
367
|
+
print(f" POST /fingerprint/check — Check skill fingerprint")
|
|
368
|
+
print(f" POST /fingerprint/register — Register approved skill")
|
|
369
|
+
print(f" GET /health — Health check")
|
|
370
|
+
print(f" GET /report/<skill> — Retrieve scan report")
|
|
371
|
+
print(f"Press Ctrl+C to stop.")
|
|
372
|
+
try:
|
|
373
|
+
server.serve_forever()
|
|
374
|
+
except KeyboardInterrupt:
|
|
375
|
+
print("\nShutting down.")
|
|
376
|
+
server.shutdown()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
import argparse
|
|
381
|
+
parser = argparse.ArgumentParser(description="Tweek OpenClaw Scanning Server")
|
|
382
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT,
|
|
383
|
+
help=f"Port to listen on (default: {DEFAULT_PORT})")
|
|
384
|
+
args = parser.parse_args()
|
|
385
|
+
run_server(args.port)
|
tweek/licensing.py
CHANGED
|
@@ -29,9 +29,10 @@ from typing import Callable, Optional, List
|
|
|
29
29
|
# Thread lock for singleton pattern
|
|
30
30
|
_license_lock = threading.Lock()
|
|
31
31
|
|
|
32
|
-
# License key secret
|
|
33
|
-
#
|
|
34
|
-
|
|
32
|
+
# License key secret — no hardcoded fallback for security.
|
|
33
|
+
# Set TWEEK_LICENSE_SECRET env var. Without it, license validation
|
|
34
|
+
# will fail (free tier still works, only paid license verification affected).
|
|
35
|
+
LICENSE_SECRET = os.environ.get("TWEEK_LICENSE_SECRET", "")
|
|
35
36
|
|
|
36
37
|
LICENSE_FILE = Path.home() / ".tweek" / "license.key"
|
|
37
38
|
|
|
@@ -73,7 +74,7 @@ class LicenseInfo:
|
|
|
73
74
|
# Only compliance and team management features require a license.
|
|
74
75
|
TIER_FEATURES = {
|
|
75
76
|
Tier.FREE: [
|
|
76
|
-
"pattern_matching", # All
|
|
77
|
+
"pattern_matching", # All 215 patterns included free
|
|
77
78
|
"basic_logging",
|
|
78
79
|
"vault_storage",
|
|
79
80
|
"cli_commands",
|
|
@@ -162,12 +163,18 @@ class License:
|
|
|
162
163
|
|
|
163
164
|
payload_b64, signature = key.rsplit(".", 1)
|
|
164
165
|
|
|
165
|
-
#
|
|
166
|
-
|
|
166
|
+
# Fail closed: reject all license validation when secret is not configured
|
|
167
|
+
if not LICENSE_SECRET:
|
|
168
|
+
logger.debug("LICENSE_SECRET not configured — cannot validate licenses")
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
# Verify signature (accept both full and legacy truncated signatures)
|
|
172
|
+
full_sig = hmac.new(
|
|
167
173
|
LICENSE_SECRET.encode(),
|
|
168
174
|
payload_b64.encode(),
|
|
169
175
|
hashlib.sha256
|
|
170
|
-
).hexdigest()
|
|
176
|
+
).hexdigest()
|
|
177
|
+
expected_sig = full_sig if len(signature) > 32 else full_sig[:32]
|
|
171
178
|
|
|
172
179
|
if not hmac.compare_digest(signature, expected_sig):
|
|
173
180
|
return None
|
|
@@ -349,50 +356,3 @@ def require_feature(feature: str) -> Callable:
|
|
|
349
356
|
return func(*args, **kwargs)
|
|
350
357
|
return wrapper
|
|
351
358
|
return decorator
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
# ============================================================
|
|
355
|
-
# License Key Generation (for internal/admin use)
|
|
356
|
-
# ============================================================
|
|
357
|
-
|
|
358
|
-
def generate_license_key(
|
|
359
|
-
tier: Tier,
|
|
360
|
-
email: str,
|
|
361
|
-
expires_at: Optional[int] = None,
|
|
362
|
-
features: Optional[List[str]] = None,
|
|
363
|
-
) -> str:
|
|
364
|
-
"""
|
|
365
|
-
Generate a license key.
|
|
366
|
-
|
|
367
|
-
This is an admin function - in production, this would be
|
|
368
|
-
on a separate license server, not in the client code.
|
|
369
|
-
|
|
370
|
-
Args:
|
|
371
|
-
tier: License tier
|
|
372
|
-
email: Customer email
|
|
373
|
-
expires_at: Expiration timestamp (None = never)
|
|
374
|
-
features: Additional feature flags
|
|
375
|
-
|
|
376
|
-
Returns:
|
|
377
|
-
License key string
|
|
378
|
-
"""
|
|
379
|
-
import base64
|
|
380
|
-
|
|
381
|
-
payload = {
|
|
382
|
-
"tier": tier.value,
|
|
383
|
-
"email": email,
|
|
384
|
-
"issued_at": int(time.time()),
|
|
385
|
-
"expires_at": expires_at,
|
|
386
|
-
"features": features or [],
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
payload_json = json.dumps(payload, separators=(",", ":"))
|
|
390
|
-
payload_b64 = base64.b64encode(payload_json.encode()).decode()
|
|
391
|
-
|
|
392
|
-
signature = hmac.new(
|
|
393
|
-
LICENSE_SECRET.encode(),
|
|
394
|
-
payload_b64.encode(),
|
|
395
|
-
hashlib.sha256
|
|
396
|
-
).hexdigest()[:32]
|
|
397
|
-
|
|
398
|
-
return f"{payload_b64}.{signature}"
|
tweek/logging/bundle.py
CHANGED
|
@@ -170,7 +170,7 @@ class BundleCollector:
|
|
|
170
170
|
def collect_system_info(self) -> Dict[str, Any]:
|
|
171
171
|
"""Collect platform and version information."""
|
|
172
172
|
info = {
|
|
173
|
-
"timestamp": datetime.
|
|
173
|
+
"timestamp": datetime.now(tz=None).isoformat() + "Z",
|
|
174
174
|
"platform": {
|
|
175
175
|
"system": platform.system(),
|
|
176
176
|
"release": platform.release(),
|
|
@@ -284,7 +284,7 @@ class BundleCollector:
|
|
|
284
284
|
# Manifest
|
|
285
285
|
manifest = {
|
|
286
286
|
"bundle_version": "1.0",
|
|
287
|
-
"created_at": datetime.
|
|
287
|
+
"created_at": datetime.now(tz=None).isoformat() + "Z",
|
|
288
288
|
"redacted": self.redact,
|
|
289
289
|
"days_filter": self.days,
|
|
290
290
|
"files": self._collected,
|
tweek/logging/security_log.py
CHANGED
|
@@ -7,13 +7,15 @@ Logs all tool/skill invocations, screening decisions, and user responses.
|
|
|
7
7
|
|
|
8
8
|
Database location: ~/.tweek/security.db
|
|
9
9
|
|
|
10
|
-
Includes log redaction for sensitive data based on
|
|
10
|
+
Includes log redaction for sensitive data based on OpenClaw's security hardening.
|
|
11
11
|
"""
|
|
12
|
+
from __future__ import annotations
|
|
12
13
|
|
|
13
14
|
import json
|
|
14
15
|
import os
|
|
15
16
|
import re
|
|
16
17
|
import sqlite3
|
|
18
|
+
import threading
|
|
17
19
|
from contextlib import contextmanager
|
|
18
20
|
from dataclasses import dataclass, asdict
|
|
19
21
|
from datetime import datetime
|
|
@@ -26,7 +28,7 @@ class LogRedactor:
|
|
|
26
28
|
"""
|
|
27
29
|
Redacts sensitive information from log data.
|
|
28
30
|
|
|
29
|
-
Based on
|
|
31
|
+
Based on OpenClaw's log-redaction security feature.
|
|
30
32
|
Ensures secrets, tokens, and credentials are never written to logs.
|
|
31
33
|
"""
|
|
32
34
|
|
|
@@ -164,8 +166,8 @@ class LogRedactor:
|
|
|
164
166
|
for sensitive in self.SENSITIVE_KEYS
|
|
165
167
|
)
|
|
166
168
|
|
|
167
|
-
if is_sensitive
|
|
168
|
-
# Redact
|
|
169
|
+
if is_sensitive:
|
|
170
|
+
# Redact entire value for sensitive keys, regardless of type
|
|
169
171
|
result[key] = "***REDACTED***"
|
|
170
172
|
elif isinstance(value, str):
|
|
171
173
|
# Apply pattern-based redaction
|
|
@@ -222,13 +224,16 @@ class LogRedactor:
|
|
|
222
224
|
|
|
223
225
|
# Singleton redactor instance
|
|
224
226
|
_redactor: Optional[LogRedactor] = None
|
|
227
|
+
_redactor_lock = threading.Lock()
|
|
225
228
|
|
|
226
229
|
|
|
227
230
|
def get_redactor(enabled: bool = True) -> LogRedactor:
|
|
228
231
|
"""Get the singleton log redactor instance."""
|
|
229
232
|
global _redactor
|
|
230
233
|
if _redactor is None:
|
|
231
|
-
|
|
234
|
+
with _redactor_lock:
|
|
235
|
+
if _redactor is None:
|
|
236
|
+
_redactor = LogRedactor(enabled=enabled)
|
|
232
237
|
return _redactor
|
|
233
238
|
|
|
234
239
|
|
|
@@ -271,6 +276,23 @@ class EventType(Enum):
|
|
|
271
276
|
# Proxy events
|
|
272
277
|
PROXY_EVENT = "proxy_event" # HTTP proxy request screening
|
|
273
278
|
|
|
279
|
+
# Skill isolation chamber events
|
|
280
|
+
SKILL_CHAMBER_INTAKE = "skill_chamber_intake" # Skill placed in chamber
|
|
281
|
+
SKILL_SCAN_COMPLETE = "skill_scan_complete" # Scan pipeline finished
|
|
282
|
+
SKILL_APPROVED = "skill_approved" # Skill approved and installed
|
|
283
|
+
SKILL_JAILED = "skill_jailed" # Skill quarantined to jail
|
|
284
|
+
SKILL_MANUAL_REVIEW = "skill_manual_review" # Skill requires human review
|
|
285
|
+
SKILL_INSTALL_BLOCKED = "skill_install_blocked" # Direct install attempt blocked
|
|
286
|
+
|
|
287
|
+
# Project sandbox events
|
|
288
|
+
SANDBOX_PROJECT_INIT = "sandbox_project_init" # Project .tweek/ created
|
|
289
|
+
SANDBOX_LAYER_CHANGE = "sandbox_layer_change" # Project isolation layer changed
|
|
290
|
+
SANDBOX_MERGE_VIOLATION = "sandbox_merge_violation" # Project tried to weaken global
|
|
291
|
+
|
|
292
|
+
# Enforcement events
|
|
293
|
+
BREAK_GLASS = "break_glass" # Emergency override of hard block
|
|
294
|
+
FALSE_POSITIVE_REPORT = "false_positive_report" # User reported false positive
|
|
295
|
+
|
|
274
296
|
# System events
|
|
275
297
|
HEALTH_CHECK = "health_check" # Diagnostic check results
|
|
276
298
|
STARTUP = "startup" # System initialization
|
|
@@ -319,6 +341,12 @@ class SecurityLogger:
|
|
|
319
341
|
def _ensure_db_exists(self):
|
|
320
342
|
"""Create database and tables if they don't exist."""
|
|
321
343
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
# Harden directory permissions - security logs should be private
|
|
345
|
+
try:
|
|
346
|
+
import os
|
|
347
|
+
os.chmod(self.db_path.parent, 0o700)
|
|
348
|
+
except OSError:
|
|
349
|
+
pass
|
|
322
350
|
|
|
323
351
|
with self._get_connection() as conn:
|
|
324
352
|
# Create table first (without views that reference new columns)
|
|
@@ -421,14 +449,26 @@ class SecurityLogger:
|
|
|
421
449
|
|
|
422
450
|
@contextmanager
|
|
423
451
|
def _get_connection(self):
|
|
424
|
-
"""Get a database connection
|
|
425
|
-
|
|
426
|
-
|
|
452
|
+
"""Get a database connection, reusing persistent connection when possible."""
|
|
453
|
+
if not hasattr(self, '_conn') or self._conn is None:
|
|
454
|
+
self._conn = sqlite3.connect(
|
|
455
|
+
str(self.db_path),
|
|
456
|
+
timeout=5, # Wait up to 5s for locks (matches approval.py)
|
|
457
|
+
)
|
|
458
|
+
self._conn.row_factory = sqlite3.Row
|
|
459
|
+
# Enable WAL mode for concurrent access from multiple hook processes
|
|
460
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
427
461
|
try:
|
|
428
|
-
yield
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
462
|
+
yield self._conn
|
|
463
|
+
self._conn.commit()
|
|
464
|
+
except Exception:
|
|
465
|
+
# On error, close and reset so next call gets a fresh connection
|
|
466
|
+
try:
|
|
467
|
+
self._conn.close()
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
self._conn = None
|
|
471
|
+
raise
|
|
432
472
|
|
|
433
473
|
def log(self, event: SecurityEvent) -> int:
|
|
434
474
|
"""Log a security event with automatic redaction of sensitive data.
|
|
@@ -728,6 +768,7 @@ class SecurityLogger:
|
|
|
728
768
|
|
|
729
769
|
# Singleton instance for easy access
|
|
730
770
|
_logger: Optional[SecurityLogger] = None
|
|
771
|
+
_logger_lock = threading.Lock()
|
|
731
772
|
|
|
732
773
|
|
|
733
774
|
def get_logger(redact_logs: bool = True) -> SecurityLogger:
|
|
@@ -741,5 +782,7 @@ def get_logger(redact_logs: bool = True) -> SecurityLogger:
|
|
|
741
782
|
"""
|
|
742
783
|
global _logger
|
|
743
784
|
if _logger is None:
|
|
744
|
-
|
|
785
|
+
with _logger_lock:
|
|
786
|
+
if _logger is None:
|
|
787
|
+
_logger = SecurityLogger(redact_logs=redact_logs)
|
|
745
788
|
return _logger
|