tweek 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tweek/__init__.py +2 -2
- tweek/_keygen.py +53 -0
- tweek/audit.py +288 -0
- tweek/cli.py +5303 -2396
- tweek/cli_model.py +380 -0
- tweek/config/families.yaml +609 -0
- tweek/config/manager.py +42 -5
- tweek/config/patterns.yaml +1510 -8
- tweek/config/tiers.yaml +161 -11
- tweek/diagnostics.py +71 -2
- tweek/hooks/break_glass.py +163 -0
- tweek/hooks/feedback.py +223 -0
- tweek/hooks/overrides.py +531 -0
- tweek/hooks/post_tool_use.py +472 -0
- tweek/hooks/pre_tool_use.py +1024 -62
- tweek/integrations/openclaw.py +443 -0
- tweek/integrations/openclaw_server.py +385 -0
- tweek/licensing.py +14 -54
- tweek/logging/bundle.py +2 -2
- tweek/logging/security_log.py +56 -13
- tweek/mcp/approval.py +57 -16
- tweek/mcp/proxy.py +18 -0
- tweek/mcp/screening.py +5 -5
- tweek/mcp/server.py +4 -1
- tweek/memory/__init__.py +24 -0
- tweek/memory/queries.py +223 -0
- tweek/memory/safety.py +140 -0
- tweek/memory/schemas.py +80 -0
- tweek/memory/store.py +989 -0
- tweek/platform/__init__.py +4 -4
- tweek/plugins/__init__.py +40 -24
- tweek/plugins/base.py +1 -1
- tweek/plugins/detectors/__init__.py +3 -3
- tweek/plugins/detectors/{moltbot.py → openclaw.py} +30 -27
- tweek/plugins/git_discovery.py +16 -4
- tweek/plugins/git_registry.py +8 -2
- tweek/plugins/git_security.py +21 -9
- tweek/plugins/screening/__init__.py +10 -1
- tweek/plugins/screening/heuristic_scorer.py +477 -0
- tweek/plugins/screening/llm_reviewer.py +14 -6
- tweek/plugins/screening/local_model_reviewer.py +161 -0
- tweek/proxy/__init__.py +38 -37
- tweek/proxy/addon.py +22 -3
- tweek/proxy/interceptor.py +1 -0
- tweek/proxy/server.py +4 -2
- tweek/sandbox/__init__.py +11 -0
- tweek/sandbox/docker_bridge.py +143 -0
- tweek/sandbox/executor.py +9 -6
- tweek/sandbox/layers.py +97 -0
- tweek/sandbox/linux.py +1 -0
- tweek/sandbox/project.py +548 -0
- tweek/sandbox/registry.py +149 -0
- tweek/security/__init__.py +9 -0
- tweek/security/language.py +250 -0
- tweek/security/llm_reviewer.py +1146 -60
- tweek/security/local_model.py +331 -0
- tweek/security/local_reviewer.py +146 -0
- tweek/security/model_registry.py +371 -0
- tweek/security/rate_limiter.py +11 -6
- tweek/security/secret_scanner.py +70 -4
- tweek/security/session_analyzer.py +26 -2
- tweek/skill_template/SKILL.md +200 -0
- tweek/skill_template/__init__.py +0 -0
- tweek/skill_template/cli-reference.md +331 -0
- tweek/skill_template/overrides-reference.md +184 -0
- tweek/skill_template/scripts/__init__.py +0 -0
- tweek/skill_template/scripts/check_installed.py +170 -0
- tweek/skills/__init__.py +38 -0
- tweek/skills/config.py +150 -0
- tweek/skills/fingerprints.py +198 -0
- tweek/skills/guard.py +293 -0
- tweek/skills/isolation.py +469 -0
- tweek/skills/scanner.py +715 -0
- tweek/vault/__init__.py +0 -1
- tweek/vault/cross_platform.py +12 -1
- tweek/vault/keychain.py +87 -29
- tweek-0.2.0.dist-info/METADATA +281 -0
- tweek-0.2.0.dist-info/RECORD +121 -0
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/entry_points.txt +8 -1
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/licenses/LICENSE +80 -0
- tweek/integrations/moltbot.py +0 -243
- tweek-0.1.0.dist-info/METADATA +0 -335
- tweek-0.1.0.dist-info/RECORD +0 -85
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/WHEEL +0 -0
- {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/top_level.txt +0 -0
tweek/proxy/__init__.py
CHANGED
|
@@ -16,6 +16,7 @@ Usage:
|
|
|
16
16
|
The proxy is DISABLED by default. Enable with:
|
|
17
17
|
tweek proxy enable
|
|
18
18
|
"""
|
|
19
|
+
from __future__ import annotations
|
|
19
20
|
|
|
20
21
|
import shutil
|
|
21
22
|
import socket
|
|
@@ -34,7 +35,7 @@ except ImportError:
|
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
# Default ports
|
|
37
|
-
|
|
38
|
+
OPENCLAW_DEFAULT_PORT = 18789
|
|
38
39
|
TWEEK_DEFAULT_PORT = 9877
|
|
39
40
|
|
|
40
41
|
|
|
@@ -58,8 +59,8 @@ def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|
|
58
59
|
return False
|
|
59
60
|
|
|
60
61
|
|
|
61
|
-
def
|
|
62
|
-
"""Check if
|
|
62
|
+
def check_openclaw_gateway_running(port: int = OPENCLAW_DEFAULT_PORT) -> bool:
|
|
63
|
+
"""Check if OpenClaw's gateway is actively listening on its port."""
|
|
63
64
|
return is_port_in_use(port)
|
|
64
65
|
|
|
65
66
|
|
|
@@ -71,19 +72,19 @@ def detect_proxy_conflicts() -> list[ProxyConflict]:
|
|
|
71
72
|
"""
|
|
72
73
|
conflicts = []
|
|
73
74
|
|
|
74
|
-
# Check for
|
|
75
|
-
|
|
76
|
-
if
|
|
77
|
-
|
|
78
|
-
is_running =
|
|
75
|
+
# Check for OpenClaw
|
|
76
|
+
openclaw_info = detect_openclaw()
|
|
77
|
+
if openclaw_info:
|
|
78
|
+
openclaw_port = openclaw_info.get("gateway_port", OPENCLAW_DEFAULT_PORT)
|
|
79
|
+
is_running = check_openclaw_gateway_running(openclaw_port)
|
|
79
80
|
|
|
80
|
-
if
|
|
81
|
+
if openclaw_info.get("process_running") or is_running:
|
|
81
82
|
conflicts.append(ProxyConflict(
|
|
82
|
-
tool_name="
|
|
83
|
-
port=
|
|
83
|
+
tool_name="openclaw",
|
|
84
|
+
port=openclaw_port,
|
|
84
85
|
is_running=is_running,
|
|
85
|
-
description="
|
|
86
|
-
(f" on port {
|
|
86
|
+
description="OpenClaw gateway detected" +
|
|
87
|
+
(f" on port {openclaw_port}" if is_running else " (process found)")
|
|
87
88
|
))
|
|
88
89
|
|
|
89
90
|
# Check if something else is using Tweek's default port
|
|
@@ -98,31 +99,31 @@ def detect_proxy_conflicts() -> list[ProxyConflict]:
|
|
|
98
99
|
return conflicts
|
|
99
100
|
|
|
100
101
|
|
|
101
|
-
def
|
|
102
|
+
def get_openclaw_status() -> dict:
|
|
102
103
|
"""
|
|
103
|
-
Get detailed
|
|
104
|
+
Get detailed OpenClaw status including whether its gateway is running.
|
|
104
105
|
|
|
105
106
|
Returns:
|
|
106
107
|
Dict with keys: installed, running, gateway_active, port, config_path
|
|
107
108
|
"""
|
|
108
109
|
from pathlib import Path
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
openclaw_info = detect_openclaw()
|
|
111
112
|
|
|
112
113
|
status = {
|
|
113
|
-
"installed":
|
|
114
|
+
"installed": openclaw_info is not None,
|
|
114
115
|
"running": False,
|
|
115
116
|
"gateway_active": False,
|
|
116
|
-
"port":
|
|
117
|
+
"port": OPENCLAW_DEFAULT_PORT,
|
|
117
118
|
"config_path": None,
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
if
|
|
121
|
-
status["running"] =
|
|
122
|
-
status["port"] =
|
|
123
|
-
status["gateway_active"] =
|
|
121
|
+
if openclaw_info:
|
|
122
|
+
status["running"] = openclaw_info.get("process_running", False)
|
|
123
|
+
status["port"] = openclaw_info.get("gateway_port", OPENCLAW_DEFAULT_PORT)
|
|
124
|
+
status["gateway_active"] = check_openclaw_gateway_running(status["port"])
|
|
124
125
|
|
|
125
|
-
config_path = Path.home() / ".
|
|
126
|
+
config_path = Path.home() / ".openclaw"
|
|
126
127
|
if config_path.exists():
|
|
127
128
|
status["config_path"] = str(config_path)
|
|
128
129
|
|
|
@@ -130,8 +131,8 @@ def get_moltbot_status() -> dict:
|
|
|
130
131
|
|
|
131
132
|
|
|
132
133
|
# Detection functions for supported tools
|
|
133
|
-
def
|
|
134
|
-
"""Detect if
|
|
134
|
+
def detect_openclaw() -> Optional[dict]:
|
|
135
|
+
"""Detect if OpenClaw is installed on the system."""
|
|
135
136
|
import subprocess
|
|
136
137
|
import json
|
|
137
138
|
from pathlib import Path
|
|
@@ -146,22 +147,22 @@ def detect_moltbot() -> Optional[dict]:
|
|
|
146
147
|
# Check for npm global installation
|
|
147
148
|
try:
|
|
148
149
|
result = subprocess.run(
|
|
149
|
-
["npm", "list", "-g", "
|
|
150
|
+
["npm", "list", "-g", "openclaw", "--json"],
|
|
150
151
|
capture_output=True,
|
|
151
152
|
text=True,
|
|
152
153
|
timeout=5
|
|
153
154
|
)
|
|
154
155
|
if result.returncode == 0:
|
|
155
156
|
data = json.loads(result.stdout)
|
|
156
|
-
if "dependencies" in data and "
|
|
157
|
+
if "dependencies" in data and "openclaw" in data.get("dependencies", {}):
|
|
157
158
|
indicators["npm_global"] = True
|
|
158
159
|
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
159
160
|
pass
|
|
160
161
|
|
|
161
|
-
# Check for running
|
|
162
|
+
# Check for running openclaw process
|
|
162
163
|
try:
|
|
163
164
|
result = subprocess.run(
|
|
164
|
-
["pgrep", "-f", "
|
|
165
|
+
["pgrep", "-f", "openclaw"],
|
|
165
166
|
capture_output=True,
|
|
166
167
|
timeout=5
|
|
167
168
|
)
|
|
@@ -170,9 +171,9 @@ def detect_moltbot() -> Optional[dict]:
|
|
|
170
171
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
171
172
|
pass
|
|
172
173
|
|
|
173
|
-
# Check for
|
|
174
|
-
|
|
175
|
-
if
|
|
174
|
+
# Check for OpenClaw config directory
|
|
175
|
+
openclaw_config = Path.home() / ".openclaw"
|
|
176
|
+
if openclaw_config.exists():
|
|
176
177
|
indicators["config_exists"] = True
|
|
177
178
|
|
|
178
179
|
# Default gateway port
|
|
@@ -231,7 +232,7 @@ def detect_continue() -> Optional[dict]:
|
|
|
231
232
|
def detect_supported_tools() -> dict:
|
|
232
233
|
"""Detect all supported LLM tools on the system."""
|
|
233
234
|
return {
|
|
234
|
-
"
|
|
235
|
+
"openclaw": detect_openclaw(),
|
|
235
236
|
"cursor": detect_cursor(),
|
|
236
237
|
"continue": detect_continue(),
|
|
237
238
|
}
|
|
@@ -287,14 +288,14 @@ def get_proxy_status() -> dict:
|
|
|
287
288
|
__all__ = [
|
|
288
289
|
"PROXY_AVAILABLE",
|
|
289
290
|
"PROXY_MISSING_DEPS",
|
|
290
|
-
"
|
|
291
|
+
"OPENCLAW_DEFAULT_PORT",
|
|
291
292
|
"TWEEK_DEFAULT_PORT",
|
|
292
293
|
"ProxyConflict",
|
|
293
294
|
"is_port_in_use",
|
|
294
|
-
"
|
|
295
|
+
"check_openclaw_gateway_running",
|
|
295
296
|
"detect_proxy_conflicts",
|
|
296
|
-
"
|
|
297
|
-
"
|
|
297
|
+
"get_openclaw_status",
|
|
298
|
+
"detect_openclaw",
|
|
298
299
|
"detect_cursor",
|
|
299
300
|
"detect_continue",
|
|
300
301
|
"detect_supported_tools",
|
tweek/proxy/addon.py
CHANGED
|
@@ -95,7 +95,6 @@ class TweekProxyAddon:
|
|
|
95
95
|
result = self.interceptor.screen_request(flow.request.content, provider)
|
|
96
96
|
|
|
97
97
|
if result.warnings:
|
|
98
|
-
# Log warnings but don't block requests
|
|
99
98
|
logger.warning(
|
|
100
99
|
f"Prompt injection warning: {result.warnings} "
|
|
101
100
|
f"(provider={provider.value}, path={flow.request.path})"
|
|
@@ -104,6 +103,21 @@ class TweekProxyAddon:
|
|
|
104
103
|
# Add header to track warning
|
|
105
104
|
flow.request.headers["X-Tweek-Warning"] = "prompt-injection-suspected"
|
|
106
105
|
|
|
106
|
+
# Block request if screening says it's not allowed
|
|
107
|
+
if not result.allowed and self.block_mode and not self.log_only:
|
|
108
|
+
self.stats["requests_blocked"] += 1
|
|
109
|
+
flow.response = http.Response.make(
|
|
110
|
+
403,
|
|
111
|
+
json.dumps({
|
|
112
|
+
"error": {
|
|
113
|
+
"type": "security_blocked",
|
|
114
|
+
"message": f"Tweek Security: Request blocked — {result.reason}",
|
|
115
|
+
"patterns": result.matched_patterns,
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
{"Content-Type": "application/json"},
|
|
119
|
+
)
|
|
120
|
+
|
|
107
121
|
@concurrent
|
|
108
122
|
def response(self, flow: http.HTTPFlow):
|
|
109
123
|
"""Handle incoming responses from LLM APIs."""
|
|
@@ -112,10 +126,15 @@ class TweekProxyAddon:
|
|
|
112
126
|
if not self.interceptor.should_intercept(host):
|
|
113
127
|
return
|
|
114
128
|
|
|
115
|
-
#
|
|
129
|
+
# Log streaming responses - we can't fully buffer SSE without breaking UX
|
|
130
|
+
# but we flag them as unscreened for visibility
|
|
116
131
|
content_type = flow.response.headers.get("content-type", "")
|
|
117
132
|
if "text/event-stream" in content_type:
|
|
118
|
-
logger.
|
|
133
|
+
logger.warning(
|
|
134
|
+
f"Streaming response from {host} cannot be fully screened. "
|
|
135
|
+
"Tool calls in streaming responses bypass proxy screening."
|
|
136
|
+
)
|
|
137
|
+
self.stats["streaming_unscreened"] = self.stats.get("streaming_unscreened", 0) + 1
|
|
119
138
|
return
|
|
120
139
|
|
|
121
140
|
self.stats["responses_screened"] += 1
|
tweek/proxy/interceptor.py
CHANGED
tweek/proxy/server.py
CHANGED
|
@@ -267,8 +267,10 @@ def get_proxy_env_vars(port: int = DEFAULT_PORT) -> dict[str, str]:
|
|
|
267
267
|
"HTTPS_PROXY": proxy_url,
|
|
268
268
|
"http_proxy": proxy_url,
|
|
269
269
|
"https_proxy": proxy_url,
|
|
270
|
-
# For Node.js
|
|
271
|
-
|
|
270
|
+
# For Node.js: use CA cert bundle instead of disabling TLS validation
|
|
271
|
+
# NODE_TLS_REJECT_UNAUTHORIZED=0 would disable ALL TLS checks (insecure)
|
|
272
|
+
# NODE_EXTRA_CA_CERTS adds our CA to the trust store without disabling validation
|
|
273
|
+
"NODE_EXTRA_CA_CERTS": str(Path.home() / ".tweek" / "proxy" / "tweek-ca.pem"),
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
|
tweek/sandbox/__init__.py
CHANGED
|
@@ -64,8 +64,19 @@ __all__ = [
|
|
|
64
64
|
"SANDBOX_AVAILABLE",
|
|
65
65
|
"SANDBOX_TOOL",
|
|
66
66
|
"get_sandbox_status",
|
|
67
|
+
# Project sandbox (Layer 2)
|
|
68
|
+
"IsolationLayer",
|
|
69
|
+
"ProjectSandbox",
|
|
70
|
+
"get_project_sandbox",
|
|
71
|
+
"ProjectRegistry",
|
|
72
|
+
"get_registry",
|
|
67
73
|
]
|
|
68
74
|
|
|
75
|
+
# Project sandbox imports (always available, no platform dependency)
|
|
76
|
+
from .layers import IsolationLayer
|
|
77
|
+
from .project import ProjectSandbox, get_project_sandbox
|
|
78
|
+
from .registry import ProjectRegistry, get_registry
|
|
79
|
+
|
|
69
80
|
# Add Linux-specific exports if available
|
|
70
81
|
if IS_LINUX:
|
|
71
82
|
__all__.extend(["LinuxSandbox", "prompt_install_firejail", "get_sandbox"])
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tweek Docker Bridge
|
|
3
|
+
|
|
4
|
+
Lightweight integration with Docker for container-level isolation.
|
|
5
|
+
Generates docker-compose.yaml and provides simple run/status commands.
|
|
6
|
+
Delegates actual container management to Docker rather than reimplementing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import textwrap
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TWEEK_HOME = Path.home() / ".tweek"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DockerBridge:
|
|
19
|
+
"""Lightweight Docker integration for project-level container isolation."""
|
|
20
|
+
|
|
21
|
+
def is_docker_available(self) -> bool:
|
|
22
|
+
"""Check if Docker is installed and the daemon is running."""
|
|
23
|
+
try:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["docker", "info"],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
timeout=5,
|
|
29
|
+
)
|
|
30
|
+
return result.returncode == 0
|
|
31
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def get_docker_version(self) -> Optional[str]:
|
|
35
|
+
"""Get Docker version string, or None if unavailable."""
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["docker", "version", "--format", "{{.Server.Version}}"],
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
timeout=5,
|
|
42
|
+
)
|
|
43
|
+
if result.returncode == 0:
|
|
44
|
+
return result.stdout.strip()
|
|
45
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
46
|
+
pass
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def init(self, project_dir: Path) -> Path:
|
|
50
|
+
"""Generate docker-compose.yaml for this project.
|
|
51
|
+
|
|
52
|
+
Creates a minimal Docker Compose configuration that:
|
|
53
|
+
- Mounts the project directory read-write at /workspace
|
|
54
|
+
- Mounts global Tweek config read-only
|
|
55
|
+
- Disables network access by default
|
|
56
|
+
- Sets up environment for Tweek hooks
|
|
57
|
+
|
|
58
|
+
Returns the path to the generated compose file.
|
|
59
|
+
"""
|
|
60
|
+
project_dir = project_dir.resolve()
|
|
61
|
+
tweek_dir = project_dir / ".tweek"
|
|
62
|
+
tweek_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
compose_path = tweek_dir / "docker-compose.yaml"
|
|
65
|
+
tweek_home_str = str(TWEEK_HOME)
|
|
66
|
+
|
|
67
|
+
compose_content = textwrap.dedent(f"""\
|
|
68
|
+
# Tweek Docker Sandbox Configuration
|
|
69
|
+
# Generated by: tweek sandbox docker init
|
|
70
|
+
# Docs: https://github.com/your-org/tweek
|
|
71
|
+
#
|
|
72
|
+
# This file mounts your project into a container with Tweek hooks active.
|
|
73
|
+
# Customize as needed for your workflow.
|
|
74
|
+
|
|
75
|
+
services:
|
|
76
|
+
tweek-sandbox:
|
|
77
|
+
image: python:3.12-slim
|
|
78
|
+
working_dir: /workspace
|
|
79
|
+
volumes:
|
|
80
|
+
# Project directory (read-write)
|
|
81
|
+
- {project_dir}:/workspace:rw
|
|
82
|
+
# Global Tweek config (read-only)
|
|
83
|
+
- {tweek_home_str}:/home/tweek/.tweek-global:ro
|
|
84
|
+
network_mode: "none" # No network by default (security)
|
|
85
|
+
environment:
|
|
86
|
+
- TWEEK_SANDBOX_LAYER=2
|
|
87
|
+
- TWEEK_GLOBAL_CONFIG=/home/tweek/.tweek-global
|
|
88
|
+
- HOME=/home/tweek
|
|
89
|
+
# Install tweek and start a shell
|
|
90
|
+
# Customize this command for your workflow
|
|
91
|
+
command: >
|
|
92
|
+
bash -c "
|
|
93
|
+
pip install -q tweek 2>/dev/null;
|
|
94
|
+
echo 'Tweek sandbox ready. Project mounted at /workspace';
|
|
95
|
+
exec bash
|
|
96
|
+
"
|
|
97
|
+
stdin_open: true
|
|
98
|
+
tty: true
|
|
99
|
+
""")
|
|
100
|
+
|
|
101
|
+
compose_path.write_text(compose_content)
|
|
102
|
+
return compose_path
|
|
103
|
+
|
|
104
|
+
def run(self, project_dir: Path) -> int:
|
|
105
|
+
"""Launch the project in a Docker container.
|
|
106
|
+
|
|
107
|
+
Returns the container's exit code.
|
|
108
|
+
"""
|
|
109
|
+
project_dir = project_dir.resolve()
|
|
110
|
+
compose_path = project_dir / ".tweek" / "docker-compose.yaml"
|
|
111
|
+
|
|
112
|
+
if not compose_path.exists():
|
|
113
|
+
# Auto-generate if not present
|
|
114
|
+
self.init(project_dir)
|
|
115
|
+
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
["docker", "compose", "-f", str(compose_path), "run", "--rm", "tweek-sandbox"],
|
|
118
|
+
cwd=str(project_dir),
|
|
119
|
+
)
|
|
120
|
+
return result.returncode
|
|
121
|
+
|
|
122
|
+
def stop(self, project_dir: Path) -> None:
|
|
123
|
+
"""Stop any running containers for this project."""
|
|
124
|
+
compose_path = project_dir.resolve() / ".tweek" / "docker-compose.yaml"
|
|
125
|
+
if compose_path.exists():
|
|
126
|
+
subprocess.run(
|
|
127
|
+
["docker", "compose", "-f", str(compose_path), "down"],
|
|
128
|
+
capture_output=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def suggest_docker(self, project_dir: Path) -> Optional[str]:
|
|
132
|
+
"""Return a suggestion message if Docker is available but not configured."""
|
|
133
|
+
if not self.is_docker_available():
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
compose = project_dir / ".tweek" / "docker-compose.yaml"
|
|
137
|
+
if compose.exists():
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
"Docker detected. For container-level isolation, run:\n"
|
|
142
|
+
" tweek sandbox docker init"
|
|
143
|
+
)
|
tweek/sandbox/executor.py
CHANGED
|
@@ -14,6 +14,7 @@ Usage:
|
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
import os
|
|
17
|
+
import shlex
|
|
17
18
|
import subprocess
|
|
18
19
|
import tempfile
|
|
19
20
|
import json
|
|
@@ -166,10 +167,11 @@ class SandboxExecutor:
|
|
|
166
167
|
profile_path = self.generator.save(manifest)
|
|
167
168
|
|
|
168
169
|
# Build the sandboxed command
|
|
169
|
-
sandboxed_cmd = f'sandbox-exec -f
|
|
170
|
+
sandboxed_cmd = f'sandbox-exec -f {shlex.quote(str(profile_path))} /bin/bash -c {self._shell_quote(command)}'
|
|
170
171
|
|
|
171
|
-
# Set up environment
|
|
172
|
-
|
|
172
|
+
# Set up minimal environment (don't inherit all parent secrets)
|
|
173
|
+
safe_env_keys = {"PATH", "HOME", "USER", "SHELL", "TERM", "LANG", "LC_ALL", "TMPDIR"}
|
|
174
|
+
run_env = {k: v for k, v in os.environ.items() if k in safe_env_keys}
|
|
173
175
|
if env:
|
|
174
176
|
run_env.update(env)
|
|
175
177
|
|
|
@@ -327,10 +329,11 @@ class SandboxExecutor:
|
|
|
327
329
|
profile_path = self.generator.save(manifest)
|
|
328
330
|
|
|
329
331
|
# Build sandboxed command
|
|
330
|
-
sandboxed_cmd = f'sandbox-exec -f
|
|
332
|
+
sandboxed_cmd = f'sandbox-exec -f {shlex.quote(str(profile_path))} /bin/bash -c {self._shell_quote(command)}'
|
|
331
333
|
|
|
332
|
-
# Set up environment
|
|
333
|
-
|
|
334
|
+
# Set up minimal environment (don't inherit all parent secrets)
|
|
335
|
+
safe_env_keys = {"PATH", "HOME", "USER", "SHELL", "TERM", "LANG", "LC_ALL", "TMPDIR"}
|
|
336
|
+
run_env = {k: v for k, v in os.environ.items() if k in safe_env_keys}
|
|
334
337
|
if env:
|
|
335
338
|
run_env.update(env)
|
|
336
339
|
|
tweek/sandbox/layers.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tweek Sandbox Layers
|
|
3
|
+
|
|
4
|
+
Defines the isolation layer hierarchy and capability matrix.
|
|
5
|
+
|
|
6
|
+
Layer 0: Bypass - No isolation, global state only (opt-in escape hatch)
|
|
7
|
+
Layer 1: Skills Only - Skill Isolation Chamber (already built)
|
|
8
|
+
Layer 2: Project State - Per-project security DB, overrides, fingerprints, patterns (DEFAULT)
|
|
9
|
+
Layer 3+: Deferred - Filesystem/container isolation via Docker bridge
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from enum import IntEnum
|
|
13
|
+
from typing import Dict, Set
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IsolationLayer(IntEnum):
|
|
17
|
+
"""Isolation layers ordered by increasing security."""
|
|
18
|
+
|
|
19
|
+
BYPASS = 0 # No isolation, global state only
|
|
20
|
+
SKILLS = 1 # Skill Isolation Chamber only
|
|
21
|
+
PROJECT = 2 # Per-project security state (default)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_value(cls, value: int) -> "IsolationLayer":
|
|
25
|
+
"""Convert int to IsolationLayer, clamping to valid range."""
|
|
26
|
+
try:
|
|
27
|
+
return cls(value)
|
|
28
|
+
except ValueError:
|
|
29
|
+
if value < 0:
|
|
30
|
+
return cls.BYPASS
|
|
31
|
+
return cls.PROJECT
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Capability matrix: what each layer provides
|
|
35
|
+
LAYER_CAPABILITIES: Dict[IsolationLayer, Set[str]] = {
|
|
36
|
+
IsolationLayer.BYPASS: set(),
|
|
37
|
+
IsolationLayer.SKILLS: {
|
|
38
|
+
"skill_scanning",
|
|
39
|
+
"skill_fingerprints",
|
|
40
|
+
"skill_guard",
|
|
41
|
+
},
|
|
42
|
+
IsolationLayer.PROJECT: {
|
|
43
|
+
"skill_scanning",
|
|
44
|
+
"skill_fingerprints",
|
|
45
|
+
"skill_guard",
|
|
46
|
+
"project_security_db",
|
|
47
|
+
"project_overrides",
|
|
48
|
+
"project_fingerprints",
|
|
49
|
+
"project_config",
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def layer_has_capability(layer: IsolationLayer, capability: str) -> bool:
|
|
55
|
+
"""Check if a layer provides a specific capability."""
|
|
56
|
+
return capability in LAYER_CAPABILITIES.get(layer, set())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_layer_description(layer: IsolationLayer) -> str:
|
|
60
|
+
"""Human-readable description of what a layer provides."""
|
|
61
|
+
descriptions = {
|
|
62
|
+
IsolationLayer.BYPASS: (
|
|
63
|
+
"No isolation. All security state uses global ~/.tweek/. "
|
|
64
|
+
"Skills are not scanned. Use only for trusted projects."
|
|
65
|
+
),
|
|
66
|
+
IsolationLayer.SKILLS: (
|
|
67
|
+
"Skill Isolation Chamber active. Skills are scanned before install. "
|
|
68
|
+
"Security state uses global ~/.tweek/."
|
|
69
|
+
),
|
|
70
|
+
IsolationLayer.PROJECT: (
|
|
71
|
+
"Full project isolation. Security events, overrides, skill fingerprints, "
|
|
72
|
+
"and configuration are scoped to this project's .tweek/ directory. "
|
|
73
|
+
"Project overrides are additive-only (cannot weaken global security)."
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
return descriptions.get(layer, "Unknown layer")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Severity ranking for additive-only merge
|
|
80
|
+
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def stricter_severity(a: str, b: str) -> str:
|
|
84
|
+
"""Return the stricter (lower threshold) of two severity levels.
|
|
85
|
+
|
|
86
|
+
Severity thresholds define the MINIMUM severity to report:
|
|
87
|
+
- "low" = report low+medium+high+critical (screens the most, strictest)
|
|
88
|
+
- "critical" = report only critical (screens the least, most permissive)
|
|
89
|
+
|
|
90
|
+
Higher rank number = screens more things = stricter.
|
|
91
|
+
"""
|
|
92
|
+
rank_a = SEVERITY_ORDER.get(a, 3)
|
|
93
|
+
rank_b = SEVERITY_ORDER.get(b, 3)
|
|
94
|
+
# Higher rank = stricter = screens more things
|
|
95
|
+
if rank_a >= rank_b:
|
|
96
|
+
return a
|
|
97
|
+
return b
|
tweek/sandbox/linux.py
CHANGED