sproxy 0.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.
sproxy/cli.py ADDED
@@ -0,0 +1,293 @@
1
+ """sproxy command-line interface.
2
+
3
+ sproxy run [-c policy.toml] [--port N] [--insecure] [--log-file path]
4
+ [--no-reload] [-- <command...>]
5
+ (no command: hold the session open for `sproxy env`)
6
+ (edits to the policy file apply live)
7
+ sproxy init [-c path] write a starter policy
8
+ sproxy env print exports that join the live session
9
+ sproxy ca [-c policy.toml] print the CA cert path + trust hints
10
+ sproxy audit verify <log> check the audit chain is intact
11
+ sproxy version
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from sproxy import __version__, audit, ca
22
+ from sproxy.policy import PolicyError, default_policy, load
23
+ from sproxy.runner import RunnerError, _port_open, run, session_env_path
24
+
25
+ DEFAULT_POLICY_FILE = "sproxy.toml"
26
+
27
+ STARTER_POLICY = """\
28
+ # sproxy policy - local egress guard for coding agents.
29
+ # Run: sproxy run -- claude
30
+ #
31
+ # Saving this file applies it to a running session (`kill -HUP` forces it).
32
+ # Secret handles, database endpoints and the postgres token stay valid; only
33
+ # `port` and `confdir` need a restart.
34
+
35
+ # "enforce" blocks/redacts per the rules below. "monitor" only audits.
36
+ mode = "enforce"
37
+
38
+ # Proxy listen port on 127.0.0.1.
39
+ port = 8080
40
+
41
+ # What to do when a managed secret heads to a host it is not allowed to reach:
42
+ # "block", "redact", or "warn".
43
+ on_leak = "block"
44
+
45
+ # Detect credential-shaped strings (API keys, tokens, JWTs, private keys) that
46
+ # sproxy was never told about, and surface them in the audit log.
47
+ detect_patterns = true
48
+
49
+ # Tamper-evident JSONL audit log. "" disables it. Verify with `sproxy audit verify`.
50
+ audit_log = ".sproxy/audit.jsonl"
51
+
52
+ # Extra literal values to treat as secrets and block from leaving anywhere.
53
+ watch_values = []
54
+
55
+ # Hosts to tunnel WITHOUT TLS interception - use for clients that pin
56
+ # certificates (a TLS handshake error against a host usually means it pins).
57
+ # Their contents cannot be inspected, so they are recorded in the audit log as
58
+ # "passthrough / uninspected".
59
+ passthrough_hosts = []
60
+
61
+ # Secrets to inject and guard.
62
+ # name = "backend:path" -> no host restriction
63
+ # name = { source = "...", allow_hosts=[] } -> restricted to specific hosts
64
+ # Globs are supported ("*.github.com" does NOT match the bare "github.com", list the apex too).
65
+ # Backends: gopass:, pass:, vault: (vault:secret/app#field), env:, file:, literal:
66
+ # A valid environment-variable name (e.g. GITHUB_TOKEN) is also exported to the
67
+ # guarded process as an opaque, per-session handle. sproxy replaces that handle
68
+ # with the real value only for the secret's allowed hosts.
69
+ # In requests, write the placeholder {{NAME}} where the secret should go, e.g.
70
+ # Authorization: Bearer {{GITHUB_TOKEN}}
71
+ [secrets]
72
+ # GITHUB_TOKEN = { source = "gopass:personal/github/token", allow_hosts = ["github.com", "api.github.com"] }
73
+ # ANTHROPIC_API_KEY = { source = "env:ANTHROPIC_API_KEY", allow_hosts = ["api.anthropic.com"] }
74
+
75
+ # PostgreSQL access. sproxy terminates the wire protocol on a local endpoint:
76
+ # the client authenticates with a per-session token, and sproxy alone performs
77
+ # TLS + SCRAM against the real server - the password never enters the agent's
78
+ # environment. Inside the session:
79
+ # psql "$SPROXY_POSTGRES_DEVELOP" # endpoint + token + defaults, ready to use
80
+ # psql "$SPROXY_POSTGRES_DEVELOP dbname=other" # append params to override
81
+ # [postgres.develop]
82
+ # host = "db.example.com" # the real server; policy is the only place it's set
83
+ # port = 5432
84
+ # listen = 6432 # local endpoint port; omit for auto-assigned
85
+ # password_source = "gopass:work/postgres/develop/password"
86
+ # user = "app_readonly" # optional: default role (client's choice wins)
87
+ # database = "appdb" # optional: default database
88
+ # sslmode = "verify-full" # upstream TLS (default); "disable" only for local dev servers
89
+ # sslrootcert = "" # CA bundle (e.g. AWS RDS); "" = system trust store
90
+ """
91
+
92
+
93
+ def _split_command(argv: list[str]) -> tuple[list[str], list[str]]:
94
+ """Split ``argv`` at the first ``--`` into (options, command)."""
95
+ if "--" in argv:
96
+ i = argv.index("--")
97
+ return argv[:i], argv[i + 1 :]
98
+ return argv, []
99
+
100
+
101
+ def _user_policy_file() -> Path:
102
+ base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
103
+ return Path(base) / "sproxy" / DEFAULT_POLICY_FILE
104
+
105
+
106
+ def _find_policy_file() -> Path | None:
107
+ """First existing policy: ./sproxy.toml, then the user-level config."""
108
+ for candidate in (Path(DEFAULT_POLICY_FILE), _user_policy_file()):
109
+ if candidate.exists():
110
+ return candidate
111
+ return None
112
+
113
+
114
+ def _load_policy(path: str | None) -> tuple["object", Path | None]:
115
+ """The policy plus the file it came from (None when there was no file)."""
116
+ if path:
117
+ return load(path), Path(path)
118
+ found = _find_policy_file()
119
+ if found is not None:
120
+ print(f"sproxy: using policy {found.resolve()}", file=sys.stderr)
121
+ if found == Path(DEFAULT_POLICY_FILE) and _user_policy_file().exists():
122
+ # Easy to be caught out by: editing the user-level config has no
123
+ # effect on a session started in a directory that has its own.
124
+ print(
125
+ f"sproxy: note: {_user_policy_file()} is ignored while this "
126
+ f"directory has its own {DEFAULT_POLICY_FILE}",
127
+ file=sys.stderr,
128
+ )
129
+ return load(found), found
130
+ print(
131
+ f"sproxy: no {DEFAULT_POLICY_FILE} found here or at {_user_policy_file()} - "
132
+ f"running in monitor mode (audit only, no blocking). "
133
+ f"Run `sproxy init` to configure enforcement.",
134
+ file=sys.stderr,
135
+ )
136
+ return default_policy(), None
137
+
138
+
139
+ def cmd_run(opts: argparse.Namespace, command: list[str]) -> int:
140
+ try:
141
+ policy, config_path = _load_policy(opts.config)
142
+ except FileNotFoundError:
143
+ print(f"error: policy file not found: {opts.config}", file=sys.stderr)
144
+ return 2
145
+ except PolicyError as exc:
146
+ print(f"error: invalid policy: {exc}", file=sys.stderr)
147
+ return 2
148
+ try:
149
+ return run(
150
+ policy,
151
+ command,
152
+ insecure=opts.insecure,
153
+ mitmdump=opts.mitmdump,
154
+ log_file=opts.log_file,
155
+ config_path=config_path,
156
+ port=opts.port,
157
+ watch_config=not opts.no_reload,
158
+ )
159
+ except RunnerError as exc:
160
+ print(f"error: {exc}", file=sys.stderr)
161
+ return 1
162
+
163
+
164
+ def cmd_init(opts: argparse.Namespace) -> int:
165
+ target = Path(opts.config or DEFAULT_POLICY_FILE)
166
+ if target.exists() and not opts.force:
167
+ print(f"error: {target} already exists (use --force to overwrite)", file=sys.stderr)
168
+ return 1
169
+ target.write_text(STARTER_POLICY, encoding="utf-8")
170
+ print(f"wrote {target}. Edit it, then: sproxy run -- claude")
171
+ return 0
172
+
173
+
174
+ def cmd_env(opts: argparse.Namespace) -> int:
175
+ """Print export lines that join this shell to the live sproxy session."""
176
+ path = session_env_path()
177
+ try:
178
+ content = path.read_text(encoding="utf-8")
179
+ except FileNotFoundError:
180
+ print("sproxy: no live session (start one with `sproxy run`)", file=sys.stderr)
181
+ return 1
182
+ port = None
183
+ for line in content.splitlines():
184
+ if line.startswith("# sproxy-port:"):
185
+ try:
186
+ port = int(line.split(":", 1)[1])
187
+ except ValueError:
188
+ pass
189
+ if port is not None and not _port_open(port):
190
+ print(
191
+ f"sproxy: found {path} but nothing is listening on port {port} - "
192
+ "the session is gone (stale file)",
193
+ file=sys.stderr,
194
+ )
195
+ return 1
196
+ print(content, end="")
197
+ return 0
198
+
199
+
200
+ def cmd_ca(opts: argparse.Namespace) -> int:
201
+ confdir = None
202
+ if opts.config:
203
+ try:
204
+ confdir = load(opts.config).confdir or None
205
+ except (FileNotFoundError, PolicyError):
206
+ confdir = None
207
+ cert = ca.cert_path(confdir)
208
+ print(cert)
209
+ if not cert.exists():
210
+ print(
211
+ "note: cert not generated yet - it appears after mitmdump runs once "
212
+ "(e.g. via `sproxy run`).",
213
+ file=sys.stderr,
214
+ )
215
+ return 0
216
+
217
+
218
+ def cmd_audit(opts: argparse.Namespace) -> int:
219
+ ok, count, message = audit.verify(opts.log)
220
+ print(message)
221
+ return 0 if ok else 1
222
+
223
+
224
+ def build_parser() -> argparse.ArgumentParser:
225
+ p = argparse.ArgumentParser(prog="sproxy", description="Local egress guard for coding agents.")
226
+ sub = p.add_subparsers(dest="cmd", required=True)
227
+
228
+ r = sub.add_parser(
229
+ "run",
230
+ help="run a command behind the guard; with no command, hold a session "
231
+ 'open for other shells to join via eval "$(sproxy env)"',
232
+ )
233
+ r.add_argument("-c", "--config", help=f"policy file (default: ./{DEFAULT_POLICY_FILE})")
234
+ r.add_argument("--port", type=int, help="override the listen port")
235
+ r.add_argument("--insecure", action="store_true", help="disable upstream TLS verification")
236
+ r.add_argument("--mitmdump", help="path to the mitmdump binary")
237
+ r.add_argument(
238
+ "--log-file",
239
+ help="write sproxy/mitmproxy output here, leaving the command's terminal untouched",
240
+ )
241
+ r.add_argument(
242
+ "--no-reload",
243
+ action="store_true",
244
+ help="pin the policy for the whole session instead of applying edits to "
245
+ "the file as they are saved (SIGHUP also stops reloading)",
246
+ )
247
+
248
+ i = sub.add_parser("init", help="write a starter policy file")
249
+ i.add_argument("-c", "--config", help=f"output path (default: ./{DEFAULT_POLICY_FILE})")
250
+ i.add_argument("--force", action="store_true", help="overwrite an existing file")
251
+
252
+ sub.add_parser(
253
+ "env",
254
+ help='print export lines joining this shell to the live session: eval "$(sproxy env)"',
255
+ )
256
+
257
+ c = sub.add_parser("ca", help="print the mitmproxy CA cert path")
258
+ c.add_argument("-c", "--config", help="policy file (to read confdir)")
259
+
260
+ a = sub.add_parser("audit", help="audit-log tools")
261
+ asub = a.add_subparsers(dest="audit_cmd", required=True)
262
+ av = asub.add_parser("verify", help="verify the audit chain is intact")
263
+ av.add_argument("log", help="path to the audit .jsonl file")
264
+
265
+ sub.add_parser("version", help="print version")
266
+ return p
267
+
268
+
269
+ def main(argv: list[str] | None = None) -> int:
270
+ raw = list(sys.argv[1:] if argv is None else argv)
271
+ before, command = _split_command(raw)
272
+ parser = build_parser()
273
+ opts = parser.parse_args(before)
274
+
275
+ if opts.cmd == "run":
276
+ return cmd_run(opts, command)
277
+ if opts.cmd == "init":
278
+ return cmd_init(opts)
279
+ if opts.cmd == "env":
280
+ return cmd_env(opts)
281
+ if opts.cmd == "ca":
282
+ return cmd_ca(opts)
283
+ if opts.cmd == "audit":
284
+ return cmd_audit(opts)
285
+ if opts.cmd == "version":
286
+ print(f"sproxy {__version__}")
287
+ return 0
288
+ parser.print_help()
289
+ return 2
290
+
291
+
292
+ if __name__ == "__main__":
293
+ raise SystemExit(main())