@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.
@@ -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
@@ -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)
@@ -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
+ )