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,371 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek Local Model Registry
4
+
5
+ Manages the catalog of local security models, downloads from HuggingFace,
6
+ and handles model directory lifecycle.
7
+
8
+ Models are stored in ~/.tweek/models/<model-name>/ with:
9
+ - model.onnx — ONNX model file
10
+ - tokenizer.json — Tokenizer configuration
11
+ - model_meta.yaml — Metadata (catalog info + download timestamps)
12
+ """
13
+
14
+ import hashlib
15
+ import shutil
16
+ import urllib.request
17
+ import urllib.error
18
+ import os
19
+ import ssl
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Callable, Dict, List, Optional
24
+
25
+ import yaml
26
+
27
+
28
+ @dataclass
29
+ class ModelDefinition:
30
+ """Definition of a model in the catalog."""
31
+
32
+ name: str
33
+ display_name: str
34
+ hf_repo: str
35
+ description: str
36
+ num_labels: int
37
+ label_map: Dict[int, str]
38
+ risk_map: Dict[str, str] # label -> risk level (safe/suspicious/dangerous)
39
+ max_length: int = 512
40
+ license: str = "unknown"
41
+ size_mb: float = 0.0 # approximate download size
42
+ files: List[str] = field(default_factory=list)
43
+ hf_subfolder: str = "" # subfolder in the HF repo (e.g., "onnx")
44
+ requires_auth: bool = False
45
+ default: bool = False
46
+
47
+ # Confidence thresholds for escalation
48
+ escalate_min_confidence: float = 0.1
49
+ escalate_max_confidence: float = 0.9
50
+
51
+
52
+ # ============================================================================
53
+ # MODEL CATALOG
54
+ # ============================================================================
55
+
56
+ MODEL_CATALOG: Dict[str, ModelDefinition] = {
57
+ "deberta-v3-injection": ModelDefinition(
58
+ name="deberta-v3-injection",
59
+ display_name="ProtectAI DeBERTa v3 Prompt Injection v2",
60
+ hf_repo="protectai/deberta-v3-base-prompt-injection-v2",
61
+ description=(
62
+ "Binary prompt injection classifier based on DeBERTa-v3-base. "
63
+ "Detects prompt injection attacks in English text. "
64
+ "Apache 2.0 license, no authentication required."
65
+ ),
66
+ num_labels=2,
67
+ label_map={0: "safe", 1: "injection"},
68
+ risk_map={
69
+ "safe": "safe",
70
+ "injection": "dangerous",
71
+ },
72
+ max_length=512,
73
+ license="Apache-2.0",
74
+ size_mb=750.0,
75
+ files=["model.onnx", "tokenizer.json"],
76
+ hf_subfolder="onnx",
77
+ requires_auth=False,
78
+ default=True,
79
+ escalate_min_confidence=0.1,
80
+ escalate_max_confidence=0.9,
81
+ ),
82
+ }
83
+
84
+ DEFAULT_MODEL = "deberta-v3-injection"
85
+
86
+
87
+ # ============================================================================
88
+ # DIRECTORY MANAGEMENT
89
+ # ============================================================================
90
+
91
+
92
+ def get_models_dir() -> Path:
93
+ """Get the models directory (~/.tweek/models/)."""
94
+ models_dir = Path.home() / ".tweek" / "models"
95
+ return models_dir
96
+
97
+
98
+ def get_model_dir(name: str) -> Path:
99
+ """Get the directory for a specific model."""
100
+ return get_models_dir() / name
101
+
102
+
103
+ def get_default_model_name() -> str:
104
+ """Get the configured default model name.
105
+
106
+ Checks user config first, falls back to catalog default.
107
+ """
108
+ config_path = Path.home() / ".tweek" / "config.yaml"
109
+ if config_path.exists():
110
+ try:
111
+ with open(config_path) as f:
112
+ config = yaml.safe_load(f) or {}
113
+ local_model_cfg = config.get("local_model", {})
114
+ model = local_model_cfg.get("model", "auto")
115
+ if model != "auto" and model in MODEL_CATALOG:
116
+ return model
117
+ except Exception:
118
+ pass
119
+
120
+ return DEFAULT_MODEL
121
+
122
+
123
+ def is_model_installed(name: str) -> bool:
124
+ """Check if a model is installed with all required files."""
125
+ if name not in MODEL_CATALOG:
126
+ return False
127
+
128
+ model_dir = get_model_dir(name)
129
+ if not model_dir.exists():
130
+ return False
131
+
132
+ definition = MODEL_CATALOG[name]
133
+ for filename in definition.files:
134
+ if not (model_dir / filename).exists():
135
+ return False
136
+
137
+ return True
138
+
139
+
140
+ def list_installed_models() -> List[str]:
141
+ """List all installed model names."""
142
+ models_dir = get_models_dir()
143
+ if not models_dir.exists():
144
+ return []
145
+
146
+ installed = []
147
+ for name in MODEL_CATALOG:
148
+ if is_model_installed(name):
149
+ installed.append(name)
150
+
151
+ return installed
152
+
153
+
154
+ def get_model_definition(name: str) -> Optional[ModelDefinition]:
155
+ """Get the catalog definition for a model."""
156
+ return MODEL_CATALOG.get(name)
157
+
158
+
159
+ # ============================================================================
160
+ # MODEL DOWNLOAD
161
+ # ============================================================================
162
+
163
+
164
+ class ModelDownloadError(Exception):
165
+ """Error during model download."""
166
+
167
+ pass
168
+
169
+
170
+ def _build_hf_url(repo: str, filename: str, subfolder: str = "") -> str:
171
+ """Build a HuggingFace CDN download URL."""
172
+ if subfolder:
173
+ return f"https://huggingface.co/{repo}/resolve/main/{subfolder}/{filename}"
174
+ return f"https://huggingface.co/{repo}/resolve/main/{filename}"
175
+
176
+
177
+ def _get_hf_headers() -> Dict[str, str]:
178
+ """Get HTTP headers for HuggingFace requests."""
179
+ headers = {
180
+ "User-Agent": "tweek/0.1.0",
181
+ }
182
+
183
+ # Support HF_TOKEN for gated models (like Prompt Guard)
184
+ hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
185
+ if hf_token:
186
+ headers["Authorization"] = f"Bearer {hf_token}"
187
+
188
+ return headers
189
+
190
+
191
+ def download_model(
192
+ name: str,
193
+ progress_callback: Optional[Callable[[str, int, int], None]] = None,
194
+ force: bool = False,
195
+ ) -> Path:
196
+ """Download a model from HuggingFace.
197
+
198
+ Args:
199
+ name: Model name from the catalog.
200
+ progress_callback: Optional callback(filename, bytes_downloaded, total_bytes).
201
+ force: If True, re-download even if already installed.
202
+
203
+ Returns:
204
+ Path to the model directory.
205
+
206
+ Raises:
207
+ ModelDownloadError: If the model is not in the catalog or download fails.
208
+ """
209
+ definition = MODEL_CATALOG.get(name)
210
+ if definition is None:
211
+ available = ", ".join(MODEL_CATALOG.keys())
212
+ raise ModelDownloadError(
213
+ f"Unknown model '{name}'. Available models: {available}"
214
+ )
215
+
216
+ model_dir = get_model_dir(name)
217
+
218
+ if is_model_installed(name) and not force:
219
+ return model_dir
220
+
221
+ # Create directory
222
+ model_dir.mkdir(parents=True, exist_ok=True)
223
+
224
+ headers = _get_hf_headers()
225
+
226
+ if definition.requires_auth and "Authorization" not in headers:
227
+ raise ModelDownloadError(
228
+ f"Model '{name}' requires HuggingFace authentication. "
229
+ f"Set HF_TOKEN environment variable with a token that has "
230
+ f"access to {definition.hf_repo}. "
231
+ f"Get a token at https://huggingface.co/settings/tokens"
232
+ )
233
+
234
+ # Create SSL context
235
+ ssl_context = ssl.create_default_context()
236
+
237
+ # Download each file
238
+ for filename in definition.files:
239
+ url = _build_hf_url(definition.hf_repo, filename, definition.hf_subfolder)
240
+ dest = model_dir / filename
241
+ tmp_dest = model_dir / f".{filename}.tmp"
242
+
243
+ try:
244
+ request = urllib.request.Request(url, headers=headers)
245
+ response = urllib.request.urlopen(request, context=ssl_context)
246
+
247
+ total = int(response.headers.get("Content-Length", 0))
248
+ downloaded = 0
249
+ chunk_size = 1024 * 1024 # 1MB chunks
250
+
251
+ with open(tmp_dest, "wb") as f:
252
+ while True:
253
+ chunk = response.read(chunk_size)
254
+ if not chunk:
255
+ break
256
+ f.write(chunk)
257
+ downloaded += len(chunk)
258
+ if progress_callback:
259
+ progress_callback(filename, downloaded, total)
260
+
261
+ # Atomic rename
262
+ tmp_dest.rename(dest)
263
+
264
+ except urllib.error.HTTPError as e:
265
+ tmp_dest.unlink(missing_ok=True)
266
+ if e.code == 401:
267
+ raise ModelDownloadError(
268
+ f"Authentication failed for '{name}'. "
269
+ f"Check your HF_TOKEN has access to {definition.hf_repo}. "
270
+ f"You may need to accept the license at "
271
+ f"https://huggingface.co/{definition.hf_repo}"
272
+ ) from e
273
+ elif e.code == 404:
274
+ raise ModelDownloadError(
275
+ f"File '{filename}' not found in {definition.hf_repo}. "
276
+ f"The model may have been moved or renamed."
277
+ ) from e
278
+ else:
279
+ raise ModelDownloadError(
280
+ f"HTTP {e.code} downloading {filename}: {e.reason}"
281
+ ) from e
282
+ except urllib.error.URLError as e:
283
+ tmp_dest.unlink(missing_ok=True)
284
+ raise ModelDownloadError(
285
+ f"Network error downloading {filename}: {e.reason}"
286
+ ) from e
287
+ except Exception as e:
288
+ tmp_dest.unlink(missing_ok=True)
289
+ raise ModelDownloadError(
290
+ f"Failed to download {filename}: {e}"
291
+ ) from e
292
+
293
+ # Write metadata
294
+ meta = {
295
+ "name": definition.name,
296
+ "display_name": definition.display_name,
297
+ "hf_repo": definition.hf_repo,
298
+ "num_labels": definition.num_labels,
299
+ "label_map": definition.label_map,
300
+ "risk_map": definition.risk_map,
301
+ "max_length": definition.max_length,
302
+ "license": definition.license,
303
+ "downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
304
+ "files": definition.files,
305
+ }
306
+
307
+ with open(model_dir / "model_meta.yaml", "w") as f:
308
+ yaml.dump(meta, f, default_flow_style=False, sort_keys=False)
309
+
310
+ return model_dir
311
+
312
+
313
+ def remove_model(name: str) -> bool:
314
+ """Remove a downloaded model.
315
+
316
+ Args:
317
+ name: Model name.
318
+
319
+ Returns:
320
+ True if the model was removed, False if not found.
321
+ """
322
+ model_dir = get_model_dir(name)
323
+ if model_dir.exists():
324
+ shutil.rmtree(model_dir)
325
+ return True
326
+ return False
327
+
328
+
329
+ def verify_model(name: str) -> Dict[str, bool]:
330
+ """Verify a model installation.
331
+
332
+ Args:
333
+ name: Model name.
334
+
335
+ Returns:
336
+ Dict mapping filename to exists status.
337
+ """
338
+ definition = MODEL_CATALOG.get(name)
339
+ if definition is None:
340
+ return {}
341
+
342
+ model_dir = get_model_dir(name)
343
+ status = {}
344
+
345
+ for filename in definition.files:
346
+ status[filename] = (model_dir / filename).exists()
347
+
348
+ status["model_meta.yaml"] = (model_dir / "model_meta.yaml").exists()
349
+
350
+ return status
351
+
352
+
353
+ def get_model_size(name: str) -> Optional[int]:
354
+ """Get the total size of an installed model in bytes.
355
+
356
+ Args:
357
+ name: Model name.
358
+
359
+ Returns:
360
+ Total size in bytes, or None if not installed.
361
+ """
362
+ model_dir = get_model_dir(name)
363
+ if not model_dir.exists():
364
+ return None
365
+
366
+ total = 0
367
+ for path in model_dir.iterdir():
368
+ if path.is_file():
369
+ total += path.stat().st_size
370
+
371
+ return total
@@ -120,7 +120,7 @@ class CircuitBreaker:
120
120
  - OPEN: Too many failures, requests blocked, waiting for timeout
121
121
  - HALF_OPEN: Testing recovery, limited requests allowed
122
122
 
123
- Based on moltbot's circuit breaker implementation for resilience.
123
+ Based on OpenClaw's circuit breaker implementation for resilience.
124
124
  """
125
125
 
126
126
  def __init__(self, config: Optional[CircuitBreakerConfig] = None):
@@ -459,8 +459,12 @@ class RateLimiter:
459
459
  RateLimitResult with allowed status and any violations
460
460
  """
461
461
  if not session_id:
462
- # No session tracking - allow but log
463
- return RateLimitResult(allowed=True, message="No session ID for rate limiting")
462
+ # No session ID - generate unique one per process invocation
463
+ import os as _os
464
+ import uuid as _uuid
465
+ session_id = hashlib.sha256(
466
+ f"tweek-{_os.getpid()}-{_os.getcwd()}-{_uuid.getnode()}".encode()
467
+ ).hexdigest()[:16]
464
468
 
465
469
  # Check circuit breaker first
466
470
  circuit_key = f"session:{session_id}"
@@ -531,10 +535,11 @@ class RateLimiter:
531
535
  details["velocity_ratio"] = round(current / baseline, 2)
532
536
 
533
537
  except Exception as e:
534
- # Database error - fail open but log
538
+ # Database error - fail closed for safety
535
539
  return RateLimitResult(
536
- allowed=True,
537
- message=f"Rate limit check failed: {e}",
540
+ allowed=False,
541
+ violations=[RateLimitViolation.BURST],
542
+ message=f"Rate limit check failed (blocking for safety): {e}",
538
543
  details={"error": str(e)}
539
544
  )
540
545
 
@@ -5,7 +5,7 @@ Tweek Secret Scanner
5
5
  Scans configuration files for hardcoded secrets and credentials.
6
6
  Enforces environment-variable-only secrets policy.
7
7
 
8
- Based on moltbot's secret-guard security hardening initiative.
8
+ Based on OpenClaw's secret-guard security hardening initiative.
9
9
  """
10
10
 
11
11
  import os
@@ -150,6 +150,68 @@ class SecretScanner:
150
150
  # Slack tokens
151
151
  (r'xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*', SecretType.TOKEN, "critical"),
152
152
 
153
+ # Stripe keys (live and test)
154
+ (r'sk_live_[A-Za-z0-9]{24,}', SecretType.API_KEY, "critical"),
155
+ (r'pk_live_[A-Za-z0-9]{24,}', SecretType.API_KEY, "high"),
156
+ (r'rk_live_[A-Za-z0-9]{24,}', SecretType.API_KEY, "critical"),
157
+
158
+ # SendGrid API key
159
+ (r'SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}', SecretType.API_KEY, "critical"),
160
+
161
+ # Anthropic API key
162
+ (r'sk-ant-[A-Za-z0-9_-]{36,}', SecretType.API_KEY, "critical"),
163
+
164
+ # OpenAI API key
165
+ (r'sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}', SecretType.API_KEY, "critical"),
166
+
167
+ # npm token
168
+ (r'npm_[A-Za-z0-9]{36,}', SecretType.TOKEN, "critical"),
169
+
170
+ # PyPI token
171
+ (r'pypi-[A-Za-z0-9_-]{50,}', SecretType.TOKEN, "critical"),
172
+
173
+ # Azure subscription key / connection string
174
+ (r'(?i)DefaultEndpointProtocol=https;AccountName=[^;]+;AccountKey=[A-Za-z0-9+/=]{60,}', SecretType.CONNECTION_STRING, "critical"),
175
+
176
+ # Google API key
177
+ (r'AIza[A-Za-z0-9_-]{35}', SecretType.API_KEY, "high"),
178
+
179
+ # Google OAuth client secret
180
+ (r'GOCSPX-[A-Za-z0-9_-]{28}', SecretType.OAUTH_SECRET, "critical"),
181
+
182
+ # Twilio API key
183
+ (r'SK[a-f0-9]{32}', SecretType.API_KEY, "high"),
184
+
185
+ # Mailchimp API key
186
+ (r'[a-f0-9]{32}-us[0-9]{1,2}', SecretType.API_KEY, "high"),
187
+
188
+ # Discord bot token
189
+ (r'[MN][A-Za-z0-9]{23,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}', SecretType.TOKEN, "critical"),
190
+
191
+ # Heroku API key
192
+ (r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}', SecretType.API_KEY, "medium"),
193
+
194
+ # Vercel token
195
+ (r'vercel_[A-Za-z0-9_-]{24,}', SecretType.TOKEN, "critical"),
196
+
197
+ # Supabase key
198
+ (r'sbp_[A-Za-z0-9]{40,}', SecretType.API_KEY, "critical"),
199
+
200
+ # Databricks token
201
+ (r'dapi[a-f0-9]{32}', SecretType.TOKEN, "critical"),
202
+
203
+ # Hashicorp Vault token
204
+ (r'hvs\.[A-Za-z0-9_-]{24,}', SecretType.TOKEN, "critical"),
205
+
206
+ # GitLab tokens (personal, pipeline, runner)
207
+ (r'glpat-[A-Za-z0-9_-]{20,}', SecretType.TOKEN, "critical"),
208
+
209
+ # Figma token
210
+ (r'figd_[A-Za-z0-9_-]{40,}', SecretType.TOKEN, "high"),
211
+
212
+ # Linear API key
213
+ (r'lin_api_[A-Za-z0-9]{40,}', SecretType.API_KEY, "high"),
214
+
153
215
  # Generic high-entropy strings that look like secrets
154
216
  (r'[\'"][A-Za-z0-9+/]{40,}={0,2}[\'"]', SecretType.API_KEY, "medium"),
155
217
  ]
@@ -347,15 +409,19 @@ class SecretScanner:
347
409
  return False
348
410
 
349
411
  def _redact_value(self, value: Any) -> str:
350
- """Redact a secret value for safe display."""
412
+ """Redact a secret value for safe display.
413
+
414
+ Only shows first 4 characters (provider prefix) — never reveals suffix
415
+ to minimize information leakage.
416
+ """
351
417
  if not isinstance(value, str):
352
418
  return "<redacted>"
353
419
 
354
420
  if len(value) <= 8:
355
421
  return "*" * len(value)
356
422
 
357
- # Show first 4 and last 4 chars
358
- return f"{value[:4]}{'*' * (len(value) - 8)}{value[-4:]}"
423
+ # Show first 4 chars only (identifies provider without revealing key material)
424
+ return f"{value[:4]}{'*' * (len(value) - 4)}"
359
425
 
360
426
  def _redact_line(self, line: str) -> str:
361
427
  """Redact sensitive parts of a line."""
@@ -475,6 +475,28 @@ class SessionAnalyzer:
475
475
  anomalies.append(AnomalyType.CAPABILITY_AGGREGATION)
476
476
  all_details["capability_aggregation"] = aggregation_details
477
477
 
478
+ # Memory: read cross-session workflow baselines
479
+ try:
480
+ from tweek.memory.queries import memory_get_workflow_baseline
481
+ from tweek.memory.store import hash_project
482
+ # Use session_id as a proxy for project context
483
+ baseline = memory_get_workflow_baseline(session_id)
484
+ if baseline:
485
+ all_details["memory_baseline"] = baseline
486
+ # Flag if current denial ratio significantly exceeds baseline
487
+ current_denial_ratio = len(
488
+ [e for e in events if e.get("decision") in ("block", "ask")]
489
+ ) / max(len(events), 1)
490
+ baseline_ratio = baseline.get("denial_ratio", 0)
491
+ if (
492
+ current_denial_ratio > baseline_ratio * 2
493
+ and current_denial_ratio > 0.2
494
+ and baseline.get("total_invocations", 0) >= 20
495
+ ):
496
+ all_details["memory_baseline_exceeded"] = True
497
+ except Exception:
498
+ pass # Memory is best-effort
499
+
478
500
  # Calculate risk score
479
501
  risk_score = self._calculate_risk_score(anomalies, events, all_details)
480
502
 
@@ -523,10 +545,12 @@ class SessionAnalyzer:
523
545
  )
524
546
 
525
547
  except Exception as e:
548
+ # Fail closed: analysis failure is itself suspicious
526
549
  return SessionAnalysis(
527
550
  session_id=session_id,
528
- risk_score=0.0,
529
- details={"error": str(e)}
551
+ risk_score=0.5,
552
+ anomalies=[AnomalyType.SUSPICIOUS_PATTERN],
553
+ details={"error": str(e), "fail_closed": True}
530
554
  )
531
555
 
532
556
  def _update_session_profile(