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,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 - in production, this would be more secure
33
- # This is used to validate license keys were issued by Tweek
34
- LICENSE_SECRET = os.environ.get("TWEEK_LICENSE_SECRET", "tweek-2025-license-secret")
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 116 patterns included free
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
- # Verify signature
166
- expected_sig = hmac.new(
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()[:32]
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.utcnow().isoformat() + "Z",
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.utcnow().isoformat() + "Z",
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,
@@ -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 moltbot's security hardening.
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 moltbot's log-redaction security feature.
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 and isinstance(value, str):
168
- # Redact the entire value
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
- _redactor = LogRedactor(enabled=enabled)
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 with proper cleanup."""
425
- conn = sqlite3.connect(str(self.db_path))
426
- conn.row_factory = sqlite3.Row
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 conn
429
- conn.commit()
430
- finally:
431
- conn.close()
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
- _logger = SecurityLogger(redact_logs=redact_logs)
785
+ with _logger_lock:
786
+ if _logger is None:
787
+ _logger = SecurityLogger(redact_logs=redact_logs)
745
788
  return _logger