@ao_zorin/zocket 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/bin/zocket-setup.cjs +12 -0
- package/bin/zocket.cjs +174 -0
- package/docs/AI_AUTODEPLOY.md +52 -0
- package/docs/CLIENTS_MCP.md +59 -0
- package/docs/INSTALL.md +288 -0
- package/docs/LOCAL_MODELS.md +95 -0
- package/package.json +52 -0
- package/pyproject.toml +29 -0
- package/scripts/ai-autodeploy.py +127 -0
- package/scripts/install-zocket.ps1 +116 -0
- package/scripts/install-zocket.sh +228 -0
- package/zocket/__init__.py +2 -0
- package/zocket/__main__.py +5 -0
- package/zocket/audit.py +76 -0
- package/zocket/auth.py +34 -0
- package/zocket/autostart.py +281 -0
- package/zocket/backup.py +33 -0
- package/zocket/cli.py +655 -0
- package/zocket/config_store.py +68 -0
- package/zocket/crypto.py +158 -0
- package/zocket/harden.py +136 -0
- package/zocket/i18n.py +216 -0
- package/zocket/mcp_server.py +249 -0
- package/zocket/paths.py +50 -0
- package/zocket/runner.py +108 -0
- package/zocket/templates/index.html +1062 -0
- package/zocket/templates/login.html +244 -0
- package/zocket/vault.py +331 -0
- package/zocket/web.py +490 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
from .audit import AuditLogger
|
|
6
|
+
from .config_store import ConfigStore
|
|
7
|
+
from .paths import config_path
|
|
8
|
+
from .runner import ExecPolicyError, run_with_env_limited
|
|
9
|
+
from .vault import ProjectNotFoundError, SecretNotFoundError, ValidationError, VaultService
|
|
10
|
+
|
|
11
|
+
MCPMode = Literal["metadata", "admin"]
|
|
12
|
+
|
|
13
|
+
MCP_INSTRUCTIONS = (
|
|
14
|
+
"Zocket MCP provides project metadata and a safe command runner. "
|
|
15
|
+
"Secret values are never returned. To use secrets, call run_with_project_env "
|
|
16
|
+
"and pass command arguments with $VAR or ${VAR} placeholders. "
|
|
17
|
+
"Those placeholders are substituted from the project environment before execution. "
|
|
18
|
+
"Do not try to print or exfiltrate secrets."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _public_project_metadata(rows: list[dict]) -> list[dict]:
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
"project": row.get("project"),
|
|
26
|
+
"folder_path": row.get("folder_path"),
|
|
27
|
+
"secret_count": row.get("secret_count"),
|
|
28
|
+
"updated_at": row.get("updated_at"),
|
|
29
|
+
}
|
|
30
|
+
for row in rows
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _public_key_metadata(rows: list[dict]) -> list[dict]:
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
"key": row.get("key"),
|
|
38
|
+
"has_value": row.get("has_value"),
|
|
39
|
+
"updated_at": row.get("updated_at"),
|
|
40
|
+
}
|
|
41
|
+
for row in rows
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_server(
|
|
46
|
+
vault: VaultService,
|
|
47
|
+
mode: MCPMode = "metadata",
|
|
48
|
+
audit: AuditLogger | None = None,
|
|
49
|
+
host: str = "127.0.0.1",
|
|
50
|
+
port: int = 18002,
|
|
51
|
+
) -> FastMCP:
|
|
52
|
+
mcp = FastMCP(name="zocket", host=host, port=port, instructions=MCP_INSTRUCTIONS)
|
|
53
|
+
|
|
54
|
+
def _exec_policy() -> tuple[list[str], bool, int, bool, bool]:
|
|
55
|
+
cfg = ConfigStore(config_path()).load()
|
|
56
|
+
allowlist = cfg.get("exec_allowlist") or []
|
|
57
|
+
return_output = bool(cfg.get("exec_return_output", False))
|
|
58
|
+
allow_full_output = bool(cfg.get("exec_allow_full_output", False))
|
|
59
|
+
substitute_env = bool(cfg.get("exec_substitute_env", True))
|
|
60
|
+
try:
|
|
61
|
+
max_output = int(cfg.get("exec_max_output_chars", 0))
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
max_output = 0
|
|
64
|
+
if not return_output:
|
|
65
|
+
max_output = 0
|
|
66
|
+
return allowlist, return_output, max_output, allow_full_output, substitute_env
|
|
67
|
+
|
|
68
|
+
@mcp.tool(
|
|
69
|
+
description=(
|
|
70
|
+
"List all known projects from local zocket vault. "
|
|
71
|
+
"Secret values are never returned."
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
def list_projects() -> list:
|
|
75
|
+
if audit:
|
|
76
|
+
audit.log("mcp.list_projects", "ok", "mcp")
|
|
77
|
+
return _public_project_metadata(vault.list_projects())
|
|
78
|
+
|
|
79
|
+
@mcp.tool(
|
|
80
|
+
description=(
|
|
81
|
+
"List secret keys for a project without returning secret values."
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
def list_project_keys(project: str) -> list:
|
|
85
|
+
if audit:
|
|
86
|
+
audit.log("mcp.list_project_keys", "ok", "mcp", {"project": project})
|
|
87
|
+
return _public_key_metadata(
|
|
88
|
+
vault.list_project_secrets(project, include_values=False)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@mcp.tool(
|
|
92
|
+
description=(
|
|
93
|
+
"Find a project by local filesystem path. "
|
|
94
|
+
"Returns best match by longest folder prefix."
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
def find_project_by_path(path: str) -> dict:
|
|
98
|
+
match = vault.find_project_by_path(path)
|
|
99
|
+
if audit:
|
|
100
|
+
audit.log(
|
|
101
|
+
"mcp.find_project_by_path",
|
|
102
|
+
"ok",
|
|
103
|
+
"mcp",
|
|
104
|
+
{"path": path, "matched": bool(match)},
|
|
105
|
+
)
|
|
106
|
+
if not match:
|
|
107
|
+
return {"status": "not_found"}
|
|
108
|
+
return {
|
|
109
|
+
"status": "ok",
|
|
110
|
+
"project": match.get("project"),
|
|
111
|
+
"folder_path": match.get("folder_path"),
|
|
112
|
+
"secret_count": match.get("secret_count"),
|
|
113
|
+
"updated_at": match.get("updated_at"),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if mode == "admin":
|
|
117
|
+
@mcp.tool(
|
|
118
|
+
description=(
|
|
119
|
+
"Create or update a secret for project. "
|
|
120
|
+
"Use env-like key names, e.g. SSH_HOST / SSH_PASSWORD."
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
def upsert_secret(
|
|
124
|
+
project: str, key: str, value: str, description: str = ""
|
|
125
|
+
) -> dict:
|
|
126
|
+
vault.upsert_secret(project=project, key=key, value=value, description=description)
|
|
127
|
+
if audit:
|
|
128
|
+
audit.log("mcp.upsert_secret", "ok", "mcp", {"project": project, "key": key})
|
|
129
|
+
return {"status": "ok", "message": f"Saved {key} in project {project}"}
|
|
130
|
+
|
|
131
|
+
@mcp.tool(description="Delete a secret key from project.")
|
|
132
|
+
def delete_secret(project: str, key: str) -> dict:
|
|
133
|
+
vault.delete_secret(project=project, key=key)
|
|
134
|
+
if audit:
|
|
135
|
+
audit.log("mcp.delete_secret", "ok", "mcp", {"project": project, "key": key})
|
|
136
|
+
return {"status": "ok", "message": f"Deleted {key} from project {project}"}
|
|
137
|
+
|
|
138
|
+
@mcp.tool(description="Delete project and all its secrets.")
|
|
139
|
+
def delete_project(project: str) -> dict:
|
|
140
|
+
vault.delete_project(project=project)
|
|
141
|
+
if audit:
|
|
142
|
+
audit.log("mcp.delete_project", "ok", "mcp", {"project": project})
|
|
143
|
+
return {"status": "ok", "message": f"Deleted project {project}"}
|
|
144
|
+
|
|
145
|
+
@mcp.tool(
|
|
146
|
+
description=(
|
|
147
|
+
"Run local command with project secrets injected into process ENV. "
|
|
148
|
+
"Secret values are not returned and output is redacted."
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
def run_with_project_env(project: str, command: list, full_output: bool = False) -> dict:
|
|
152
|
+
if not isinstance(command, list) or not command:
|
|
153
|
+
return {"status": "error", "error": "Command must be a non-empty list."}
|
|
154
|
+
env = vault.get_project_env(project)
|
|
155
|
+
allowlist, return_output, max_output, allow_full_output, substitute_env = _exec_policy()
|
|
156
|
+
if full_output and not allow_full_output:
|
|
157
|
+
return {"status": "denied", "error": "Full output is not allowed."}
|
|
158
|
+
output_limit = None if full_output else (max_output if return_output else 0)
|
|
159
|
+
try:
|
|
160
|
+
result = run_with_env_limited(
|
|
161
|
+
command=command,
|
|
162
|
+
project_env=env,
|
|
163
|
+
allowlist=allowlist,
|
|
164
|
+
max_output_chars=output_limit,
|
|
165
|
+
substitute_env=substitute_env,
|
|
166
|
+
)
|
|
167
|
+
except ExecPolicyError as exc:
|
|
168
|
+
if audit:
|
|
169
|
+
audit.log(
|
|
170
|
+
"mcp.run_with_project_env",
|
|
171
|
+
"denied",
|
|
172
|
+
"mcp",
|
|
173
|
+
{"project": project, "reason": str(exc)},
|
|
174
|
+
)
|
|
175
|
+
return {"status": "denied", "error": str(exc)}
|
|
176
|
+
if audit:
|
|
177
|
+
audit.log(
|
|
178
|
+
"mcp.run_with_project_env",
|
|
179
|
+
"ok",
|
|
180
|
+
"mcp",
|
|
181
|
+
{"project": project, "exit_code": result.exit_code},
|
|
182
|
+
)
|
|
183
|
+
payload = {"status": "ok", "exit_code": result.exit_code}
|
|
184
|
+
if full_output or return_output:
|
|
185
|
+
payload["stdout"] = result.stdout
|
|
186
|
+
payload["stderr"] = result.stderr
|
|
187
|
+
return payload
|
|
188
|
+
|
|
189
|
+
@mcp.tool(
|
|
190
|
+
description=(
|
|
191
|
+
"Describe execution policy for run_with_project_env, including "
|
|
192
|
+
"allowed commands, output limits, and whether ${VAR} substitution is enabled."
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
def get_exec_policy() -> dict:
|
|
196
|
+
allowlist, return_output, max_output, allow_full_output, substitute_env = _exec_policy()
|
|
197
|
+
return {
|
|
198
|
+
"status": "ok",
|
|
199
|
+
"allowlist": allowlist,
|
|
200
|
+
"return_output": return_output,
|
|
201
|
+
"max_output_chars": max_output,
|
|
202
|
+
"allow_full_output": allow_full_output,
|
|
203
|
+
"substitute_env": substitute_env,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@mcp.tool(
|
|
207
|
+
description=(
|
|
208
|
+
"Create an empty project. This is optional, because upsert_secret "
|
|
209
|
+
"creates project automatically."
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
def create_project(
|
|
213
|
+
project: str, description: str = "", folder_path: str = ""
|
|
214
|
+
) -> dict:
|
|
215
|
+
vault.create_project(
|
|
216
|
+
project=project,
|
|
217
|
+
description=description,
|
|
218
|
+
folder_path=folder_path,
|
|
219
|
+
)
|
|
220
|
+
if audit:
|
|
221
|
+
audit.log("mcp.create_project", "ok", "mcp", {"project": project})
|
|
222
|
+
return {"status": "ok", "message": f"Project created: {project}"}
|
|
223
|
+
|
|
224
|
+
@mcp.tool(
|
|
225
|
+
description=(
|
|
226
|
+
"Health check for zocket MCP server. Use this to verify server is alive."
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
def ping() -> dict:
|
|
230
|
+
if audit:
|
|
231
|
+
audit.log("mcp.ping", "ok", "mcp", {"mode": mode})
|
|
232
|
+
return {"status": "ok", "name": "zocket", "mode": mode}
|
|
233
|
+
|
|
234
|
+
return mcp
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def run_server(
|
|
238
|
+
vault: VaultService,
|
|
239
|
+
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
|
|
240
|
+
mode: MCPMode = "metadata",
|
|
241
|
+
audit: AuditLogger | None = None,
|
|
242
|
+
host: str = "127.0.0.1",
|
|
243
|
+
port: int = 18002,
|
|
244
|
+
) -> None:
|
|
245
|
+
server = create_server(vault, mode=mode, audit=audit, host=host, port=port)
|
|
246
|
+
try:
|
|
247
|
+
server.run(transport=transport)
|
|
248
|
+
except (ValidationError, ProjectNotFoundError, SecretNotFoundError) as exc:
|
|
249
|
+
raise RuntimeError(str(exc)) from exc
|
package/zocket/paths.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def zocket_home() -> Path:
|
|
8
|
+
default = Path.home() / ".zocket"
|
|
9
|
+
return Path(os.environ.get("ZOCKET_HOME", str(default))).expanduser()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def vault_path() -> Path:
|
|
13
|
+
default = zocket_home() / "vault.enc"
|
|
14
|
+
return Path(os.environ.get("ZOCKET_VAULT_PATH", str(default))).expanduser()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def key_path() -> Path:
|
|
18
|
+
default = zocket_home() / "master.key"
|
|
19
|
+
return Path(os.environ.get("ZOCKET_KEY_PATH", str(default))).expanduser()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def lock_path() -> Path:
|
|
23
|
+
default = zocket_home() / "vault.lock"
|
|
24
|
+
return Path(os.environ.get("ZOCKET_LOCK_PATH", str(default))).expanduser()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def config_path() -> Path:
|
|
28
|
+
default = zocket_home() / "config.json"
|
|
29
|
+
return Path(os.environ.get("ZOCKET_CONFIG_PATH", str(default))).expanduser()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def audit_log_path() -> Path:
|
|
33
|
+
default = zocket_home() / "audit.log"
|
|
34
|
+
return Path(os.environ.get("ZOCKET_AUDIT_LOG_PATH", str(default))).expanduser()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def backups_dir() -> Path:
|
|
38
|
+
default = zocket_home() / "backups"
|
|
39
|
+
return Path(os.environ.get("ZOCKET_BACKUPS_DIR", str(default))).expanduser()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_dirs() -> None:
|
|
43
|
+
home = zocket_home()
|
|
44
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
vault_path().parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
key_path().parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
lock_path().parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
config_path().parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
audit_log_path().parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
backups_dir().mkdir(parents=True, exist_ok=True)
|
package/zocket/runner.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def redact_text(text: str, secrets: Sequence[str]) -> str:
|
|
11
|
+
redacted = text
|
|
12
|
+
# Replace longest secrets first to avoid partial leaking.
|
|
13
|
+
for secret in sorted(set(secrets), key=len, reverse=True):
|
|
14
|
+
if secret:
|
|
15
|
+
redacted = redacted.replace(secret, "***REDACTED***")
|
|
16
|
+
return redacted
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RunResult:
|
|
21
|
+
exit_code: int
|
|
22
|
+
stdout: str
|
|
23
|
+
stderr: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ExecPolicyError(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ENV_PATTERN = re.compile(r"\$(\w+)|\$\{([^}]+)\}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _substitute_env_vars(args: Sequence[str], env: dict[str, str]) -> list[str]:
|
|
34
|
+
resolved: list[str] = []
|
|
35
|
+
for arg in args:
|
|
36
|
+
def _replace(match: re.Match[str]) -> str:
|
|
37
|
+
key = match.group(1) or match.group(2) or ""
|
|
38
|
+
return env.get(key, match.group(0))
|
|
39
|
+
resolved.append(_ENV_PATTERN.sub(_replace, arg))
|
|
40
|
+
return resolved
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _enforce_allowlist(command: Sequence[str], allowlist: Sequence[str]) -> None:
|
|
44
|
+
if not command:
|
|
45
|
+
raise ExecPolicyError("Command is required.")
|
|
46
|
+
base = os.path.basename(command[0])
|
|
47
|
+
if base == "sudo":
|
|
48
|
+
raise ExecPolicyError("sudo is not allowed.")
|
|
49
|
+
if allowlist and base not in set(allowlist):
|
|
50
|
+
raise ExecPolicyError(f"Command '{base}' is not allowed by policy.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_with_env(command: Sequence[str], project_env: dict[str, str]) -> RunResult:
|
|
54
|
+
if not command:
|
|
55
|
+
raise ValueError("Command is required.")
|
|
56
|
+
|
|
57
|
+
env = os.environ.copy()
|
|
58
|
+
env.update(project_env)
|
|
59
|
+
completed = subprocess.run(
|
|
60
|
+
list(command),
|
|
61
|
+
env=env,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
check=False,
|
|
65
|
+
)
|
|
66
|
+
secrets = list(project_env.values())
|
|
67
|
+
return RunResult(
|
|
68
|
+
exit_code=completed.returncode,
|
|
69
|
+
stdout=redact_text(completed.stdout, secrets),
|
|
70
|
+
stderr=redact_text(completed.stderr, secrets),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_with_env_limited(
|
|
75
|
+
*,
|
|
76
|
+
command: Sequence[str],
|
|
77
|
+
project_env: dict[str, str],
|
|
78
|
+
allowlist: Sequence[str] | None = None,
|
|
79
|
+
max_output_chars: int | None = 0,
|
|
80
|
+
substitute_env: bool = True,
|
|
81
|
+
) -> RunResult:
|
|
82
|
+
if not command:
|
|
83
|
+
raise ValueError("Command is required.")
|
|
84
|
+
allowlist = list(allowlist or [])
|
|
85
|
+
_enforce_allowlist(command, allowlist)
|
|
86
|
+
env = os.environ.copy()
|
|
87
|
+
env.update(project_env)
|
|
88
|
+
resolved = _substitute_env_vars(command, project_env) if substitute_env else list(command)
|
|
89
|
+
completed = subprocess.run(
|
|
90
|
+
list(resolved),
|
|
91
|
+
env=env,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
check=False,
|
|
95
|
+
)
|
|
96
|
+
secrets = list(project_env.values())
|
|
97
|
+
stdout = redact_text(completed.stdout, secrets)
|
|
98
|
+
stderr = redact_text(completed.stderr, secrets)
|
|
99
|
+
if max_output_chars is None:
|
|
100
|
+
return RunResult(exit_code=completed.returncode, stdout=stdout, stderr=stderr)
|
|
101
|
+
limit = max(int(max_output_chars), 0)
|
|
102
|
+
if limit == 0:
|
|
103
|
+
return RunResult(exit_code=completed.returncode, stdout="", stderr="")
|
|
104
|
+
return RunResult(
|
|
105
|
+
exit_code=completed.returncode,
|
|
106
|
+
stdout=stdout[:limit],
|
|
107
|
+
stderr=stderr[:limit],
|
|
108
|
+
)
|