tweek 0.3.1__py3-none-any.whl → 0.4.1__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/audit.py +2 -2
- tweek/cli.py +78 -6605
- tweek/cli_config.py +643 -0
- tweek/cli_configure.py +413 -0
- tweek/cli_core.py +718 -0
- tweek/cli_dry_run.py +390 -0
- tweek/cli_helpers.py +316 -0
- tweek/cli_install.py +1666 -0
- tweek/cli_logs.py +301 -0
- tweek/cli_mcp.py +148 -0
- tweek/cli_memory.py +343 -0
- tweek/cli_plugins.py +748 -0
- tweek/cli_protect.py +564 -0
- tweek/cli_proxy.py +405 -0
- tweek/cli_security.py +236 -0
- tweek/cli_skills.py +289 -0
- tweek/cli_uninstall.py +551 -0
- tweek/cli_vault.py +313 -0
- tweek/config/allowed_dirs.yaml +16 -17
- tweek/config/families.yaml +4 -1
- tweek/config/manager.py +17 -0
- tweek/config/patterns.yaml +29 -5
- tweek/config/templates/config.yaml.template +212 -0
- tweek/config/templates/env.template +45 -0
- tweek/config/templates/overrides.yaml.template +121 -0
- tweek/config/templates/tweek.yaml.template +20 -0
- tweek/config/templates.py +136 -0
- tweek/config/tiers.yaml +5 -4
- tweek/diagnostics.py +112 -32
- tweek/hooks/overrides.py +4 -0
- tweek/hooks/post_tool_use.py +46 -1
- tweek/hooks/pre_tool_use.py +149 -49
- tweek/integrations/openclaw.py +84 -0
- tweek/licensing.py +1 -1
- tweek/mcp/__init__.py +7 -9
- tweek/mcp/clients/chatgpt.py +2 -2
- tweek/mcp/clients/claude_desktop.py +2 -2
- tweek/mcp/clients/gemini.py +2 -2
- tweek/mcp/proxy.py +165 -1
- tweek/memory/provenance.py +438 -0
- tweek/memory/queries.py +2 -0
- tweek/memory/safety.py +23 -4
- tweek/memory/schemas.py +1 -0
- tweek/memory/store.py +101 -71
- tweek/plugins/screening/heuristic_scorer.py +1 -1
- tweek/security/integrity.py +77 -0
- tweek/security/llm_reviewer.py +170 -74
- tweek/security/local_reviewer.py +44 -2
- tweek/security/model_registry.py +73 -7
- tweek/skill_template/overrides-reference.md +1 -1
- tweek/skills/context.py +221 -0
- tweek/skills/scanner.py +2 -2
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/METADATA +8 -7
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/RECORD +60 -38
- tweek/mcp/server.py +0 -320
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/WHEEL +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/entry_points.txt +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/licenses/NOTICE +0 -0
- {tweek-0.3.1.dist-info → tweek-0.4.1.dist-info}/top_level.txt +0 -0
tweek/cli_proxy.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""CLI commands for the Tweek LLM security proxy.
|
|
2
|
+
|
|
3
|
+
Provides the ``proxy`` Click group and its subcommands:
|
|
4
|
+
start, stop, trust, config, wrap, setup.
|
|
5
|
+
|
|
6
|
+
These were extracted from the monolithic cli.py to improve
|
|
7
|
+
maintainability. The group is registered on the main CLI
|
|
8
|
+
entry-point via ``main.add_command(proxy)``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from tweek.cli_helpers import console, print_error, print_success, print_warning, spinner
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ------------------------------------------------------------------
|
|
20
|
+
# Proxy command group
|
|
21
|
+
# ------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
def proxy():
|
|
25
|
+
"""LLM API security proxy for universal protection.
|
|
26
|
+
|
|
27
|
+
The proxy intercepts LLM API traffic and screens for dangerous tool calls.
|
|
28
|
+
Works with any application that calls Anthropic, OpenAI, or other LLM APIs.
|
|
29
|
+
|
|
30
|
+
\b
|
|
31
|
+
Install dependencies: pip install tweek[proxy]
|
|
32
|
+
Quick start:
|
|
33
|
+
tweek proxy start # Start the proxy
|
|
34
|
+
tweek proxy trust # Install CA certificate
|
|
35
|
+
tweek proxy wrap openclaw "npm start" # Wrap an app
|
|
36
|
+
"""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# tweek proxy start
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@proxy.command(
|
|
45
|
+
"start",
|
|
46
|
+
epilog="""\b
|
|
47
|
+
Examples:
|
|
48
|
+
tweek proxy start Start proxy on default port (9877)
|
|
49
|
+
tweek proxy start --port 8080 Start proxy on custom port
|
|
50
|
+
tweek proxy start --foreground Run in foreground for debugging
|
|
51
|
+
tweek proxy start --log-only Log traffic without blocking
|
|
52
|
+
""",
|
|
53
|
+
)
|
|
54
|
+
@click.option("--port", "-p", default=9877, help="Port for proxy to listen on")
|
|
55
|
+
@click.option("--web-port", type=int, help="Port for web interface (disabled by default)")
|
|
56
|
+
@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (for debugging)")
|
|
57
|
+
@click.option("--log-only", is_flag=True, help="Log only, don't block dangerous requests")
|
|
58
|
+
def proxy_start(port: int, web_port: int, foreground: bool, log_only: bool):
|
|
59
|
+
"""Start the Tweek LLM security proxy."""
|
|
60
|
+
from tweek.proxy import PROXY_AVAILABLE, PROXY_MISSING_DEPS
|
|
61
|
+
|
|
62
|
+
if not PROXY_AVAILABLE:
|
|
63
|
+
console.print("[red]\u2717[/red] Proxy dependencies not installed.")
|
|
64
|
+
console.print(" [white]Hint: Install with: pip install tweek[proxy][/white]")
|
|
65
|
+
console.print(" [white]This adds mitmproxy for HTTP(S) interception.[/white]")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
from tweek.proxy.server import start_proxy
|
|
69
|
+
|
|
70
|
+
console.print(f"[cyan]Starting Tweek proxy on port {port}...[/cyan]")
|
|
71
|
+
|
|
72
|
+
success, message = start_proxy(
|
|
73
|
+
port=port,
|
|
74
|
+
web_port=web_port,
|
|
75
|
+
log_only=log_only,
|
|
76
|
+
foreground=foreground,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if success:
|
|
80
|
+
console.print(f"[green]\u2713[/green] {message}")
|
|
81
|
+
console.print()
|
|
82
|
+
console.print("[bold]To use the proxy:[/bold]")
|
|
83
|
+
console.print(f" export HTTPS_PROXY=http://127.0.0.1:{port}")
|
|
84
|
+
console.print(f" export HTTP_PROXY=http://127.0.0.1:{port}")
|
|
85
|
+
console.print()
|
|
86
|
+
console.print("[white]Or use 'tweek proxy wrap' to create a wrapper script[/white]")
|
|
87
|
+
else:
|
|
88
|
+
console.print(f"[red]\u2717[/red] {message}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# tweek proxy stop
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
@proxy.command(
|
|
96
|
+
"stop",
|
|
97
|
+
epilog="""\b
|
|
98
|
+
Examples:
|
|
99
|
+
tweek proxy stop Stop the running proxy server
|
|
100
|
+
""",
|
|
101
|
+
)
|
|
102
|
+
def proxy_stop():
|
|
103
|
+
"""Stop the Tweek LLM security proxy."""
|
|
104
|
+
from tweek.proxy import PROXY_AVAILABLE
|
|
105
|
+
|
|
106
|
+
if not PROXY_AVAILABLE:
|
|
107
|
+
console.print("[red]\u2717[/red] Proxy dependencies not installed.")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
from tweek.proxy.server import stop_proxy
|
|
111
|
+
|
|
112
|
+
success, message = stop_proxy()
|
|
113
|
+
|
|
114
|
+
if success:
|
|
115
|
+
console.print(f"[green]\u2713[/green] {message}")
|
|
116
|
+
else:
|
|
117
|
+
console.print(f"[yellow]![/yellow] {message}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# tweek proxy trust
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@proxy.command(
|
|
125
|
+
"trust",
|
|
126
|
+
epilog="""\b
|
|
127
|
+
Examples:
|
|
128
|
+
tweek proxy trust Install CA certificate for HTTPS interception
|
|
129
|
+
""",
|
|
130
|
+
)
|
|
131
|
+
def proxy_trust():
|
|
132
|
+
"""Install the proxy CA certificate in system trust store.
|
|
133
|
+
|
|
134
|
+
This is required for HTTPS interception to work. The certificate
|
|
135
|
+
is generated locally and only used for local proxy traffic.
|
|
136
|
+
"""
|
|
137
|
+
from tweek.proxy import PROXY_AVAILABLE
|
|
138
|
+
|
|
139
|
+
if not PROXY_AVAILABLE:
|
|
140
|
+
console.print("[red]\u2717[/red] Proxy dependencies not installed.")
|
|
141
|
+
console.print("[white]Run: pip install tweek\\[proxy][/white]")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
from tweek.proxy.server import get_proxy_info, install_ca_certificate
|
|
145
|
+
|
|
146
|
+
info = get_proxy_info()
|
|
147
|
+
|
|
148
|
+
console.print("[bold]Tweek Proxy Certificate Installation[/bold]")
|
|
149
|
+
console.print()
|
|
150
|
+
console.print("This will install a local CA certificate to enable HTTPS interception.")
|
|
151
|
+
console.print("The certificate is generated on YOUR machine and never transmitted.")
|
|
152
|
+
console.print()
|
|
153
|
+
console.print(f"[white]Certificate location: {info['ca_cert']}[/white]")
|
|
154
|
+
console.print()
|
|
155
|
+
|
|
156
|
+
if not click.confirm("Install certificate? (requires admin password)"):
|
|
157
|
+
console.print("[white]Cancelled[/white]")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
success, message = install_ca_certificate()
|
|
161
|
+
|
|
162
|
+
if success:
|
|
163
|
+
console.print(f"[green]\u2713[/green] {message}")
|
|
164
|
+
else:
|
|
165
|
+
console.print(f"[red]\u2717[/red] {message}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# tweek proxy config
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
@proxy.command(
|
|
173
|
+
"config",
|
|
174
|
+
epilog="""\b
|
|
175
|
+
Examples:
|
|
176
|
+
tweek proxy config --enabled Enable proxy in configuration
|
|
177
|
+
tweek proxy config --disabled Disable proxy in configuration
|
|
178
|
+
tweek proxy config --enabled --port 8080 Enable proxy on custom port
|
|
179
|
+
""",
|
|
180
|
+
)
|
|
181
|
+
@click.option("--enabled", "set_enabled", is_flag=True, help="Enable proxy in configuration")
|
|
182
|
+
@click.option("--disabled", "set_disabled", is_flag=True, help="Disable proxy in configuration")
|
|
183
|
+
@click.option("--port", "-p", default=9877, help="Port for proxy")
|
|
184
|
+
def proxy_config(set_enabled, set_disabled, port):
|
|
185
|
+
"""Configure proxy settings."""
|
|
186
|
+
if not set_enabled and not set_disabled:
|
|
187
|
+
console.print("[red]Specify --enabled or --disabled[/red]")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
import yaml
|
|
191
|
+
|
|
192
|
+
config_path = Path.home() / ".tweek" / "config.yaml"
|
|
193
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
|
|
195
|
+
config = {}
|
|
196
|
+
if config_path.exists():
|
|
197
|
+
try:
|
|
198
|
+
with open(config_path) as f:
|
|
199
|
+
config = yaml.safe_load(f) or {}
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
if set_enabled:
|
|
204
|
+
config["proxy"] = {
|
|
205
|
+
"enabled": True,
|
|
206
|
+
"port": port,
|
|
207
|
+
"block_mode": True,
|
|
208
|
+
"log_only": False,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
with open(config_path, "w") as f:
|
|
212
|
+
yaml.dump(config, f, default_flow_style=False)
|
|
213
|
+
|
|
214
|
+
console.print(f"[green]\u2713[/green] Proxy mode enabled (port {port})")
|
|
215
|
+
console.print("[white]Run 'tweek proxy start' to start the proxy[/white]")
|
|
216
|
+
|
|
217
|
+
elif set_disabled:
|
|
218
|
+
if "proxy" in config:
|
|
219
|
+
config["proxy"]["enabled"] = False
|
|
220
|
+
|
|
221
|
+
with open(config_path, "w") as f:
|
|
222
|
+
yaml.dump(config, f, default_flow_style=False)
|
|
223
|
+
|
|
224
|
+
console.print("[green]\u2713[/green] Proxy mode disabled")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# tweek proxy wrap
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@proxy.command(
|
|
232
|
+
"wrap",
|
|
233
|
+
epilog="""\b
|
|
234
|
+
Examples:
|
|
235
|
+
tweek proxy wrap openclaw "npm start" Wrap a Node.js app
|
|
236
|
+
tweek proxy wrap cursor "/Applications/Cursor.app/Contents/MacOS/Cursor"
|
|
237
|
+
tweek proxy wrap myapp "python serve.py" -o run.sh Custom output path
|
|
238
|
+
tweek proxy wrap myapp "npm start" --port 8080 Use custom proxy port
|
|
239
|
+
""",
|
|
240
|
+
)
|
|
241
|
+
@click.argument("app_name")
|
|
242
|
+
@click.argument("command")
|
|
243
|
+
@click.option("--output", "-o", help="Output script path (default: ./run-{app_name}-protected.sh)")
|
|
244
|
+
@click.option("--port", "-p", default=9877, help="Proxy port")
|
|
245
|
+
def proxy_wrap(app_name: str, command: str, output: str, port: int):
|
|
246
|
+
"""Generate a wrapper script to run an app through the proxy."""
|
|
247
|
+
from tweek.proxy.server import generate_wrapper_script
|
|
248
|
+
|
|
249
|
+
if output:
|
|
250
|
+
output_path = Path(output)
|
|
251
|
+
else:
|
|
252
|
+
output_path = Path(f"./run-{app_name}-protected.sh")
|
|
253
|
+
|
|
254
|
+
script = generate_wrapper_script(command, port=port, output_path=output_path)
|
|
255
|
+
|
|
256
|
+
console.print(f"[green]\u2713[/green] Created wrapper script: {output_path}")
|
|
257
|
+
console.print()
|
|
258
|
+
console.print("[bold]Usage:[/bold]")
|
|
259
|
+
console.print(f" chmod +x {output_path}")
|
|
260
|
+
console.print(f" ./{output_path.name}")
|
|
261
|
+
console.print()
|
|
262
|
+
console.print("[white]The script will:[/white]")
|
|
263
|
+
console.print("[white] 1. Start Tweek proxy if not running[/white]")
|
|
264
|
+
console.print("[white] 2. Set proxy environment variables[/white]")
|
|
265
|
+
console.print(f"[white] 3. Run: {command}[/white]")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# tweek proxy setup
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
@proxy.command(
|
|
273
|
+
"setup",
|
|
274
|
+
epilog="""\b
|
|
275
|
+
Examples:
|
|
276
|
+
tweek proxy setup Launch interactive proxy setup wizard
|
|
277
|
+
""",
|
|
278
|
+
)
|
|
279
|
+
def proxy_setup():
|
|
280
|
+
"""Interactive setup wizard for the HTTP proxy.
|
|
281
|
+
|
|
282
|
+
Walks through:
|
|
283
|
+
1. Detecting LLM tools to protect
|
|
284
|
+
2. Generating and trusting CA certificate
|
|
285
|
+
3. Configuring shell environment variables
|
|
286
|
+
"""
|
|
287
|
+
console.print()
|
|
288
|
+
console.print("[bold]HTTP Proxy Setup[/bold]")
|
|
289
|
+
console.print("\u2500" * 30)
|
|
290
|
+
console.print()
|
|
291
|
+
|
|
292
|
+
# Check dependencies
|
|
293
|
+
try:
|
|
294
|
+
from tweek.proxy import PROXY_AVAILABLE, PROXY_MISSING_DEPS
|
|
295
|
+
except ImportError:
|
|
296
|
+
print_error(
|
|
297
|
+
"Proxy module not available",
|
|
298
|
+
fix_hint="Install with: pip install tweek[proxy]",
|
|
299
|
+
)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
if not PROXY_AVAILABLE:
|
|
303
|
+
print_error(
|
|
304
|
+
"Proxy dependencies not installed",
|
|
305
|
+
fix_hint="Install with: pip install tweek[proxy]",
|
|
306
|
+
)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
# Step 1: Detect tools
|
|
310
|
+
console.print("[bold cyan]Step 1/3: Detect LLM Tools[/bold cyan]")
|
|
311
|
+
try:
|
|
312
|
+
from tweek.proxy import detect_supported_tools
|
|
313
|
+
|
|
314
|
+
with spinner("Scanning for LLM tools"):
|
|
315
|
+
tools = detect_supported_tools()
|
|
316
|
+
|
|
317
|
+
detected = [(name, info) for name, info in tools.items() if info]
|
|
318
|
+
if detected:
|
|
319
|
+
for name, info in detected:
|
|
320
|
+
print_success(f"Found {name.capitalize()}")
|
|
321
|
+
else:
|
|
322
|
+
print_warning("No LLM tools detected. You can still set up the proxy manually.")
|
|
323
|
+
except Exception as e:
|
|
324
|
+
print_warning(f"Could not detect tools: {e}")
|
|
325
|
+
console.print()
|
|
326
|
+
|
|
327
|
+
# Step 2: CA Certificate
|
|
328
|
+
console.print("[bold cyan]Step 2/3: CA Certificate[/bold cyan]")
|
|
329
|
+
setup_cert = click.confirm("Generate and trust Tweek CA certificate?", default=True)
|
|
330
|
+
if setup_cert:
|
|
331
|
+
try:
|
|
332
|
+
from tweek.proxy.cert import generate_ca, trust_ca
|
|
333
|
+
|
|
334
|
+
with spinner("Generating CA certificate"):
|
|
335
|
+
generate_ca()
|
|
336
|
+
print_success("CA certificate generated")
|
|
337
|
+
|
|
338
|
+
with spinner("Installing to system trust store"):
|
|
339
|
+
trust_ca()
|
|
340
|
+
print_success("Certificate trusted")
|
|
341
|
+
except ImportError:
|
|
342
|
+
print_warning("Certificate module not available. Run: tweek proxy trust")
|
|
343
|
+
except Exception as e:
|
|
344
|
+
print_warning(f"Could not set up certificate: {e}")
|
|
345
|
+
console.print(" [white]You can do this later with: tweek proxy trust[/white]")
|
|
346
|
+
else:
|
|
347
|
+
console.print(" [white]Skipped. Run 'tweek proxy trust' later.[/white]")
|
|
348
|
+
console.print()
|
|
349
|
+
|
|
350
|
+
# Step 3: Shell environment
|
|
351
|
+
console.print("[bold cyan]Step 3/3: Environment Variables[/bold cyan]")
|
|
352
|
+
port = click.prompt("Proxy port", default=9877, type=int)
|
|
353
|
+
|
|
354
|
+
shell_rc = _detect_shell_rc()
|
|
355
|
+
if shell_rc:
|
|
356
|
+
console.print(f" Detected shell config: {shell_rc}")
|
|
357
|
+
console.print(f" Will add:")
|
|
358
|
+
console.print(f" export HTTP_PROXY=http://127.0.0.1:{port}")
|
|
359
|
+
console.print(f" export HTTPS_PROXY=http://127.0.0.1:{port}")
|
|
360
|
+
console.print()
|
|
361
|
+
|
|
362
|
+
apply_env = click.confirm(f"Add to {shell_rc}?", default=True)
|
|
363
|
+
if apply_env:
|
|
364
|
+
try:
|
|
365
|
+
rc_path = Path(shell_rc).expanduser()
|
|
366
|
+
with open(rc_path, "a") as f:
|
|
367
|
+
f.write(f"\n# Tweek proxy environment\n")
|
|
368
|
+
f.write(f"export HTTP_PROXY=http://127.0.0.1:{port}\n")
|
|
369
|
+
f.write(f"export HTTPS_PROXY=http://127.0.0.1:{port}\n")
|
|
370
|
+
print_success(f"Added to {shell_rc}")
|
|
371
|
+
console.print(f" [white]Restart your shell or run: source {shell_rc}[/white]")
|
|
372
|
+
except Exception as e:
|
|
373
|
+
print_warning(f"Could not write to {shell_rc}: {e}")
|
|
374
|
+
else:
|
|
375
|
+
console.print(" [white]Skipped. Set HTTP_PROXY and HTTPS_PROXY manually.[/white]")
|
|
376
|
+
else:
|
|
377
|
+
console.print(" [white]Could not detect shell config file.[/white]")
|
|
378
|
+
console.print(f" Add these to your shell profile:")
|
|
379
|
+
console.print(f" export HTTP_PROXY=http://127.0.0.1:{port}")
|
|
380
|
+
console.print(f" export HTTPS_PROXY=http://127.0.0.1:{port}")
|
|
381
|
+
|
|
382
|
+
console.print()
|
|
383
|
+
console.print("[bold green]Proxy configured![/bold green]")
|
|
384
|
+
console.print(" Start with: [cyan]tweek proxy start[/cyan]")
|
|
385
|
+
console.print()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
# Helper (only used by proxy_setup)
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def _detect_shell_rc() -> str:
|
|
393
|
+
"""Detect the user's shell config file."""
|
|
394
|
+
shell = os.environ.get("SHELL", "")
|
|
395
|
+
home = Path.home()
|
|
396
|
+
|
|
397
|
+
if "zsh" in shell:
|
|
398
|
+
return "~/.zshrc"
|
|
399
|
+
elif "bash" in shell:
|
|
400
|
+
if (home / ".bash_profile").exists():
|
|
401
|
+
return "~/.bash_profile"
|
|
402
|
+
return "~/.bashrc"
|
|
403
|
+
elif "fish" in shell:
|
|
404
|
+
return "~/.config/fish/config.fish"
|
|
405
|
+
return ""
|
tweek/cli_security.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""CLI commands for break-glass overrides and false-positive feedback.
|
|
2
|
+
|
|
3
|
+
Extracted from cli.py to keep the main CLI module manageable.
|
|
4
|
+
Groups:
|
|
5
|
+
override_group -- break-glass override create / list / clear
|
|
6
|
+
feedback_group -- false-positive reporting, stats, reset
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from tweek.cli_helpers import console
|
|
16
|
+
|
|
17
|
+
# =========================================================================
|
|
18
|
+
# BREAK-GLASS OVERRIDE COMMANDS
|
|
19
|
+
# =========================================================================
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group("override")
|
|
23
|
+
def override_group():
|
|
24
|
+
"""Break-glass override for hard-blocked patterns.
|
|
25
|
+
|
|
26
|
+
When graduated enforcement blocks a pattern with "deny" (critical +
|
|
27
|
+
deterministic), use these commands to create a temporary override.
|
|
28
|
+
|
|
29
|
+
Overrides downgrade "deny" to "ask" — you still see the prompt and
|
|
30
|
+
must explicitly approve. Every use is logged for audit.
|
|
31
|
+
"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@override_group.command("create")
|
|
36
|
+
@click.option("--pattern", required=True, help="Pattern name to override (e.g., ssh_key_read)")
|
|
37
|
+
@click.option("--once", "mode", flag_value="once", default=True, help="Single-use override (consumed on first use)")
|
|
38
|
+
@click.option("--duration", "duration_minutes", type=int, default=None, help="Duration in minutes (overrides --once)")
|
|
39
|
+
@click.option("--reason", default="", help="Reason for the override (logged for audit)")
|
|
40
|
+
def override_create(pattern: str, mode: str, duration_minutes: Optional[int], reason: str):
|
|
41
|
+
"""Create a break-glass override for a hard-blocked pattern."""
|
|
42
|
+
from tweek.hooks.break_glass import create_override
|
|
43
|
+
|
|
44
|
+
if duration_minutes:
|
|
45
|
+
mode = "duration"
|
|
46
|
+
|
|
47
|
+
override = create_override(pattern, mode=mode, duration_minutes=duration_minutes, reason=reason)
|
|
48
|
+
|
|
49
|
+
# Log the creation
|
|
50
|
+
try:
|
|
51
|
+
from tweek.logging.security_log import get_logger, EventType, SecurityEvent
|
|
52
|
+
logger = get_logger()
|
|
53
|
+
logger.log(SecurityEvent(
|
|
54
|
+
event_type=EventType.BREAK_GLASS,
|
|
55
|
+
tool_name="tweek_cli",
|
|
56
|
+
decision="override_created",
|
|
57
|
+
decision_reason=f"Break-glass override created for '{pattern}'",
|
|
58
|
+
metadata={
|
|
59
|
+
"pattern": pattern,
|
|
60
|
+
"mode": mode,
|
|
61
|
+
"duration_minutes": duration_minutes,
|
|
62
|
+
"reason": reason,
|
|
63
|
+
},
|
|
64
|
+
))
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
console.print(f"[bold green]Break-glass override created[/bold green]")
|
|
69
|
+
console.print(f" Pattern: [bold]{pattern}[/bold]")
|
|
70
|
+
console.print(f" Mode: {mode}")
|
|
71
|
+
if duration_minutes:
|
|
72
|
+
console.print(f" Expires: {override.get('expires_at', 'N/A')}")
|
|
73
|
+
if reason:
|
|
74
|
+
console.print(f" Reason: {reason}")
|
|
75
|
+
console.print()
|
|
76
|
+
console.print("[white]Next time this pattern triggers, you'll see an 'ask' prompt instead of a hard block.[/white]")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@override_group.command("list")
|
|
80
|
+
def override_list():
|
|
81
|
+
"""List all break-glass overrides (active and historical)."""
|
|
82
|
+
from tweek.hooks.break_glass import list_overrides, list_active_overrides
|
|
83
|
+
|
|
84
|
+
all_overrides = list_overrides()
|
|
85
|
+
active = list_active_overrides()
|
|
86
|
+
active_patterns = {o["pattern"] for o in active}
|
|
87
|
+
|
|
88
|
+
if not all_overrides:
|
|
89
|
+
console.print("[white]No break-glass overrides found.[/white]")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
table = Table(title="Break-Glass Overrides")
|
|
93
|
+
table.add_column("Pattern", style="bold")
|
|
94
|
+
table.add_column("Mode")
|
|
95
|
+
table.add_column("Status")
|
|
96
|
+
table.add_column("Reason")
|
|
97
|
+
table.add_column("Created")
|
|
98
|
+
|
|
99
|
+
for o in all_overrides:
|
|
100
|
+
if o["pattern"] in active_patterns and not o.get("used"):
|
|
101
|
+
status = "[green]active[/green]"
|
|
102
|
+
elif o.get("used"):
|
|
103
|
+
status = "[white]consumed[/white]"
|
|
104
|
+
else:
|
|
105
|
+
status = "[white]expired[/white]"
|
|
106
|
+
|
|
107
|
+
table.add_row(
|
|
108
|
+
o["pattern"],
|
|
109
|
+
o["mode"],
|
|
110
|
+
status,
|
|
111
|
+
o.get("reason", ""),
|
|
112
|
+
o.get("created_at", "")[:19],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
console.print(table)
|
|
116
|
+
console.print(f"\n[bold]{len(active)}[/bold] active override(s)")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@override_group.command("clear")
|
|
120
|
+
@click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
|
|
121
|
+
def override_clear(confirm: bool):
|
|
122
|
+
"""Remove all break-glass overrides."""
|
|
123
|
+
from tweek.hooks.break_glass import clear_overrides
|
|
124
|
+
|
|
125
|
+
if not confirm:
|
|
126
|
+
if not sys.stdin.isatty():
|
|
127
|
+
console.print("[red]Use --confirm to clear overrides in non-interactive mode.[/red]")
|
|
128
|
+
return
|
|
129
|
+
if not click.confirm("Clear all break-glass overrides?"):
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
count = clear_overrides()
|
|
133
|
+
console.print(f"[bold]Cleared {count} override(s).[/bold]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# =========================================================================
|
|
137
|
+
# FEEDBACK COMMANDS
|
|
138
|
+
# =========================================================================
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@click.group("feedback")
|
|
142
|
+
def feedback_group():
|
|
143
|
+
"""False-positive feedback and pattern performance tracking.
|
|
144
|
+
|
|
145
|
+
Report false positives, view per-pattern FP rates, and manage
|
|
146
|
+
automatic severity demotions for noisy patterns.
|
|
147
|
+
"""
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@feedback_group.command("fp")
|
|
152
|
+
@click.argument("pattern_name")
|
|
153
|
+
@click.option("--context", default="", help="Description of the false positive context")
|
|
154
|
+
def feedback_fp(pattern_name: str, context: str):
|
|
155
|
+
"""Report a false positive for a pattern."""
|
|
156
|
+
from tweek.hooks.feedback import report_false_positive
|
|
157
|
+
|
|
158
|
+
result = report_false_positive(pattern_name, context=context)
|
|
159
|
+
|
|
160
|
+
# Log the report
|
|
161
|
+
try:
|
|
162
|
+
from tweek.logging.security_log import get_logger, EventType, SecurityEvent
|
|
163
|
+
logger = get_logger()
|
|
164
|
+
logger.log(SecurityEvent(
|
|
165
|
+
event_type=EventType.FALSE_POSITIVE_REPORT,
|
|
166
|
+
tool_name="tweek_cli",
|
|
167
|
+
pattern_name=pattern_name,
|
|
168
|
+
decision="fp_reported",
|
|
169
|
+
decision_reason=f"False positive reported for '{pattern_name}'",
|
|
170
|
+
metadata={
|
|
171
|
+
"context": context,
|
|
172
|
+
"fp_rate": result.get("fp_rate"),
|
|
173
|
+
"total_triggers": result.get("total_triggers"),
|
|
174
|
+
"false_positives": result.get("false_positives"),
|
|
175
|
+
},
|
|
176
|
+
))
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
console.print(f"[bold green]False positive recorded[/bold green] for [bold]{pattern_name}[/bold]")
|
|
181
|
+
console.print(f" FP rate: {result.get('fp_rate', 0):.1%} ({result.get('false_positives', 0)}/{result.get('total_triggers', 0)})")
|
|
182
|
+
|
|
183
|
+
if result.get("auto_demoted"):
|
|
184
|
+
console.print(f" [yellow]Auto-demoted:[/yellow] {result.get('original_severity')} -> {result.get('current_severity')}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@feedback_group.command("stats")
|
|
188
|
+
@click.option("--above-threshold", is_flag=True, help="Show only patterns exceeding 5% FP rate")
|
|
189
|
+
def feedback_stats(above_threshold: bool):
|
|
190
|
+
"""Show false-positive rates per pattern."""
|
|
191
|
+
from tweek.hooks.feedback import get_stats
|
|
192
|
+
|
|
193
|
+
stats = get_stats()
|
|
194
|
+
if not stats:
|
|
195
|
+
console.print("[white]No feedback data recorded yet.[/white]")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
table = Table(title="Pattern FP Statistics")
|
|
199
|
+
table.add_column("Pattern", style="bold")
|
|
200
|
+
table.add_column("Triggers", justify="right")
|
|
201
|
+
table.add_column("FPs", justify="right")
|
|
202
|
+
table.add_column("FP Rate", justify="right")
|
|
203
|
+
table.add_column("Demoted?")
|
|
204
|
+
|
|
205
|
+
for name, data in sorted(stats.items(), key=lambda x: x[1].get("fp_rate", 0), reverse=True):
|
|
206
|
+
fp_rate = data.get("fp_rate", 0)
|
|
207
|
+
if above_threshold and fp_rate < 0.05:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
rate_style = "red" if fp_rate >= 0.05 else "green"
|
|
211
|
+
demoted = "[yellow]yes[/yellow]" if data.get("auto_demoted") else "no"
|
|
212
|
+
|
|
213
|
+
table.add_row(
|
|
214
|
+
name,
|
|
215
|
+
str(data.get("total_triggers", 0)),
|
|
216
|
+
str(data.get("false_positives", 0)),
|
|
217
|
+
f"[{rate_style}]{fp_rate:.1%}[/{rate_style}]",
|
|
218
|
+
demoted,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
console.print(table)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@feedback_group.command("reset")
|
|
225
|
+
@click.argument("pattern_name")
|
|
226
|
+
def feedback_reset(pattern_name: str):
|
|
227
|
+
"""Reset FP tracking and undo auto-demotion for a pattern."""
|
|
228
|
+
from tweek.hooks.feedback import reset_pattern
|
|
229
|
+
|
|
230
|
+
result = reset_pattern(pattern_name)
|
|
231
|
+
if result:
|
|
232
|
+
console.print(f"[bold]Reset feedback data for '{pattern_name}'[/bold]")
|
|
233
|
+
if result.get("was_demoted"):
|
|
234
|
+
console.print(f" Restored severity: {result.get('original_severity')}")
|
|
235
|
+
else:
|
|
236
|
+
console.print(f"[white]No feedback data found for '{pattern_name}'.[/white]")
|