pressbridge 1.1.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.
@@ -0,0 +1,3 @@
1
+ """PressBridge: give Claude safe, audited access to WordPress sites."""
2
+
3
+ __version__ = "1.1.0"
@@ -0,0 +1,166 @@
1
+ """Command line entry point.
2
+
3
+ pressbridge serve --config config.yaml # stdio (Claude Desktop / Claude Code, local)
4
+ pressbridge serve --config config.yaml --http # HTTPS-ready HTTP server (Pro)
5
+ pressbridge check --config config.yaml # validate config and WP-CLI access
6
+ pressbridge token # print a new random token
7
+ pressbridge watch run|daemon|status|report|test-alert # security watch (Agent)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import secrets
14
+ import shutil
15
+ import sys
16
+
17
+ from . import __version__
18
+ from .config import ConfigError, load_config
19
+ from .edition import EDITION
20
+
21
+
22
+ def _cmd_check(cfg) -> int:
23
+ from . import wpcli
24
+
25
+ ok = True
26
+ print(f"PressBridge {__version__} ({EDITION})")
27
+ print(f"Data dir: {cfg.data_dir}")
28
+ if not shutil.which(cfg.wp_bin):
29
+ print(f" ! WP-CLI '{cfg.wp_bin}' not found on PATH")
30
+ ok = False
31
+ for s in cfg.sites.values():
32
+ print(f"- {s.name}: root={s.root} mode={s.mode} wp={s.wp_path or 'none'} profile={s.wp_profile}")
33
+ if s.wp_path and shutil.which(cfg.wp_bin):
34
+ r = wpcli.run(cfg, s, ["core", "version"])
35
+ if r["exit_code"] == 0:
36
+ print(f" WordPress {r['stdout'].strip()}")
37
+ else:
38
+ ok = False
39
+ print(f" ! WP-CLI failed: {r['stderr'].strip()[:300]}")
40
+ print("OK" if ok else "Problems found")
41
+ return 0 if ok else 1
42
+
43
+
44
+ def _cmd_watch(cfg, a) -> int:
45
+ if EDITION != "agent":
46
+ print("PressBridge Watch is part of PressBridge Agent (https://pressbridgesupport.github.io/pressbridge).", file=sys.stderr)
47
+ return 2
48
+ from .watch import parse_settings
49
+ from .watch import runner
50
+
51
+ try:
52
+ settings = parse_settings(cfg.watch)
53
+ except ConfigError as e:
54
+ print(f"Config error: {e}", file=sys.stderr)
55
+ return 2
56
+
57
+ if a.watch_cmd == "status":
58
+ print(runner.status(cfg, settings))
59
+ return 0
60
+ if a.watch_cmd == "report":
61
+ p = runner.watch_dir(cfg) / "reports" / "latest.txt"
62
+ print(p.read_text() if p.exists() else "No report yet. Run: pressbridge watch run")
63
+ return 0
64
+ if a.watch_cmd == "test-alert":
65
+ errors = runner.test_alert(settings)
66
+ for e in errors:
67
+ print(f"! {e}", file=sys.stderr)
68
+ if not errors:
69
+ print("Test alert sent.")
70
+ return 1 if errors else 0
71
+ if a.watch_cmd == "daemon":
72
+ try:
73
+ runner.daemon(cfg, settings, run_now=a.now, log=lambda m: print(m, flush=True))
74
+ except KeyboardInterrupt:
75
+ pass
76
+ return 0
77
+
78
+ try:
79
+ result = runner.run_once(cfg, settings, sites=a.site, notify=not a.no_notify,
80
+ use_claude=not a.no_claude, force_notify=a.notify_always,
81
+ save=not a.dry_run)
82
+ except (RuntimeError, ValueError) as e:
83
+ print(f"Error: {e}", file=sys.stderr)
84
+ return 3
85
+ print(result.report_path.read_text() if result.report_path else "")
86
+ if result.notified:
87
+ print("Alert sent.")
88
+ for e in result.delivery_errors:
89
+ print(f"! Alert delivery failed: {e}", file=sys.stderr)
90
+ return result.exit_code
91
+
92
+
93
+ def main(argv: list[str] | None = None) -> int:
94
+ p = argparse.ArgumentParser(prog="pressbridge", description="Safe WordPress access for Claude")
95
+ p.add_argument("--version", action="version", version=f"pressbridge {__version__} ({EDITION})")
96
+ sub = p.add_subparsers(dest="cmd", required=True)
97
+
98
+ serve = sub.add_parser("serve", help="Run the MCP server")
99
+ serve.add_argument("--config", default=os.environ.get("PRESSBRIDGE_CONFIG", "/config/config.yaml"))
100
+ serve.add_argument("--http", action="store_true", help="Serve over HTTP instead of stdio (Pro)")
101
+ serve.add_argument("--port", type=int, default=int(os.environ.get("PRESSBRIDGE_PORT", "8765")))
102
+ serve.add_argument("--host", default=os.environ.get("PRESSBRIDGE_HOST", "0.0.0.0"),
103
+ help="Interface to bind in HTTP mode (use 127.0.0.1 behind a proxy on the host network)")
104
+
105
+ check = sub.add_parser("check", help="Validate the config and WP-CLI access")
106
+ check.add_argument("--config", default=os.environ.get("PRESSBRIDGE_CONFIG", "/config/config.yaml"))
107
+
108
+ sub.add_parser("token", help="Generate a random bearer token")
109
+
110
+ watch = sub.add_parser("watch", help="Security watch: nightly checks and alerts (Agent edition)")
111
+ watch.add_argument("--config", default=os.environ.get("PRESSBRIDGE_CONFIG", "/config/config.yaml"))
112
+ wsub = watch.add_subparsers(dest="watch_cmd", required=True)
113
+ w_run = wsub.add_parser("run", help="Check all sites once now")
114
+ w_run.add_argument("--site", action="append", help="Only this site (repeatable)")
115
+ w_run.add_argument("--no-notify", action="store_true", help="Don't send alerts")
116
+ w_run.add_argument("--no-claude", action="store_true", help="Skip Claude's review")
117
+ w_run.add_argument("--notify-always", action="store_true", help="Send the report even if all is clear")
118
+ w_run.add_argument("--dry-run", action="store_true", help="Don't save the new baseline (compare only)")
119
+ w_daemon = wsub.add_parser("daemon", help="Run on the schedule, forever (for Docker or systemd)")
120
+ w_daemon.add_argument("--now", action="store_true", help="Also run once at start")
121
+ wsub.add_parser("status", help="Show the last run, the next run and each site's baseline")
122
+ wsub.add_parser("test-alert", help="Send a test alert to every configured channel")
123
+ wsub.add_parser("report", help="Print the latest report")
124
+
125
+ a = p.parse_args(argv)
126
+ if a.cmd == "token":
127
+ print(secrets.token_hex(32))
128
+ return 0
129
+
130
+ try:
131
+ cfg = load_config(a.config)
132
+ except ConfigError as e:
133
+ print(f"Config error: {e}", file=sys.stderr)
134
+ return 2
135
+
136
+ if a.cmd == "check":
137
+ return _cmd_check(cfg)
138
+
139
+ if a.cmd == "watch":
140
+ return _cmd_watch(cfg, a)
141
+
142
+ if a.http:
143
+ if EDITION == "free":
144
+ print("HTTP mode is a PressBridge Pro feature. Free runs over stdio.", file=sys.stderr)
145
+ return 2
146
+ import uvicorn
147
+
148
+ from .server import build_http_app
149
+
150
+ try:
151
+ app = build_http_app(cfg)
152
+ except ConfigError as e:
153
+ print(f"Config error: {e}", file=sys.stderr)
154
+ return 2
155
+ uvicorn.run(app, host=a.host, port=a.port, log_level="info", proxy_headers=True,
156
+ forwarded_allow_ips="*")
157
+ return 0
158
+
159
+ from .server import build_server
160
+
161
+ build_server(cfg).run("stdio")
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ sys.exit(main())
pressbridge/audit.py ADDED
@@ -0,0 +1,58 @@
1
+ """Append-only JSON Lines audit log of every tool call."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import threading
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ _lock = threading.Lock()
10
+ _MAX_FIELD = 500
11
+
12
+
13
+ def _shorten(value):
14
+ if isinstance(value, str) and len(value) > _MAX_FIELD:
15
+ return value[:_MAX_FIELD] + f"... [{len(value)} chars]"
16
+ if isinstance(value, list):
17
+ return [_shorten(v) for v in value]
18
+ if isinstance(value, dict):
19
+ return {k: _shorten(v) for k, v in value.items()}
20
+ return value
21
+
22
+
23
+ def record(log_path: Path, *, tool: str, site: str | None, args: dict, ok: bool, error: str | None = None,
24
+ result_summary: dict | None = None) -> None:
25
+ entry = {
26
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
27
+ "tool": tool,
28
+ "site": site,
29
+ "args": _shorten(args),
30
+ "ok": ok,
31
+ }
32
+ if error:
33
+ entry["error"] = error[:_MAX_FIELD]
34
+ if result_summary:
35
+ entry["result"] = result_summary
36
+ line = json.dumps(entry, ensure_ascii=False)
37
+ with _lock:
38
+ log_path.parent.mkdir(parents=True, exist_ok=True)
39
+ with log_path.open("a", encoding="utf-8") as f:
40
+ f.write(line + "\n")
41
+
42
+
43
+ def tail(log_path: Path, limit: int = 50, site: str | None = None) -> list[dict]:
44
+ if not log_path.exists():
45
+ return []
46
+ lines = log_path.read_text(encoding="utf-8").splitlines()
47
+ out: list[dict] = []
48
+ for line in reversed(lines):
49
+ try:
50
+ item = json.loads(line)
51
+ except json.JSONDecodeError:
52
+ continue
53
+ if site and item.get("site") != site:
54
+ continue
55
+ out.append(item)
56
+ if len(out) >= limit:
57
+ break
58
+ return out
pressbridge/config.py ADDED
@@ -0,0 +1,208 @@
1
+ """Configuration loading for PressBridge.
2
+
3
+ A single YAML file describes the WordPress sites the server may touch,
4
+ what each one is allowed to do, and where audit logs and backups live.
5
+ Secrets (bearer tokens) are pulled from environment variables so the
6
+ config file itself can be committed safely.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ from .edition import EDITION
18
+
19
+ DEFAULT_DENY = [
20
+ "wp-config*",
21
+ ".env",
22
+ ".env.*",
23
+ ".htpasswd",
24
+ ".git",
25
+ ".git/*",
26
+ "*.sql",
27
+ "*.sql.gz",
28
+ "*.sqlite",
29
+ "*.sqlite3",
30
+ ".ht.sqlite",
31
+ "*.wpress",
32
+ "backup*.zip",
33
+ "*.key",
34
+ "*.pem",
35
+ "id_rsa*",
36
+ "debug.log",
37
+ ]
38
+
39
+ VALID_MODES = ("read", "write")
40
+ VALID_PROFILES = ("readonly", "maintenance")
41
+ _ENV_REF = re.compile(r"\$\{([A-Z0-9_]+)\}")
42
+
43
+
44
+ class ConfigError(ValueError):
45
+ """Raised when the config file is missing or invalid."""
46
+
47
+
48
+ @dataclass
49
+ class Site:
50
+ name: str
51
+ root: Path
52
+ mode: str = "read"
53
+ wp_path: Path | None = None
54
+ wp_profile: str = "readonly"
55
+ wp_url: str | None = None
56
+ deny: list[str] = field(default_factory=lambda: list(DEFAULT_DENY))
57
+ max_read_bytes: int = 512_000
58
+ max_write_bytes: int = 2_000_000
59
+ watched: bool = True
60
+
61
+ @property
62
+ def writable(self) -> bool:
63
+ return self.mode == "write"
64
+
65
+
66
+ @dataclass
67
+ class Config:
68
+ sites: dict[str, Site]
69
+ data_dir: Path
70
+ wp_bin: str = "wp"
71
+ wp_timeout: int = 120
72
+ tokens: list[str] = field(default_factory=list)
73
+ allowed_hosts: list[str] = field(default_factory=list)
74
+ wp_env: list[str] = field(default_factory=list)
75
+ watch: dict = field(default_factory=dict)
76
+
77
+ @property
78
+ def audit_log(self) -> Path:
79
+ return self.data_dir / "audit.jsonl"
80
+
81
+ def backup_root(self, site: Site) -> Path:
82
+ return self.data_dir / "backups" / site.name
83
+
84
+ def trash_root(self, site: Site) -> Path:
85
+ return self.data_dir / "trash" / site.name
86
+
87
+ def site(self, name: str | None) -> Site:
88
+ if not name:
89
+ if len(self.sites) == 1:
90
+ return next(iter(self.sites.values()))
91
+ raise ConfigError(
92
+ "Several sites are configured; pass `site`. Known sites: "
93
+ + ", ".join(sorted(self.sites))
94
+ )
95
+ try:
96
+ return self.sites[name]
97
+ except KeyError:
98
+ raise ConfigError(
99
+ f"Unknown site '{name}'. Known sites: " + ", ".join(sorted(self.sites))
100
+ ) from None
101
+
102
+
103
+ def _expand_env(value):
104
+ if isinstance(value, str):
105
+ def repl(m: re.Match) -> str:
106
+ # Unset variables become "", and empty tokens/hosts are dropped later.
107
+ # HTTP mode refuses to start without a token, so this cannot open a hole.
108
+ return os.environ.get(m.group(1), "")
109
+ return _ENV_REF.sub(repl, value)
110
+ if isinstance(value, list):
111
+ return [_expand_env(v) for v in value]
112
+ if isinstance(value, dict):
113
+ return {k: _expand_env(v) for k, v in value.items()}
114
+ return value
115
+
116
+
117
+ def _parse_site(name: str, raw: dict) -> Site:
118
+ if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,62}", name):
119
+ raise ConfigError(f"Site name '{name}' must be lowercase letters, digits, '-' or '_'")
120
+ if not isinstance(raw, dict) or "root" not in raw:
121
+ raise ConfigError(f"Site '{name}' needs a `root` path")
122
+ root = Path(raw["root"]).expanduser()
123
+ if not root.is_absolute():
124
+ raise ConfigError(f"Site '{name}': root must be an absolute path")
125
+ if not root.is_dir():
126
+ raise ConfigError(f"Site '{name}': root {root} does not exist or is not a directory")
127
+
128
+ mode = raw.get("mode", "read")
129
+ if mode not in VALID_MODES:
130
+ raise ConfigError(f"Site '{name}': mode must be one of {VALID_MODES}")
131
+ profile = raw.get("wp_profile", "readonly")
132
+ if profile not in VALID_PROFILES:
133
+ raise ConfigError(f"Site '{name}': wp_profile must be one of {VALID_PROFILES}")
134
+ if profile == "maintenance" and mode != "write":
135
+ raise ConfigError(f"Site '{name}': wp_profile 'maintenance' requires mode 'write'")
136
+
137
+ wp_path = raw.get("wp_path")
138
+ wp_path = Path(wp_path) if wp_path else None
139
+ if wp_path is None and (root / "wp-includes").is_dir():
140
+ wp_path = root
141
+
142
+ deny = list(DEFAULT_DENY)
143
+ if raw.get("replace_default_deny"):
144
+ deny = []
145
+ deny += list(raw.get("deny", []))
146
+ for pattern in raw.get("allow", []):
147
+ if pattern in deny:
148
+ deny.remove(pattern)
149
+
150
+ return Site(
151
+ name=name,
152
+ root=root.resolve(),
153
+ mode=mode,
154
+ wp_path=wp_path.resolve() if wp_path else None,
155
+ wp_profile=profile,
156
+ wp_url=raw.get("wp_url"),
157
+ deny=deny,
158
+ max_read_bytes=int(raw.get("max_read_bytes", 512_000)),
159
+ max_write_bytes=int(raw.get("max_write_bytes", 2_000_000)),
160
+ watched=bool(raw.get("watch", True)),
161
+ )
162
+
163
+
164
+ def load_config(path: str | Path) -> Config:
165
+ path = Path(path)
166
+ if not path.is_file():
167
+ raise ConfigError(f"Config file not found: {path}")
168
+ try:
169
+ raw = yaml.safe_load(path.read_text()) or {}
170
+ except yaml.YAMLError as e:
171
+ raise ConfigError(f"Config file is not valid YAML: {e}") from e
172
+ raw = _expand_env(raw)
173
+
174
+ sites_raw = raw.get("sites") or {}
175
+ if not sites_raw:
176
+ raise ConfigError("Config needs at least one entry under `sites`")
177
+ sites = {name: _parse_site(name, s) for name, s in sites_raw.items()}
178
+
179
+ if EDITION == "free":
180
+ if len(sites) > 1:
181
+ raise ConfigError(
182
+ "PressBridge Free supports one site. Remove the extra sites or upgrade to Pro."
183
+ )
184
+ for s in sites.values():
185
+ s.mode = "read"
186
+ s.wp_profile = "readonly"
187
+
188
+ data_dir = Path(raw.get("data_dir", "/data")).expanduser()
189
+ data_dir.mkdir(parents=True, exist_ok=True)
190
+
191
+ tokens = raw.get("tokens") or []
192
+ if isinstance(tokens, str):
193
+ tokens = [tokens]
194
+ tokens = [t for t in tokens if t]
195
+ for t in tokens:
196
+ if len(t) < 24:
197
+ raise ConfigError("Each token must be at least 24 characters (use `openssl rand -hex 32`)")
198
+
199
+ return Config(
200
+ sites=sites,
201
+ data_dir=data_dir.resolve(),
202
+ wp_bin=raw.get("wp_bin", "wp"),
203
+ wp_timeout=int(raw.get("wp_timeout", 120)),
204
+ tokens=tokens,
205
+ allowed_hosts=[h for h in (raw.get("allowed_hosts") or []) if h],
206
+ wp_env=[str(v) for v in (raw.get("wp_env") or [])],
207
+ watch=raw.get("watch") or {},
208
+ )
pressbridge/edition.py ADDED
@@ -0,0 +1 @@
1
+ EDITION = "free"