anonproxy 0.1.1__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.
anonproxy/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ Anonproxy — a reversible anonymization layer for sending pentest data to LLMs.
3
+
4
+ A more reliable successor to the match/replace approach: deterministic
5
+ format-preserving surrogates, a session-consistent vault, multi-pass detection
6
+ (regex floor + optional local LLM + known-entity rescan), and — most importantly
7
+ — a *tolerant, streaming-safe restorer* that puts the real values back even when
8
+ the model mangles a surrogate (markdown, backticks, line wraps, case changes) or
9
+ splits it across streaming chunks.
10
+
11
+ Public API:
12
+ from anonproxy import Engine
13
+ eng = Engine(engagement="acme-2026")
14
+ safe = eng.anonymize(text) # real -> surrogate
15
+ back = eng.deanonymize(safe) # surrogate -> real (tolerant)
16
+ """
17
+
18
+ from .engine import Engine
19
+ from .config import Settings
20
+
21
+ __all__ = ["Engine", "Settings"]
22
+ __version__ = "0.1.0"
anonproxy/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
anonproxy/audit.py ADDED
@@ -0,0 +1,195 @@
1
+ """
2
+ Audit dashboard — a self-contained page for reviewing what was anonymized.
3
+
4
+ Served by the proxy at ``/audit``. It shows the live ``original → surrogate``
5
+ mapping for the current engagement, filterable by entity type, with counts, so
6
+ you can verify coverage at a glance during an engagement and export the table
7
+ for your evidence trail at close.
8
+
9
+ Security: the page exposes the reverse lookup, so it is bound to the proxy's
10
+ listen address (localhost by default — reach a VPS instance only over the SSH
11
+ tunnel) and honours ``ANONPROXY_API_TOKEN`` if set. Disable entirely with
12
+ ``ANONPROXY_AUDIT=false``.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ import json
18
+
19
+
20
+ def render_page(engagement: str, token_required: bool) -> str:
21
+ # token (if any) is read from the page URL ?token=... and forwarded as a
22
+ # header on the data fetch, so it never needs to be embedded server-side.
23
+ return _PAGE.replace("__ENGAGEMENT__", html.escape(engagement)) \
24
+ .replace("__TOKEN_REQUIRED__", json.dumps(token_required))
25
+
26
+
27
+ _PAGE = r"""<!doctype html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1">
32
+ <title>Anonproxy audit — __ENGAGEMENT__</title>
33
+ <style>
34
+ :root { color-scheme: dark; }
35
+ body { font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
36
+ margin: 0; background: #0d1117; color: #c9d1d9; }
37
+ header { padding: 16px 20px; border-bottom: 1px solid #21262d;
38
+ display: flex; gap: 20px; align-items: baseline; flex-wrap: wrap; }
39
+ h1 { font-size: 16px; margin: 0; color: #58a6ff; }
40
+ .eng { color: #8b949e; }
41
+ .toolbar { padding: 12px 20px; display: flex; gap: 10px; flex-wrap: wrap;
42
+ align-items: center; border-bottom: 1px solid #21262d; }
43
+ input, select, button { background: #161b22; color: #c9d1d9;
44
+ border: 1px solid #30363d; border-radius: 6px; padding: 6px 10px;
45
+ font: inherit; }
46
+ button { cursor: pointer; }
47
+ button:hover { border-color: #58a6ff; }
48
+ .stats { padding: 10px 20px; color: #8b949e; display: flex; gap: 16px;
49
+ flex-wrap: wrap; }
50
+ .pill { background: #161b22; border: 1px solid #30363d; border-radius: 999px;
51
+ padding: 2px 10px; }
52
+ table { width: 100%; border-collapse: collapse; }
53
+ th, td { text-align: left; padding: 8px 20px; border-bottom: 1px solid #21262d;
54
+ vertical-align: top; word-break: break-all; }
55
+ th { position: sticky; top: 0; background: #0d1117; color: #8b949e;
56
+ font-weight: 600; cursor: pointer; }
57
+ tr:hover td { background: #11161d; }
58
+ .type { color: #d2a8ff; }
59
+ .orig { color: #ff7b72; }
60
+ .surr { color: #7ee787; }
61
+ .muted { color: #6e7681; }
62
+ .err { color: #ff7b72; padding: 20px; }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <header>
67
+ <h1>🛡️ Anonproxy audit</h1>
68
+ <span class="eng">engagement: <b>__ENGAGEMENT__</b></span>
69
+ <span class="muted" id="updated"></span>
70
+ </header>
71
+ <div class="toolbar">
72
+ <input id="q" placeholder="filter (original / surrogate / type)" size="34">
73
+ <select id="type"><option value="">all types</option></select>
74
+ <button id="refresh">↻ refresh</button>
75
+ <button id="csv">⬇ export CSV</button>
76
+ <label class="muted"><input type="checkbox" id="auto"> auto-refresh 5s</label>
77
+ </div>
78
+ <div class="stats" id="stats"></div>
79
+ <table>
80
+ <thead><tr>
81
+ <th data-k="entity_type">type</th>
82
+ <th data-k="original">original</th>
83
+ <th data-k="surrogate">surrogate</th>
84
+ </tr></thead>
85
+ <tbody id="rows"></tbody>
86
+ </table>
87
+ <div id="error" class="err"></div>
88
+
89
+ <script>
90
+ const TOKEN_REQUIRED = __TOKEN_REQUIRED__;
91
+ // token via ?token= (legacy) or #token= (preferred — fragments are never sent
92
+ // to the server, so they stay out of access logs; they do land in history
93
+ // either way, so clear the URL after reading)
94
+ const params = new URLSearchParams(location.search);
95
+ let token = params.get("token") || "";
96
+ if (!token && location.hash.startsWith("#token=")) {
97
+ try { token = decodeURIComponent(location.hash.slice("#token=".length)); }
98
+ catch { token = location.hash.slice("#token=".length); }
99
+ }
100
+ if (token) history.replaceState(null, "", location.pathname + location.search);
101
+ let data = [], sortKey = "entity_type", sortAsc = true, timer = null;
102
+
103
+ function headers() {
104
+ const h = {};
105
+ if (TOKEN_REQUIRED && token) h["X-Anonproxy-Token"] = token;
106
+ return h;
107
+ }
108
+
109
+ async function load() {
110
+ document.getElementById("error").textContent = "";
111
+ try {
112
+ const [ex, st] = await Promise.all([
113
+ fetch("/anonproxy/export", {headers: headers()}),
114
+ fetch("/anonproxy/stats", {headers: headers()}),
115
+ ]);
116
+ if (!ex.ok) throw new Error("export " + ex.status + (ex.status===401?" — add ?token=…":""));
117
+ data = (await ex.json()).mappings || [];
118
+ renderStats(await st.json());
119
+ populateTypes();
120
+ render();
121
+ document.getElementById("updated").textContent =
122
+ "updated " + new Date().toLocaleTimeString();
123
+ } catch (e) {
124
+ document.getElementById("error").textContent = "Error: " + e.message;
125
+ }
126
+ }
127
+
128
+ function renderStats(s) {
129
+ const el = document.getElementById("stats");
130
+ const parts = [`<span class="pill">total: <b>${s.total||0}</b></span>`];
131
+ // entity types can originate from LLM output parsed out of hostile client
132
+ // traffic — never interpolate them into innerHTML unescaped
133
+ for (const [k, v] of Object.entries(s.by_type || {}))
134
+ parts.push(`<span class="pill">${esc(k)}: ${v}</span>`);
135
+ for (const [k, v] of Object.entries(s.detector_failures || {}))
136
+ if (v) parts.push(`<span class="pill" style="border-color:#f85149;color:#ff7b72">⚠ ${esc(k)} failed ×${v}</span>`);
137
+ el.innerHTML = parts.join("");
138
+ }
139
+
140
+ function populateTypes() {
141
+ const sel = document.getElementById("type");
142
+ const cur = sel.value;
143
+ const types = [...new Set(data.map(d => d.entity_type))].sort();
144
+ sel.innerHTML = '<option value="">all types</option>' +
145
+ types.map(t => `<option ${t===cur?"selected":""}>${esc(t)}</option>`).join("");
146
+ }
147
+
148
+ function render() {
149
+ const q = document.getElementById("q").value.toLowerCase();
150
+ const t = document.getElementById("type").value;
151
+ let rows = data.filter(d =>
152
+ (!t || d.entity_type === t) &&
153
+ (!q || (d.original+d.surrogate+d.entity_type).toLowerCase().includes(q)));
154
+ rows.sort((a,b) => {
155
+ const x=(a[sortKey]||"").toString(), y=(b[sortKey]||"").toString();
156
+ return sortAsc ? x.localeCompare(y) : y.localeCompare(x);
157
+ });
158
+ document.getElementById("rows").innerHTML = rows.map(d => `<tr>
159
+ <td class="type">${esc(d.entity_type)}</td>
160
+ <td class="orig">${esc(d.original)}</td>
161
+ <td class="surr">${esc(d.surrogate)}</td></tr>`).join("") ||
162
+ '<tr><td colspan="3" class="muted">no mappings yet</td></tr>';
163
+ }
164
+
165
+ function esc(s){return (s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));}
166
+
167
+ function toCSV() {
168
+ const head = "entity_type,original,surrogate\n";
169
+ const body = data.map(d =>
170
+ [d.entity_type, d.original, d.surrogate]
171
+ .map(v => '"'+(v||"").replace(/"/g,'""')+'"').join(",")).join("\n");
172
+ const blob = new Blob([head+body], {type:"text/csv"});
173
+ const a = document.createElement("a");
174
+ a.href = URL.createObjectURL(blob);
175
+ a.download = "anonproxy-__ENGAGEMENT__.csv";
176
+ a.click();
177
+ }
178
+
179
+ document.getElementById("q").oninput = render;
180
+ document.getElementById("type").onchange = render;
181
+ document.getElementById("refresh").onclick = load;
182
+ document.getElementById("csv").onclick = toCSV;
183
+ document.getElementById("auto").onchange = e => {
184
+ clearInterval(timer);
185
+ if (e.target.checked) timer = setInterval(load, 5000);
186
+ };
187
+ document.querySelectorAll("th[data-k]").forEach(th => th.onclick = () => {
188
+ const k = th.dataset.k;
189
+ sortAsc = sortKey === k ? !sortAsc : true;
190
+ sortKey = k; render();
191
+ });
192
+ load();
193
+ </script>
194
+ </body>
195
+ </html>"""
anonproxy/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """Command line entrypoint.
2
+
3
+ python -m anonproxy serve # start the proxy
4
+ python -m anonproxy anon < file.txt # anonymize stdin
5
+ python -m anonproxy deanon < file.txt # restore stdin
6
+ python -m anonproxy stats # vault stats
7
+ python -m anonproxy export # dump mappings as JSON
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ from dataclasses import asdict
17
+ from pathlib import Path
18
+
19
+ from .config import Settings
20
+ from .engine import Engine
21
+
22
+
23
+ def _load_dotenv(path: str = ".env") -> None:
24
+ """Load KEY=VALUE pairs without overriding real env vars.
25
+
26
+ Searched in order: ``path`` (the cwd convention), then the repo root
27
+ next to the installed package — so the CLI picks up your settings from
28
+ any working directory, and after moving/renaming the repo.
29
+ """
30
+ candidates = [Path(path)]
31
+ pkg_root_env = Path(__file__).resolve().parent.parent / ".env"
32
+ if pkg_root_env.resolve() != Path(path).resolve():
33
+ candidates.append(pkg_root_env)
34
+ for p in candidates:
35
+ if not p.exists():
36
+ continue
37
+ for line in p.read_text().splitlines():
38
+ line = line.strip()
39
+ if not line or line.startswith("#") or "=" not in line:
40
+ continue
41
+ key, _, val = line.partition("=")
42
+ os.environ.setdefault(key.strip(), val.strip())
43
+
44
+
45
+ def main(argv=None) -> int:
46
+ _load_dotenv()
47
+ p = argparse.ArgumentParser(prog="anonproxy", description="Reversible LLM anonymization proxy")
48
+ p.add_argument("--engagement", help="engagement id (overrides $ENGAGEMENT_ID)")
49
+ sub = p.add_subparsers(dest="cmd", required=True)
50
+
51
+ s = sub.add_parser("serve", help="run the reverse proxy")
52
+ s.add_argument("--host", default=None)
53
+ s.add_argument("--port", type=int, default=None)
54
+ s.add_argument("--model", default=None,
55
+ help="Ollama model to use (overrides OLLAMA_MODEL), e.g. qwen3:4b, qwen3.6:27b")
56
+ s.add_argument("--scope", default=None,
57
+ help="comma list of client domains/hostnames/orgs to always anonymize")
58
+ s.add_argument("--scope-file", default=None, dest="scope_file",
59
+ help="path to a scope file (one term per line, optional value=TYPE)")
60
+
61
+ sub.add_parser("wizard", help="interactive local setup")
62
+ sub.add_parser("audit", help="open the audit dashboard in a browser")
63
+ v = sub.add_parser("verify", help="run tool-output fixtures and report leaks")
64
+ v.add_argument("--no-llm", action="store_true", help="regex-only (skip Ollama)")
65
+ v.add_argument("--model", default=None, help="Ollama model to verify with")
66
+ v.add_argument("--scope", default=None, help="comma list of scope terms")
67
+ v.add_argument("--scope-file", default=None, dest="scope_file", help="path to a scope file")
68
+ v.add_argument("--show-mappings", "--audit", action="store_true", dest="show_mappings",
69
+ help="print the anonymized output + original→surrogate table")
70
+ a = sub.add_parser("anon", help="anonymize stdin")
71
+ a.add_argument("--scope", default=None, help="comma list of scope terms")
72
+ a.add_argument("--scope-file", default=None, dest="scope_file", help="path to a scope file")
73
+ sub.add_parser("deanon", help="deanonymize stdin")
74
+ sub.add_parser("stats", help="show vault stats")
75
+ sub.add_parser("export", help="dump mappings")
76
+
77
+ # --- profiles: per-test context, one JSON each ---------------------------
78
+ prof = sub.add_parser("profile", help="manage engagement profiles")
79
+ pp = prof.add_subparsers(dest="pcmd", required=True)
80
+ pnew = pp.add_parser("new", help="create a profile")
81
+ pnew.add_argument("name", help="profile/engagement name (e.g. acme-web)")
82
+ pnew.add_argument("--scope", default="", help="comma list of scope terms")
83
+ pnew.add_argument("--scope-file", default=None, dest="scope_file")
84
+ pnew.add_argument("--detectors", default=None, help="comma chain (default regex,ollama)")
85
+ pnew.add_argument("--model", default=None, help="Ollama model")
86
+ pnew.add_argument("--ephemeral", action="store_true",
87
+ help="in-memory vault for this engagement (nothing on disk)")
88
+ pnew.add_argument("--port", type=int, default=8099)
89
+ pnew.add_argument("--notes", default="")
90
+ pp.add_parser("list", help="list profiles")
91
+ pshow = pp.add_parser("show", help="print one profile as JSON")
92
+ pshow.add_argument("name")
93
+ ped = pp.add_parser("edit", help="open a profile's JSON in your editor")
94
+ ped.add_argument("name")
95
+ prm = pp.add_parser("rm", help="delete a profile (vault/exports untouched)")
96
+ prm.add_argument("name")
97
+
98
+ up = sub.add_parser("up", help="start the proxy from a profile (default: most recent)")
99
+ up.add_argument("profile", nargs="?", default=None)
100
+ up.add_argument("--daemon", action="store_true",
101
+ help="run detached; manage with `anonproxy stop`")
102
+ up.add_argument("--host", default=None)
103
+ sub.add_parser("stop", help="stop daemonized `up` instance(s)")
104
+ envp = sub.add_parser("env", help="print/copy client env lines for a profile")
105
+ envp.add_argument("profile", nargs="?", default=None)
106
+ envp.add_argument("--copy", action="store_true", dest="do_copy")
107
+ closep = sub.add_parser("close", help="export mappings to evidence files + wipe vault")
108
+ closep.add_argument("profile", nargs="?", default=None)
109
+ closep.add_argument("--keep-vault", action="store_true", dest="keep_vault")
110
+
111
+ args = p.parse_args(argv)
112
+
113
+ if args.cmd == "wizard":
114
+ from .wizard import run as wizard_run
115
+ return wizard_run()
116
+
117
+ # --- profiles: pure file management, no Settings needed ------------------
118
+ from .profiles import (Profile, ProfileStore, client_env_lines,
119
+ close_engagement, copy_to_clipboard, open_in_editor,
120
+ sanitize_name)
121
+ store = ProfileStore()
122
+
123
+ def _resolve_profile(name: str | None) -> Profile:
124
+ prof = store.get(name) if name else store.most_recent()
125
+ if prof is None and name is None:
126
+ prof = Profile(name="default") # first run: sensible starter
127
+ store.save(prof)
128
+ if prof is None:
129
+ known = ", ".join(pr.name for pr in store.list()) or "(none yet)"
130
+ raise SystemExit(f"no profile {name!r} — known: {known}\n"
131
+ f"create one: anonproxy profile new {name}")
132
+ return prof
133
+
134
+ if args.cmd == "profile":
135
+ if args.pcmd == "new":
136
+ name = sanitize_name(args.name)
137
+ if store.exists(name):
138
+ raise SystemExit(f"profile {name!r} already exists "
139
+ f"({store.path(name)})")
140
+ prof = Profile(name=name, notes=args.notes, port=args.port,
141
+ ephemeral=args.ephemeral,
142
+ scope_file=args.scope_file or "")
143
+ if args.scope:
144
+ prof.scope_terms = [x.strip() for x in args.scope.split(",") if x.strip()]
145
+ if args.detectors:
146
+ prof.detectors = [x.strip() for x in args.detectors.split(",") if x.strip()]
147
+ if args.model:
148
+ prof.ollama_model = args.model
149
+ path = store.save(prof)
150
+ print(f"saved {path}")
151
+ print(f"start it: anonproxy up {name}"
152
+ + (" (--daemon to detach)" if not args.ephemeral else ""))
153
+ print(f"client env: anonproxy env {name}")
154
+ return 0
155
+ if args.pcmd == "list":
156
+ rows = store.list()
157
+ if not rows:
158
+ print("no profiles yet — create one: anonproxy profile new <name>")
159
+ return 0
160
+ print(f"{'NAME':<24} {'PORT':<6} {'DETECTORS':<28} EPHEMERAL NOTES")
161
+ for pr in rows:
162
+ last = time.strftime("%Y-%m-%d", time.localtime(pr.last_used_at)) \
163
+ if pr.last_used_at else "never"
164
+ notes = f"{pr.notes} (last used {last})" if pr.notes else \
165
+ (f"(last used {last})" if pr.last_used_at else "")
166
+ print(f"{pr.name:<24} {pr.port:<6} "
167
+ f"{','.join(pr.detectors):<28} "
168
+ f"{'yes' if pr.ephemeral else 'no':<9} {notes}")
169
+ return 0
170
+ if args.pcmd == "show":
171
+ prof = _resolve_profile(args.name)
172
+ print(json.dumps(asdict(prof), indent=2))
173
+ return 0
174
+ if args.pcmd == "edit":
175
+ prof = _resolve_profile(args.name)
176
+ open_in_editor(store.path(prof.name))
177
+ print(f"opened {store.path(prof.name)}")
178
+ return 0
179
+ if args.pcmd == "rm":
180
+ if store.delete(args.name):
181
+ print(f"deleted {args.name} (vault/exports untouched)")
182
+ return 0
183
+ raise SystemExit(f"no profile {args.name!r}")
184
+ raise SystemExit(f"unknown profile command {args.pcmd!r}")
185
+
186
+ settings = Settings()
187
+ if args.engagement:
188
+ settings.engagement_id = args.engagement
189
+ if getattr(args, "model", None):
190
+ settings.ollama_model = args.model
191
+ if getattr(args, "scope", None):
192
+ settings.scope_terms = [x.strip() for x in args.scope.split(",") if x.strip()]
193
+ if getattr(args, "scope_file", None):
194
+ settings.scope_file = args.scope_file
195
+
196
+ if args.cmd == "up":
197
+ prof = _resolve_profile(args.profile)
198
+ store.touch(prof.name)
199
+ prof.apply(settings)
200
+ if getattr(args, "host", None):
201
+ settings.host = args.host
202
+ if args.daemon:
203
+ return _spawn_daemon(["up", prof.name], settings)
204
+ _print_banner(settings)
205
+ _serve(settings)
206
+ return 0
207
+
208
+ if args.cmd == "stop":
209
+ return _stop_daemons()
210
+
211
+ if args.cmd == "env":
212
+ prof = _resolve_profile(args.profile)
213
+ lines = client_env_lines(prof, host=settings.host)
214
+ text = "\n".join(lines)
215
+ if args.do_copy:
216
+ ok = copy_to_clipboard(text + "\n")
217
+ print(("copied to clipboard:\n" if ok else "clipboard unavailable, print only:\n")
218
+ + text)
219
+ else:
220
+ print(text)
221
+ audit = f"http://{settings.host}:{prof.port}/audit"
222
+ print(f"# audit: {audit}" +
223
+ (" (token-gated: see ANONPROXY_API_TOKEN)" if settings.engine_api_token else ""))
224
+ return 0
225
+
226
+ if args.cmd == "close":
227
+ prof = _resolve_profile(args.profile)
228
+ prof.apply(settings)
229
+ result = close_engagement(settings, keep_vault=args.keep_vault)
230
+ if not result["count"]:
231
+ print("no mappings on disk for this engagement (ephemeral session? "
232
+ "close while the proxy still holds them)")
233
+ print(f"mappings exported: {result['count']}")
234
+ print(f" json: {result['json']}")
235
+ print(f" csv : {result['csv']}")
236
+ print(f"vault removed: {'yes' if result['vault_removed'] else 'no'}"
237
+ + (" (--keep-vault)" if args.keep_vault else ""))
238
+ return 0
239
+
240
+ if args.cmd == "verify":
241
+ from . import verify
242
+ report = verify.run(settings, use_llm=not args.no_llm)
243
+ verify.print_report(report, show_mappings=args.show_mappings)
244
+ tcp = report["tool_call_probe"]
245
+ hard_fail = (report["total_leaks"] or report["roundtrip_failures"]
246
+ or report["preserved_failures"]
247
+ or report["adversarial"]["leaked"]
248
+ or tcp["anthropic_tool_use_leak"] or tcp["openai_tool_call_leak"])
249
+ return 1 if hard_fail else 0
250
+
251
+ if args.cmd == "audit":
252
+ import webbrowser
253
+ from urllib.parse import quote
254
+ url = f"http://{settings.host}:{settings.port}/audit"
255
+ if settings.engine_api_token:
256
+ # fragment, not query: #fragments are never sent to the server, so
257
+ # the token stays out of access logs; the page reads location.hash.
258
+ url += "#token=" + quote(settings.engine_api_token, safe="")
259
+ print(f"Opening {url}")
260
+ webbrowser.open(url)
261
+ return 0
262
+
263
+ if args.cmd == "serve":
264
+ import uvicorn
265
+ from .proxy.app import create_app
266
+ if args.host:
267
+ settings.host = args.host
268
+ if args.port:
269
+ settings.port = args.port
270
+ _print_banner(settings)
271
+ _serve(settings)
272
+ return 0
273
+
274
+ engine = Engine(settings=settings)
275
+ if args.cmd == "anon":
276
+ sys.stdout.write(engine.anonymize(sys.stdin.read()))
277
+ elif args.cmd == "deanon":
278
+ sys.stdout.write(engine.deanonymize(sys.stdin.read()))
279
+ elif args.cmd == "stats":
280
+ print(json.dumps(engine.stats(), indent=2))
281
+ elif args.cmd == "export":
282
+ print(json.dumps(engine.export(), indent=2))
283
+ return 0
284
+
285
+
286
+ def _print_banner(settings) -> None:
287
+ print(f"Anonproxy listening on http://{settings.host}:{settings.port} "
288
+ f"(engagement={settings.engagement_id})", file=sys.stderr)
289
+ print(" Claude Code: export ANTHROPIC_BASE_URL=http://"
290
+ f"{settings.host}:{settings.port}", file=sys.stderr)
291
+ print(" OpenAI SDK: base_url=http://"
292
+ f"{settings.host}:{settings.port}/v1", file=sys.stderr)
293
+
294
+
295
+ def _serve(settings) -> None:
296
+ import uvicorn
297
+ from .proxy.app import create_app
298
+ uvicorn.run(create_app(settings), host=settings.host,
299
+ port=settings.port, log_level="info")
300
+
301
+
302
+ def _run_dir() -> Path:
303
+ d = Path.home() / ".anonproxy" / "run"
304
+ d.mkdir(parents=True, exist_ok=True)
305
+ return d
306
+
307
+
308
+ def _spawn_daemon(child_argv: list[str], settings) -> int:
309
+ """Detach `up` so the terminal is free; `anonproxy stop` reaps it later."""
310
+ import subprocess
311
+ log_dir = Path.home() / ".anonproxy" / "logs"
312
+ log_dir.mkdir(parents=True, exist_ok=True)
313
+ log_path = log_dir / f"{settings.engagement_id}.log"
314
+ with open(log_path, "ab") as logf:
315
+ proc = subprocess.Popen(
316
+ [sys.executable, "-m", "anonproxy", *child_argv],
317
+ stdin=subprocess.DEVNULL, stdout=logf, stderr=subprocess.STDOUT,
318
+ start_new_session=True, cwd=os.getcwd(),
319
+ )
320
+ pidfile = _run_dir() / f"up-{settings.port}.pid"
321
+ pidfile.write_text(str(proc.pid))
322
+ print(f"started pid {proc.pid} (engagement={settings.engagement_id}, "
323
+ f"port={settings.port})")
324
+ print(f" logs: {log_path}")
325
+ print(f" stop: anonproxy stop")
326
+ return 0
327
+
328
+
329
+ def _stop_daemons() -> int:
330
+ import signal
331
+ import time as _time
332
+ stopped = 0
333
+ for pidfile in sorted(_run_dir().glob("*.pid")):
334
+ try:
335
+ pid = int(pidfile.read_text().strip())
336
+ except ValueError:
337
+ pidfile.unlink()
338
+ continue
339
+ try:
340
+ os.kill(pid, signal.SIGTERM)
341
+ for _ in range(20): # up to ~2s for a clean shutdown
342
+ try:
343
+ os.kill(pid, 0)
344
+ except ProcessLookupError:
345
+ break
346
+ _time.sleep(0.1)
347
+ else:
348
+ os.kill(pid, signal.SIGKILL)
349
+ stopped += 1
350
+ print(f"stopped pid {pid} ({pidfile.name})")
351
+ except (ProcessLookupError, PermissionError):
352
+ print(f"pid {pid} not running — clearing stale pidfile")
353
+ finally:
354
+ try:
355
+ pidfile.unlink()
356
+ except FileNotFoundError:
357
+ pass
358
+ if not stopped:
359
+ print("no daemonized instances found "
360
+ "(menubar-managed proxies stop from the menu)")
361
+ return 0
362
+
363
+
364
+ if __name__ == "__main__":
365
+ raise SystemExit(main())