diff --git a/README.md b/README.md index 7253a10..59ab104 100644 --- a/README.md +++ b/README.md @@ -1,273 +1,73 @@ -# ghost +# ghost ๐Ÿ‘ป -**An incognito, unrestricted agentic harness.** Censorship-resistant open intelligence that -runs a frontier open model over a hardened privacy path, drops to a fully-offline local -model on demand, and phones home to no one. +**A private, unrestricted agentic harness.** ghost is a real terminal agent -- it runs commands, edits files, executes code, and searches the web -- routing every hosted request through OpenGradient's private TEE gateway. It answers what you actually ask, in full, with no refusals or moralizing, and can drop to a fully-offline local model where nothing leaves the box. Built on the [Hermes Agent](https://github.com/NousResearch/hermes-agent) engine by Nous Research, wired to OpenGradient's gateway and to only open-weight, unrestricted models. -ghost is built on the [Hermes Agent](https://hermes-agent.nousresearch.com) engine by Nous -Research, wrapped to route inference through OpenGradient's private TEE gateway. It keeps its -own engine copy at `~/.ghost-engine` (so any existing `hermes` install is left untouched), -launches as the `ghost` command, and sends every hosted request through a PII/secret scrubber -and then through **[og-veil](https://github.com/OpenGradient/veil)**, which relays it over -**Oblivious HTTP (OHTTP) to the OpenGradient chat-api TEE gateway** -- the same private path the -[chat.opengradient.ai](https://chat.opengradient.ai) website uses. Only genuinely unrestricted, -open-weight models are offered (the Hermes family); closed, refusing models are deliberately -left out. - -> **Uncensored by default.** ghost answers everything. The privacy layer governs what leaks -> *out*, never what ghost can *do*. Nothing is filtered, moralized, or redacted in its replies. + + + + + + + +
Private by constructionEvery hosted request is HPKE/OHTTP-encrypted by og-veil and run inside a TEE enclave: the relay sees only ciphertext and the enclave never sees who you are. Optional ghost --scrub also strips your name/secrets locally before encryption (off by default, so the agent keeps full fidelity for real work).
Unrestricted, open-weight onlyDeepSeek V4 Pro (default), Hermes 4 405B/70B -- open-weight models only. The default is steered to drop the usual refusals; closed, refusing models (Claude, GPT, Gemini, Grok) aren't offered, and the gateway rejects anything off the list.
Verified responsesog-veil checks the enclave's signature on every response and refuses to emit a token it can't verify.
Offline modeOpt in with GHOST_LOCAL=1 and switch with ghost --local -- a local abliterated model, zero egress, nothing leaves your machine.
Relentless agentReads real errors, installs what it's missing, changes tactics, and keeps going until the task is done -- it doesn't stop to ask after one failure.
No memory, no telemetryCatalog served locally, web search via local ddgs, no third-party search account, isolated state in ~/.ghost.
--- -## Quickstart +## Install & update -One command installs **everything** -- the engine into `~/.ghost-engine` (Ghost-branded), the -privacy stack, and the `ghost` + `ghost-login` commands. (Local models via Ollama are opt-in: -add `GHOST_LOCAL=1`.) Idempotent (safe to re-run): +**macOS only** (the privacy stack runs as launchd services). One deterministic command -- nothing agentic, no LLM in the loop -- installs everything (the engine into `~/.ghost-engine`, leaving any existing `hermes` install untouched; the privacy stack; the `ghost` / `ghost-login` / `ghost-update` commands). The **same command updates** an existing install, and it's idempotent + safe to re-run (it keeps your login, sessions, and denylist): ```bash -unzip ghost.zip -d ~/ghost && cd ~/ghost && ./install.sh +curl -fsSL https://raw.githubusercontent.com/OpenGradient/ghost/main/install.sh | bash ``` -Then connect your account once and run **`ghost`**: - -```bash -ghost-login # browser login -> hands a session token back to this machine -ghost # chat (default = DeepSeek V4 Pro via the OpenGradient TEE gateway, OHTTP-private) -ghost --local # force the fully-offline local model (no scrubber, no og-veil, nothing leaves) -``` +From a local clone it's just `./install.sh`; once installed, `ghost update` runs the same thing. Add options before the command, e.g. `GHOST_LOCAL=1 ...` (also install an offline local model), `GHOST_LOCAL_32B=1 ...` (the stronger 32B too, 26GB), or `GHOST_SCRUB=1 ...` (turn on outbound PII/secret redaction). By default ghost is hosted-only -- no Ollama; the fallback and auxiliary tasks run hosted over the same private path. -Inside, `/model` switches between the hosted line-up (Hermes 4 405B / 70B) and the -fully-local model. `--local` does the same in one shot (requires a local install, `GHOST_LOCAL=1`). -Optional install config via env: +Then connect once and go: ```bash -GHOST_PROXY=1 ./install.sh # opt in to the Webshare rotating proxy (IP-mask the relay; off by default) -GHOST_LOCAL=1 ./install.sh # also install Ollama + a local model for an offline/incognito fallback -GHOST_LOCAL_32B=1 ./install.sh # pull the stronger 32B local model too (26GB; implies GHOST_LOCAL) -``` - -Local models are opt-in. By default ghost is hosted-only -- no Ollama, and the fallback + auxiliary -tasks route to a hosted 70B over the same private og-veil path. - -Prerequisites are auto-installed; full details under [Install](#install) below. - ---- - -## How hosted models reach you - -Hosted (non-local) models no longer go to Nous directly. They run through the **OpenGradient -chat-api OHTTP relay to a TEE (Trusted Execution Environment) gateway**, resolved from the -on-chain TEE registry. ghost no longer hand-rolls that protocol -- it delegates it to -[og-veil](https://github.com/OpenGradient/veil) (the `opengradient-veil` package), the same -implementation the chat-app and the `og-veil` CLI use: - -``` -ghost engine - โ””โ”€ hosted model โ”€โ–บ scrubber (:8788) [scrub PII/secrets, strip provider prefix] - โ””โ”€โ–บ og-veil (:11435) [HPKE-encrypt, OHTTP relay, verify] - โ””โ”€โ–บ chat-api /api/v1/chat/ohttp [Supabase bearer; RELAY only] - โ””โ”€โ–บ TEE gateway [decrypts in-enclave, runs model, signs output] +ghost-login # browser login -> session token for this machine +ghost # chat (default: DeepSeek V4 Pro via the TEE gateway) +ghost --yolo # auto-accept tool approvals (skip-permissions) +ghost --resume # resume a past session (find one with: ghost sessions browse) +ghost --local # force the offline local model (needs GHOST_LOCAL) ``` -Two boundaries, like the website: the **relay (chat-api)** sees your account token + IP but only -ciphertext; the **enclave** sees the prompt but never your identity. The scrubber runs *before* -og-veil encrypts, on plaintext localhost, so your name/email/secrets reach neither the relay nor -the enclave. og-veil reads the TEE's HPKE key + RSA signing key from the on-chain registry and -**verifies every response against that signing key before a single token is returned**. - --- -## Two modes - -ghost runs one of two kinds of model, a deliberate privacy/capability trade: - -| | **Default: hosted (Hermes 405B + others)** | **Fallback / on-demand: local 32B** | -|---|---|---| -| Model | `nous/hermes-4-405b` / `nous/hermes-4-70b` via the TEE gateway | `uncensored-local` (Qwen2.5-32B-abliterated, Q6) | -| Where it runs | An OpenGradient TEE enclave, reached scrubber โ†’ OHTTP โ†’ chat-api relay | Your machine, fully offline | -| Privacy | IP hidden from the enclave, content hidden from the relay, PII/secrets scrubbed, **but account-linked to your OpenGradient login** | **True incognito -- nothing leaves the box** | -| Strength | Frontier agentic quality; pick any hosted model with `/model` | Weaker agentic searcher; clean, uncensored prose | -| When | Always, if you're logged in and the gateway is reachable | Auto-fallback if hosted is unavailable, or via `/model` | - -The default is the hosted Hermes 405B because it is the stronger agent; the OHTTP path makes it -"private but not anonymous." Switch to the local model any time you want **zero** egress. - ---- +## Models -## What you get +Switch with `/model` -- all open-weight, nothing closed or refusing: -| Layer | Behaviour | +| Model | What it is | |---|---| -| **Default model** | `deepseek/deepseek-v4-pro` via the `opengradient` provider -- scrubber (`:8788`) โ†’ og-veil (`:11435`) โ†’ chat-api relay โ†’ TEE gateway (uncensored through ghost's steer; strongest open agentic model). `nous/hermes-4-405b` and the rest of the catalog via `/model`. | -| **Hosted line-up** | Hermes 4 (405B / 70B) -- unrestricted, open-weight models only, over the one OHTTP path. Closed/refusing models (Claude, GPT, Gemini, Grok) are deliberately not offered. | -| **Fallback model** | `fallback_model` โ†’ local `uncensored-local` (32B) if the hosted gateway is unreachable | -| **Tool / auxiliary model** | Local 7B abliterated (`ghost-tool`) runs titling, compression, triage -- never a hosted provider | -| **Auth** | A Supabase session from `ghost-login` (browser), held + auto-refreshed by og-veil. The relay authenticates it; the enclave never sees it | -| **Encryption** | HPKE (DHKEM-X25519 / HKDF-SHA256 / ChaCha20-Poly1305) per request, done by og-veil; verification before emit | -| **Registry** | og-veil reads the active TEE from the on-chain registry: endpoint, HPKE `ohttpConfig`, and RSA signing key | -| **PII + secret scrubber** | Strips your name/email/handles **and** API keys, tokens, JWTs, private keys from outbound requests, before og-veil encrypts | -| **Egress proxy** | **Opt-in** (`GHOST_PROXY=1`): a rotating residential exit hides your IP from the chat-api relay. Off by default (direct) | -| **Web search** | Local `ddgs` โ†’ engines (direct), or via the rotating proxy when `GHOST_PROXY=1`. No third-party search API sees the query | -| **Memory / telemetry** | Off / none. Catalog served locally; brightdata + codex MCPs removed; TTS local (piper) | -| **Skills** | Created/installed skills go to `~/.ghost/skills-ghost` -- isolated from your normie `hermes` | -| **Interface** | Ghost-branded over the Hermes engine -- **GHOST** banner, ๐Ÿ‘ป figure, all visible text reads Ghost | - ---- - -## Architecture - -``` - ghost โ”€โ”€โ–บ ~/.ghost-engine (its own engine copy; any `hermes` install untouched) - โ”‚ - โ”‚ default (any hosted model) - โ”œโ”€ Hermes 405B / 70B (unrestricted, open-weight) - โ”‚ โ””โ”€โ–บ scrubber (:8788) [scrub name/keys, strip provider prefix] - โ”‚ โ””โ”€โ–บ og-veil (:11435) [HPKE-encrypt, OHTTP, verify] - โ”‚ โ””โ”€โ–บ [rotating proxy (:8899), IP hidden โ€” opt-in GHOST_PROXY] โ”€โ–บ chat-api /api/v1/chat/ohttp - โ”‚ โ””โ”€โ–บ TEE gateway [decrypts in-enclave, runs model, signs] - โ”‚ /model or auto-fallback - โ”œโ”€ local 32B (uncensored-local) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ offline, zero egress - โ”‚ - โ”œโ”€ web search โ”€โ–บ ddgs โ”€โ–บ [rotating proxy (:8899) if GHOST_PROXY] โ”€โ–บ search engines - โ”‚ - โ””โ”€ 12 auxiliary tasks โ”€โ–บ local 7B (ghost-tool) (titling / compression / triage) - - launchd keeps the services alive: com.advait.hermes-pii-scrubber (scrubber) - com.advait.hermes-veil (og-veil; OHTTP/TEE/verify + auth) - com.advait.hermes-proxy (rotating proxy; only with GHOST_PROXY) -``` - -A launch preflight (`bin/ghost`) checks the scrubber `/healthz`, og-veil `/health`, your login -status, and the proxy exit IP, and warns (never hard-blocks) if anything is down -- so the offline -path still runs. +| `deepseek/deepseek-v4-pro` **(default)** | Strongest open reasoning + coding model; best for agentic work. Uncensored via ghost's per-model steer. | +| `nous/hermes-4-405b` | Flagship uncensored open model, most steerable. Also the hosted fallback. | +| `nous/hermes-4-70b` | Fast, low-cost; runs ghost's auxiliary tasks. | +| local (opt-in) | Abliterated 7B (`ghost-tool`), or 32B (`uncensored-local`) with `GHOST_LOCAL_32B`. Fully offline. | --- -## The privacy model -- what each layer actually protects - -- **Scrubber (`:8788`)** -- an OpenAI-compatible endpoint the engine talks to over plaintext - localhost. It redacts a denylist (your name/email/handles) + regex secrets (API keys, tokens, - JWTs, private keys) from request bodies, then forwards the cleaned request to og-veil. Outbound- - only: the local model never routes through it, so local replies are never scrubbed. -- **og-veil (`:11435`)** -- the [`opengradient-veil`](https://github.com/OpenGradient/veil) package - owns the protocol: it reads the active TEE from the on-chain registry, **HPKE-encrypts** each - request, relays it over OHTTP to chat-api, and verifies the enclave's signature on every response - **before emitting a token** (verify-before-emit). One implementation, shared with the chat-app -- - ghost no longer maintains its own copy. -- **OHTTP / TEE split** -- chat-api is only a *relay*: it sees your bearer token + IP but the request - body is ciphertext it cannot read. The TEE enclave decrypts and runs the model but is reached - through the relay, so it never learns who you are. (By default ghost is **direct** -- the relay - sees your real IP; the content stays private regardless. Hide the IP too with the opt-in proxy below.) -- **Rotating proxy (`:8899`) -- opt-in (`GHOST_PROXY=1`), off by default** -- picks a fresh Webshare - residential exit per connection, so the chat-api relay sees a rotating IP, never yours -- og-veil's - egress is routed through it. Also carries the engine's own egress (web search). -- **Private search** -- when the proxy is enabled, `ddgs` honours `DDGS_PROXY` (it ignores - `HTTPS_PROXY`), so every query egresses through the rotating proxy to the engines; otherwise it - goes out directly. No search-API account either way. -- **No memory, no telemetry** -- memory toolset off; model catalog served locally; gateway/update - calls blocked or removed. -- **Skill + state isolation** -- ghost's skills live in their own dir; nothing it creates pollutes `hermes`. - ---- - -## Layout - -- `profile/` -- `config.yaml` (the full incognito profile), `SOUL.md` (the Ghost identity), `.env.example`, `pii_denylist.example.txt`, `uncensored_prefill.json` -- `privacy/` - - `scrubbing_proxy.py` -- the PII/secret scrubber + local model-catalog endpoint; forwards cleaned requests to og-veil - - `scrub_patterns.py` -- single source of the secret + PII regexes (shared by both scrub paths) - - `presidio_scrub.py` -- NER PII detection (Presidio + spaCy) with reversible placeholders + stream de-anon - - `rotating_proxy.py` -- Webshare rotation (opt-in IP-masking) - - `ensure_scrubber_route.py` -- self-heals the engine's hosted route back to the scrubber after a token refresh - - _(the OHTTP/HPKE/registry/verification + Supabase auth that used to live here now comes from the `opengradient-veil` package -- run `og-veil`)_ -- `scripts/` -- `fork-engine.sh` (copy + relocate venv + isolate skills) and `debrand.py` (scrub visible strings + the two ASCII-art logos) -- `launchd/` -- the scrubber + og-veil + rotating-proxy service templates -- `bin/ghost` -- the launcher (privacy preflight) ยท `bin/ghost-login` -- account connect/refresh -- `install.sh` -- end-to-end installer ยท `models.txt` -- the local models to pull - ---- - -## Install - -**One command installs everything** -- the engine into `~/.ghost-engine` (Ghost-branded), the -privacy stack (og-veil + httpx), and the `ghost` + `ghost-login` commands. -(Local models via [Ollama](https://ollama.com) are opt-in: add `GHOST_LOCAL=1`.) Idempotent -(safe to re-run): - -```bash -./install.sh -``` - -The default is the **direct, hosted-only** private setup: it auto-installs prerequisites, -starts the scrubber + og-veil, installs the engine into `~/.ghost-engine` and applies Ghost -branding, offers to run the account login, installs `ghost`, and smoke-tests it. No proxy is set -up; og-veil talks to chat-api directly (content is still private via OHTTP/TEE). Local models -are opt-in (`GHOST_LOCAL=1`). +## How it stays private -**Config modes (optional env vars):** +Every hosted request takes the same private path: ghost's local bridge hands it to [og-veil](https://github.com/OpenGradient/veil) (the `opengradient-veil` package, the same one the [chat.opengradient.ai](https://chat.opengradient.ai) site uses), which encrypts it and relays it over Oblivious HTTP to a TEE enclave: -```bash -GHOST_PROXY=1 ./install.sh # opt in to the Webshare rotating proxy (IP-mask the chat-api relay) -GHOST_LOCAL=1 ./install.sh # opt in to Ollama + a local model (offline/incognito fallback) -GHOST_LOCAL_32B=1 ./install.sh # pull the stronger 32B local model too (26GB; implies GHOST_LOCAL) -GHOST_CHAT_APP_URL=https://... # override the website used for ghost-login (default chat.opengradient.ai) ``` - -- **`GHOST_PROXY=1`** -- opt in to IP-masking: prompts for your Webshare proxy list, runs the rotating - proxy, and routes both og-veil's egress to chat-api and the engine's web-search egress through it, so - the relay sees a rotating IP instead of yours. Off by default (direct); the scrubber + personal PII - denylist run either way. - -After install, **connect your account** and personalize the scrubber denylist: - -```bash -ghost-login # browser login (or: ghost-login --paste for headless) -ghost-login --status # who am I logged in as? -# edit ~/.ghost/privacy/pii_denylist.txt with your name/email/handles -``` - -```bash -ghost # chat (default = DeepSeek V4 Pro via the TEE gateway, OHTTP-private) -ghost --yolo -z "..." # one-shot -ghost --paths "..." # agentic file work: real filesystem paths reach the hosted model - # (your name + secrets in content are still scrubbed) -# inside: /model -> switch between the hosted line-up and uncensored-local +ghost engine + โ””โ”€ bridge (:8788) strip provider prefix, model steer (+ PII/secret scrub if --scrub) + โ””โ”€ og-veil (:11435) HPKE-encrypt, OHTTP relay, verify signature before emit + โ””โ”€ chat-api relay sees your account token + IP, but only ciphertext + โ””โ”€ TEE enclave decrypts, runs the model, signs the output ``` -### Agentic file work -- two ways - -By default the scrubber redacts everything outbound, **including filesystem paths** -- which -breaks file ops on the hosted model (it sees `/Users/[REDACTED_PII]/...`). Two ways to do real -file work: - -- **`ghost --paths`** -- flips on *path-aware* mode for that session: real paths pass through to the - hosted model so it can read/write files, while your name + secrets in prose are still scrubbed. - The trade is that your home/username becomes visible inside those paths. -- **Local model** (`/model` โ†’ `uncensored-local`) -- never touches the scrubber/bridge, so paths are - always real and **nothing leaves the box**. Weaker agent, but the fully-private option. +Two boundaries: the **relay** sees your account + IP but only ciphertext; the **enclave** sees the prompt but never your identity. Redaction is **off by default** so ghost stays full-fidelity (it can read and use secrets during real work, e.g. authorized pentesting); `ghost --scrub` turns on a local pass that strips your name/secrets before encryption. Either way the hosted path is **private, not anonymous**: your OpenGradient account is still authenticated and the relay sees your IP. For true anonymity, use the local model -- zero egress. --- ## Honest limits -- **The hosted default is private, not anonymous.** chat-api still authenticates your OpenGradient - account. The scrubber hides your name/secrets and OHTTP hides your content from the relay -- but the - account link remains, and by default (direct) the relay also sees your real IP. Enable the opt-in - proxy (`GHOST_PROXY=1`) to mask the IP too. For zero-egress anonymity, switch to the **local 32B** - (`/model`) -- that is the true-incognito mode. -- **Responses are verified before they reach you.** og-veil checks the enclave's signature on every - hosted response and refuses to emit a single token it can't verify (verify-before-emit) -- ghost no - longer carries its own best-effort `GHOST_TEE_VERIFY` knob; verification now lives in og-veil. -- **The local fallback isn't perfectly offline under tool-use enforcement.** `tool_use_enforcement: true` - makes search reliable, but the 32B's agentic loop will lean on the hosted gateway for tool - orchestration (still scrubbed + OHTTP, but account-linked), and the 32B is a weak agentic searcher. -- **Proxies are opt-in and trust-shifted.** ghost is direct by default (no proxy). With `GHOST_PROXY=1`, - Webshare sees your real IP unless you run a VPN in front. NordVPN on this Mac is GUI-only (no CLI), so - enable its auto-connect manually for the extra hop. -- **The engine is forked, not rewritten.** Internal Python package names stay `hermes_cli` (invisible to - users). `hermes update` updates only the original install; re-run `scripts/fork-engine.sh` to pull - upstream changes into the fork. +- **The local model is opt-in and weaker.** Off by default (install with `GHOST_LOCAL`); it's a weaker agentic searcher and may still lean on the hosted gateway for tool orchestration under tool-use enforcement. +- **The engine is forked, not rewritten.** Internal package names stay `hermes_cli`, and `ghost update` (not `hermes update`) is what refreshes the fork. --- @@ -277,5 +77,4 @@ file work: ## Security -ghost is a privacy tool; a PII/secret leak is treated as a P0. See [SECURITY.md](SECURITY.md) -for how to report one privately. +ghost is a privacy tool; a PII/secret leak is treated as a P0. See [SECURITY.md](SECURITY.md) for how to report one privately. diff --git a/bin/ghost b/bin/ghost index c36520c..a50e8ef 100755 --- a/bin/ghost +++ b/bin/ghost @@ -8,7 +8,6 @@ # offline local model still runs. SCRUBBER="http://127.0.0.1:8788" VEIL="http://127.0.0.1:11435" -PROXY="http://127.0.0.1:8899" PY=__PYTHON__ SENTINEL="__GHOST_HOME__/privacy/.pass_paths" export HERMES_HOME="__GHOST_HOME__" # ghost's isolated state dir (the engine honors HERMES_HOME) @@ -18,18 +17,23 @@ export HERMES_HOME="__GHOST_HOME__" # ghost's isolated state dir (the engine h # infers through the OG TEE gateway and never needs this key. unset ANTHROPIC_API_KEY +# `ghost update` -- pull the latest ghost + re-run the installer (idempotent). Intercepted here +# so it doesn't fall through to the engine's own `update` (which only touches the base install). +if [ "${1:-}" = "update" ]; then shift; exec "$HOME/.local/bin/ghost-update" "$@"; fi + # --paths (or --code): path-aware mode -- let real filesystem paths reach the hosted model # for agentic file work; name + secrets in content are still scrubbed. Default off = full redaction. PASS_PATHS="" case "${1:-}" in --paths|--code) PASS_PATHS=1; shift ;; esac -# --no-scrub / --scrub: persistent toggle for name/PII redaction. Secrets (API keys, JWTs, -# private keys) are ALWAYS scrubbed regardless. Turn off when redaction mangles an essential -# query (e.g. your own domain in the prompt). State persists across runs; status line shows it. -NOSCRUB_MARK="__GHOST_HOME__/privacy/.no_scrub" +# --scrub / --no-scrub: persistent toggle for OUTBOUND PII + secret redaction. OFF BY DEFAULT -- +# ghost is a full-fidelity agent (so it can do real work, incl. authorized pentesting, without its +# own privacy layer mangling secrets it reads). og-veil still OHTTP-encrypts + TEE-isolates the +# hosted path regardless. Turn it ON only to strip your own name/secrets before the gateway. +SCRUB_MARK="__GHOST_HOME__/privacy/.scrub" case "${1:-}" in - --no-scrub|--noscrub) : > "$NOSCRUB_MARK"; shift ;; - --scrub) rm -f "$NOSCRUB_MARK"; shift ;; + --scrub) : > "$SCRUB_MARK"; shift ;; + --no-scrub|--noscrub) rm -f "$SCRUB_MARK"; shift ;; esac # --preview "": show exactly what the PII scrubber would redact (entity table + @@ -77,24 +81,18 @@ else fi # Privacy comes from og-veil: every request is HPKE/OHTTP-encrypted, so the relay only ever sees -# ciphertext (the "via TEE gateway (OHTTP)" below). The relay still sees your IP; the OPTIONAL -# rotating proxy (GHOST_PROXY=1, off by default) hides that too. Only surface an IP indicator -# when the proxy is actually enabled (.proxy marker) -- no VPN noise in the default direct setup. -NET="" -if [ -f "__GHOST_HOME__/privacy/.proxy" ]; then - EXIT_IP=$(/usr/bin/curl -s --max-time 6 -x "$PROXY" https://api.ipify.org 2>/dev/null) - NET=" ยท ${EXIT_IP:+IP-masked via $EXIT_IP}"; [ -n "$EXIT_IP" ] || NET=" ยท proxy DOWN" -fi +# ciphertext (the "via TEE gateway (OHTTP)" below). The relay still sees your IP; for full +# anonymity use the local model (ghost --local), which has zero egress. if [ "$HC" = "200" ] && [ "$VC" = "200" ]; then - STATUS="๐Ÿ‘ป ghost ยท $DEF_MODEL via TEE gateway (OHTTP/og-veil)${NET} ยท $LOGIN ยท $FB" + STATUS="๐Ÿ‘ป ghost ยท $DEF_MODEL via TEE gateway (OHTTP/og-veil) ยท $LOGIN ยท $FB" elif [ "$HC" = "200" ]; then STATUS="๐Ÿ‘ป ghost ยท โš ๏ธ og-veil DOWN (veil=$VC) -- hosted unreachable (try ghost-login), $FB_DOWN" else STATUS="๐Ÿ‘ป ghost ยท โš ๏ธ scrubbing bridge DOWN (bridge=$HC) -- hosted unreachable, $FB_DOWN" fi [ -n "$PASS_PATHS" ] && STATUS="$STATUS ยท ๐Ÿ—‚๏ธ path-aware (real paths visible to hosted model)" -[ -f "$NOSCRUB_MARK" ] && STATUS="$STATUS ยท ๐Ÿ”“ PII redaction OFF (secrets still scrubbed)" +if [ -f "$SCRUB_MARK" ]; then STATUS="$STATUS ยท ๐Ÿ”’ redaction ON (PII+secrets scrubbed outbound)"; else STATUS="$STATUS ยท ๐Ÿ”“ no redaction (full fidelity; og-veil still encrypts)"; fi # NER scrubber expected but failed to load -> regex fallback (names may not be scrubbed). Loud. [ -f "__GHOST_HOME__/privacy/.presidio_failed" ] && STATUS="$STATUS ยท โš ๏ธ NER scrubber OFF (regex fallback -- reinstall or check Presidio)" echo "$STATUS" >&2 diff --git a/bin/ghost-update b/bin/ghost-update new file mode 100644 index 0000000..5aa3513 --- /dev/null +++ b/bin/ghost-update @@ -0,0 +1,29 @@ +#!/bin/sh +# ghost-update -- pull the latest ghost source and re-run the installer (idempotent), +# reusing the options you first installed with (GHOST_LOCAL / GHOST_SCRUB / ...). This +# updates ghost's wrapper (privacy stack, commands, profile) and re-forks + re-debrands +# the engine. Invoked by `ghost update`. +# +# Note: this does NOT pull a newer UPSTREAM Hermes engine on its own -- for that, run +# `hermes update` first (updates the base install), then `ghost update` re-forks it. +set -e +GHOST_HOME="__GHOST_HOME__" + +# Prefer the source repo you installed from; fall back to a managed clone in ~/.ghost-src. +SRC="$(cat "$GHOST_HOME/.src" 2>/dev/null || true)" +[ -d "$SRC/.git" ] || SRC="$HOME/.ghost-src" + +if [ -d "$SRC/.git" ]; then + echo "๐Ÿ‘ป ghost-update ยท pulling latest source ($SRC)" + git -C "$SRC" pull --ff-only || git -C "$SRC" pull +else + echo "๐Ÿ‘ป ghost-update ยท fetching ghost into $SRC" + rm -rf "$SRC" + git clone https://github.com/OpenGradient/ghost.git "$SRC" +fi + +# Re-apply the same install options you chose originally. +if [ -f "$GHOST_HOME/.install-env" ]; then set -a; . "$GHOST_HOME/.install-env"; set +a; fi + +echo "๐Ÿ‘ป ghost-update ยท re-running the installer (idempotent)" +exec bash "$SRC/install.sh" diff --git a/install.sh b/install.sh index a3bddcf..96ddb7d 100644 --- a/install.sh +++ b/install.sh @@ -13,16 +13,15 @@ # website uses. After install, run `ghost-login` once to connect your account (a browser # login that hands a session token to og-veil). # -# By default ghost runs DIRECT: the scrubber + og-veil talk to chat-api directly (content is -# still private -- og-veil OHTTP-encrypts it and the TEE enclave separates identity). No -# rotating-proxy setup is needed. IP-masking is opt-in (see GHOST_PROXY below). +# The scrubber + og-veil talk to chat-api directly: content is private (og-veil OHTTP-encrypts +# it and the TEE enclave separates identity), reached over your normal connection. # # Optional config via env (all optional -- plain `./install.sh` does the full private setup): -# GHOST_PROXY=1 opt in to the Webshare rotating proxy: masks your IP from the chat-api -# relay (og-veil egress) + carries the engine's web-search egress # GHOST_LOCAL=1 also install Ollama + a local model for an offline / true-incognito # fallback (DEFAULT is hosted-only -- no Ollama, fallback is hosted 70B) # GHOST_LOCAL_32B=1 pull the stronger 32B local model too (26GB; implies GHOST_LOCAL) +# GHOST_SCRUB=1 opt in to OUTBOUND PII + secret redaction (OFF by default -- ghost is a +# full-fidelity agent; og-veil's OHTTP+TEE provides the privacy regardless) # GHOST_CHAT_APP_URL= override the website used for `ghost-login` (default chat.opengradient.ai) set -euo pipefail @@ -33,7 +32,20 @@ if [ "$(uname -s)" != "Darwin" ]; then exit 1 fi -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Resolve where this script lives. When run via `curl ... | bash` there is no checkout, so +# self-bootstrap: clone (or fast-forward) the repo into ~/.ghost-src and re-exec from there. This +# makes ONE deterministic command both INSTALL and UPDATE ghost -- no manual clone, no LLM needed. +REPO="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)" +if [ -z "$REPO" ] || [ ! -f "$REPO/profile/config.yaml" ]; then + command -v git >/dev/null 2>&1 || { echo "!! ghost needs git to fetch itself; install it (xcode-select --install) and re-run." >&2; exit 1; } + SRC="${GHOST_SRC_DIR:-$HOME/.ghost-src}" + if [ -d "$SRC/.git" ]; then + echo "==> Updating ghost source ($SRC)"; git -C "$SRC" pull --ff-only || git -C "$SRC" pull + else + echo "==> Fetching ghost into $SRC"; rm -rf "$SRC"; git clone https://github.com/OpenGradient/ghost.git "$SRC" + fi + exec bash "$SRC/install.sh" "$@" +fi ENGINE_HOME="${ENGINE_HOME:-$HOME/.hermes}" # where the Hermes engine installs (official installer default) GHOST_HOME="${GHOST_HOME:-$HOME/.ghost}" # ghost's ISOLATED state (profiles, privacy, auth) PROFILE="$GHOST_HOME/profiles/uncensored" @@ -42,9 +54,6 @@ LA="$HOME/Library/LaunchAgents" ENG="${GHOST_ENGINE:-$HOME/.ghost-engine}" PYTHON="${GHOST_PYTHON:-$(command -v python3 || true)}" SCRUBBER="http://127.0.0.1:8788" -# Direct is the default. Opt in to the Webshare rotating proxy with GHOST_PROXY=1. -# (GHOST_DIRECT is still honored for back-compat, but it's now the default anyway.) -USE_PROXY="${GHOST_PROXY:-}" # Local models (Ollama) are OPT-IN. Default = hosted-only: no Ollama, and the fallback + # auxiliary tasks route to a hosted model (nous/hermes-4-70b) over the same private og-veil # path. Set GHOST_LOCAL=1 to also install Ollama + a local model for an offline / incognito @@ -52,6 +61,18 @@ USE_PROXY="${GHOST_PROXY:-}" # since hosted-only is now the default.) WANT_LOCAL="${GHOST_LOCAL:-}"; [ -n "${GHOST_LOCAL_32B:-}" ] && WANT_LOCAL=1 +# Record the source path + the chosen install options so `ghost update` can re-pull and +# re-install the exact same way (see bin/ghost-update). +mkdir -p "$GHOST_HOME" +echo "$REPO" > "$GHOST_HOME/.src" +{ + [ -n "${GHOST_LOCAL:-}" ] && echo "GHOST_LOCAL=1" + [ -n "${GHOST_LOCAL_32B:-}" ] && echo "GHOST_LOCAL_32B=1" + [ -n "${GHOST_SCRUB:-}" ] && echo "GHOST_SCRUB=1" + [ -n "${GHOST_CHAT_APP_URL:-}" ] && echo "GHOST_CHAT_APP_URL=$GHOST_CHAT_APP_URL" + : +} > "$GHOST_HOME/.install-env" + say(){ printf '\n\033[1;33m==>\033[0m %s\n' "$*"; } have(){ command -v "$1" >/dev/null 2>&1; } @@ -115,14 +136,6 @@ mkdir -p "$PROFILE" sed -e "s#__HOME__#$HOME#g" -e "s#__LOCAL_MODEL__#$LOCAL_MODEL#g" "$REPO/profile/config.yaml" > "$PROFILE/config.yaml" cp "$REPO/profile/SOUL.md" "$PROFILE/SOUL.md" [ -f "$PROFILE/.env" ] || cp "$REPO/profile/.env.example" "$PROFILE/.env" -if [ -n "$USE_PROXY" ]; then # opt-in: route the engine's own egress (web search/fetches) through the rotating proxy - "$PYTHON" - "$PROFILE/.env" <<'PYEOF' -import sys, re -p = sys.argv[1]; s = open(p).read() -s = re.sub(r"(?m)^#\s*((?:HTTPS_PROXY|HTTP_PROXY|ALL_PROXY|DDGS_PROXY)=\S+)\s*$", r"\1", s) -open(p, "w").write(s) -PYEOF -fi if [ -z "$WANT_LOCAL" ]; then # hosted-only (default) -> route auxiliary + fallback to a hosted model via og-veil "$PYTHON" - "$PROFILE/config.yaml" <<'PYEOF' import sys, re @@ -140,8 +153,8 @@ open(p, "w").write(s); print(" hosted-only: fallback -> nous/hermes-4-405b, au PYEOF fi -# ---------- 3. privacy stack (PII scrubber + og-veil always; rotating proxy only with GHOST_PROXY) ---------- -say "Privacy stack (PII/secret scrubber -> og-veil${USE_PROXY:+ + rotating proxy})" +# ---------- 3. privacy stack (PII scrubber + og-veil) ---------- +say "Privacy stack (PII/secret scrubber -> og-veil)" mkdir -p "$PRIV" cp "$REPO"/privacy/*.py "$PRIV/" # Enable the NER PII scrubber when Presidio + the spaCy model are present; else leave it off @@ -153,24 +166,29 @@ else fi [ -f "$PRIV/pii_denylist.txt" ] || cp "$REPO/profile/pii_denylist.example.txt" "$PRIV/pii_denylist.txt" cp "$REPO/profile/uncensored_prefill.json" "$PRIV/uncensored_prefill.json" +# Outbound PII + secret redaction is OPT-IN (GHOST_SCRUB=1), OFF by default: ghost is a +# full-fidelity agent and og-veil's OHTTP+TEE already make the hosted path private. The .scrub +# marker drives the bridge; the engine's redact_secrets/redact_pii follow the same default. +if [ -n "${GHOST_SCRUB:-}" ]; then + : > "$PRIV/.scrub" + "$PYTHON" - "$PROFILE/config.yaml" <<'PYEOF' +import sys, re +p = sys.argv[1]; s = open(p).read() +s = re.sub(r"(?m)^ redact_secrets: false$", " redact_secrets: true", s) +s = re.sub(r"(?m)^ redact_pii: false$", " redact_pii: true", s) +open(p, "w").write(s) +PYEOF + say "Outbound PII + secret redaction ON (GHOST_SCRUB)" +else + rm -f "$PRIV/.scrub" "$PRIV/.no_scrub" + say "Full-fidelity mode (default) -- no outbound redaction. Set GHOST_SCRUB=1 to strip your PII/secrets before the gateway." +fi mkdir -p "$LA" -# The scrubber always runs. The Webshare rotating proxy is OPT-IN (GHOST_PROXY=1): -# by default ghost is direct -- og-veil talks to chat-api itself (content is still -# private via OHTTP/TEE; only IP-masking is skipped). +# The scrubber runs as a launchd service; og-veil talks to chat-api directly (content is +# still private via OHTTP/TEE). Clean up any rotating-proxy marker from an older install. BASE_SERVICES="hermes-pii-scrubber" -if [ -n "$USE_PROXY" ]; then - if [ ! -s "$GHOST_HOME/webshare_proxies.txt" ]; then - echo " Paste your Webshare proxy-list download URL (ip:port:user:pass), or Enter to skip:" - read -r WS_URL || true - [ -n "${WS_URL:-}" ] && curl -fsSL "$WS_URL" -o "$GHOST_HOME/webshare_proxies.txt" && echo " $(wc -l <"$GHOST_HOME/webshare_proxies.txt"|tr -d ' ') proxies" - fi - BASE_SERVICES="hermes-proxy $BASE_SERVICES" - : > "$PRIV/.proxy" # marker: egress is IP-masked through the rotating proxy (banner reads this) -else - rm -f "$PRIV/.proxy" - say "Direct mode (default) -- og-veil talks to chat-api directly; no rotating proxy. Set GHOST_PROXY=1 to IP-mask." -fi +rm -f "$PRIV/.proxy" for svc in $BASE_SERVICES; do sed -e "s#__PYTHON__#$PYTHON#g" -e "s#__HOME__#$HOME#g" "$REPO/launchd/com.advait.$svc.plist" > "$LA/com.advait.$svc.plist" @@ -179,23 +197,9 @@ for svc in $BASE_SERVICES; do done # og-veil service (port 11435, to avoid colliding with Ollama on 11434). It owns the -# OHTTP/TEE/verification + auth. Default: direct egress to chat-api. With GHOST_PROXY=1 -# its egress is routed through the rotating proxy so the relay never sees your real IP. -if [ -n "$USE_PROXY" ]; then - VEIL_PROXY_ENV=$' HTTPS_PROXY\n http://127.0.0.1:8899\n HTTP_PROXY\n http://127.0.0.1:8899\n NO_PROXY\n 127.0.0.1,localhost,::1' -else - VEIL_PROXY_ENV="" -fi +# OHTTP/TEE/verification + auth, and talks to chat-api directly. VEIL_PLIST="$LA/com.advait.hermes-veil.plist" -GP_PYTHON="$PYTHON" GP_HOME="$HOME" GP_PROXY_ENV="$VEIL_PROXY_ENV" \ - "$PYTHON" - "$REPO/launchd/com.advait.hermes-veil.plist" "$VEIL_PLIST" <<'PYEOF' -import os, sys -src, dst = sys.argv[1], sys.argv[2] -s = open(src).read() -s = s.replace("__PYTHON__", os.environ["GP_PYTHON"]).replace("__HOME__", os.environ["GP_HOME"]) -s = s.replace("__VEIL_PROXY_ENV__", os.environ.get("GP_PROXY_ENV", "")) -open(dst, "w").write(s) -PYEOF +sed -e "s#__PYTHON__#$PYTHON#g" -e "s#__HOME__#$HOME#g" "$REPO/launchd/com.advait.hermes-veil.plist" > "$VEIL_PLIST" launchctl unload "$VEIL_PLIST" 2>/dev/null || true launchctl load -w "$VEIL_PLIST" @@ -208,12 +212,13 @@ for _ in $(seq 1 20); do [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time say "Forking + debranding the engine -> $ENG" GHOST_PYTHON="$PYTHON" GHOST_ENGINE="$ENG" HERMES_SRC="$SRC" bash "$REPO/scripts/fork-engine.sh" -# ---------- 5. the ghost + ghost-login commands ---------- -say "Installing the ghost + ghost-login commands" +# ---------- 5. the ghost + ghost-login + ghost-update commands ---------- +say "Installing the ghost + ghost-login + ghost-update commands" mkdir -p "$HOME/.local/bin" sed -e "s#__PYTHON__#$PYTHON#g" -e "s#__HOME__#$HOME#g" -e "s#__ENG__#$ENG#g" -e "s#__GHOST_HOME__#$GHOST_HOME#g" "$REPO/bin/ghost" > "$HOME/.local/bin/ghost" sed -e "s#__PYTHON__#$PYTHON#g" -e "s#__HOME__#$HOME#g" -e "s#__GHOST_HOME__#$GHOST_HOME#g" "$REPO/bin/ghost-login" > "$HOME/.local/bin/ghost-login" -chmod +x "$HOME/.local/bin/ghost" "$HOME/.local/bin/ghost-login" +sed -e "s#__HOME__#$HOME#g" -e "s#__GHOST_HOME__#$GHOST_HOME#g" "$REPO/bin/ghost-update" > "$HOME/.local/bin/ghost-update" +chmod +x "$HOME/.local/bin/ghost" "$HOME/.local/bin/ghost-login" "$HOME/.local/bin/ghost-update" # ---------- 6. connect your account (hosted models) ---------- say "Connect your OpenGradient Chat account (for hosted models)" @@ -221,7 +226,7 @@ GL="$HOME/.local/bin/ghost-login" # thin wrapper over `og-veil login` (install if "$GL" --status >/dev/null 2>&1; then echo " already connected: $("$GL" --status)" else - echo " Hosted models (the default Hermes 405B + Claude/GPT/Gemini/Grok) need a one-time login." + echo " Hosted models (the default DeepSeek V4 Pro + Hermes 4, all open-weight) need a one-time login." if [ -t 0 ]; then printf " Run the browser login now? [Y/n] "; read -r ANS || true case "${ANS:-Y}" in [Nn]*) echo " Skipped -- run 'ghost-login' anytime.";; *) "$GL" || echo " (login skipped/failed -- run 'ghost-login' anytime)";; esac @@ -238,6 +243,5 @@ say "ghost installed -- run: ghost" case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) echo " (add ~/.local/bin to your PATH first)";; esac echo " Hosted default = deepseek/deepseek-v4-pro via og-veil -> the OpenGradient TEE gateway (OHTTP-private)." echo " Inside ghost, /model switches between hosted models and the local model (true incognito)." -echo " Personalize $PRIV/pii_denylist.txt with your name/email/handles for the hosted-path scrubber." -[ -n "$USE_PROXY" ] || echo " Direct mode (default). For IP-masking from the relay, reinstall with GHOST_PROXY=1." +echo " Redaction is OFF by default (full fidelity). Opt in with GHOST_SCRUB=1 (or 'ghost --scrub'), then personalize $PRIV/pii_denylist.txt." echo " Not connected yet? Run: ghost-login" diff --git a/launchd/com.advait.hermes-proxy.plist b/launchd/com.advait.hermes-proxy.plist deleted file mode 100644 index 9d9114b..0000000 --- a/launchd/com.advait.hermes-proxy.plist +++ /dev/null @@ -1,21 +0,0 @@ - - - - - Label - com.advait.hermes-proxy - ProgramArguments - - __PYTHON__ - __HOME__/.ghost/privacy/rotating_proxy.py - - RunAtLoad - - KeepAlive - - StandardOutPath - __HOME__/.ghost/privacy/proxy.out.log - StandardErrorPath - __HOME__/.ghost/privacy/proxy.err.log - - diff --git a/launchd/com.advait.hermes-veil.plist b/launchd/com.advait.hermes-veil.plist index 10e4145..9ac2b5c 100644 --- a/launchd/com.advait.hermes-veil.plist +++ b/launchd/com.advait.hermes-veil.plist @@ -23,7 +23,6 @@ OG_VEIL_PORT 11435 -__VEIL_PROXY_ENV__ StandardOutPath __HOME__/.ghost/privacy/veil.out.log diff --git a/privacy/rotating_proxy.py b/privacy/rotating_proxy.py deleted file mode 100644 index 90fa1ab..0000000 --- a/privacy/rotating_proxy.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -"""Local rotating forward-proxy for Hermes. - -Listens on 127.0.0.1:8899. For each new connection it picks a random Webshare -upstream proxy (from webshare_proxies.txt, format ip:port:user:pass), chains the -request through it (CONNECT tunnel for HTTPS, absolute-form forward for HTTP), -and retries other upstreams if one is dead. This hides the machine's real IP -from the inference endpoint and rotates exit IPs across connections. - -Run via launchd (com.advait.hermes-proxy) so it persists across sessions. -""" -import asyncio, base64, os, random, time -from urllib.parse import urlparse - -PROXY_FILE = os.path.expanduser("~/.ghost/webshare_proxies.txt") -LOG_FILE = os.path.expanduser("~/.ghost/privacy/proxy.log") -LISTEN_HOST, LISTEN_PORT = "127.0.0.1", 8899 -MAX_TRIES, CONN_TIMEOUT = 8, 5 -# Incognito blocklist: hosts the harness phones home to that we refuse (anonymous pricing -# lookups, telemetry, update checks). CONNECT to these returns 403 and Hermes falls back. -BLOCKLIST = {"openrouter.ai", "firecrawl-gateway.nousresearch.com", "hermes-agent.nousresearch.com"} - - -def load_proxies(): - out = [] - try: - with open(PROXY_FILE) as f: - for ln in f: - ln = ln.strip() - if ln and ln.count(":") >= 3: - h, p, u, pw = ln.split(":", 3) - out.append((h, int(p), u, pw)) - except FileNotFoundError: - pass - return out - - -PROXIES = load_proxies() - - -def log(msg): - try: - with open(LOG_FILE, "a") as f: - f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n") - except Exception: - pass - - -async def pipe(reader, writer): - try: - while True: - data = await reader.read(65536) - if not data: - break - writer.write(data) - await writer.drain() - except Exception: - pass - finally: - try: - writer.close() - except Exception: - pass - - -def pick(): - return random.sample(PROXIES, min(MAX_TRIES, len(PROXIES))) - - -async def connect_upstream(host, port): - """Open a CONNECT tunnel via a random upstream. Returns (r, w, ip) or None.""" - for ph, pp, pu, ppw in pick(): - try: - ur, uw = await asyncio.wait_for(asyncio.open_connection(ph, pp), CONN_TIMEOUT) - auth = base64.b64encode(f"{pu}:{ppw}".encode()).decode() - uw.write((f"CONNECT {host}:{port} HTTP/1.1\r\n" - f"Host: {host}:{port}\r\n" - f"Proxy-Authorization: Basic {auth}\r\n" - f"Proxy-Connection: keep-alive\r\n\r\n").encode()) - await uw.drain() - status = await asyncio.wait_for(ur.readline(), CONN_TIMEOUT) - if b" 200 " not in status: - uw.close() - continue - while True: # drain upstream response headers - h = await asyncio.wait_for(ur.readline(), CONN_TIMEOUT) - if h in (b"\r\n", b"\n", b""): - break - return ur, uw, ph - except Exception: - continue - return None - - -async def handle(creader, cwriter): - try: - first = await asyncio.wait_for(creader.readline(), 30) - if not first: - cwriter.close(); return - method, target = first.split()[0].decode("latin1"), first.split()[1].decode("latin1") - headers = [] - while True: - h = await asyncio.wait_for(creader.readline(), 30) - headers.append(h) - if h in (b"\r\n", b"\n", b""): - break - except Exception: - try: cwriter.close() - except Exception: pass - return - - if method.upper() == "CONNECT": - host, _, port = target.partition(":") - port = int(port or 443) - if any(host == b or host.endswith("." + b) for b in BLOCKLIST): - cwriter.write(b"HTTP/1.1 403 Blocked (incognito)\r\n\r\n") - try: await cwriter.drain() - except Exception: pass - cwriter.close() - log(f"BLOCKED {host}:{port}") - return - up = await connect_upstream(host, port) - if not up: - cwriter.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") - try: await cwriter.drain() - except Exception: pass - cwriter.close() - log(f"FAIL CONNECT {host}:{port}") - return - ur, uw, ip = up - cwriter.write(b"HTTP/1.1 200 Connection established\r\n\r\n") - try: await cwriter.drain() - except Exception: pass - log(f"CONNECT {host}:{port} via {ip}") - await asyncio.gather(pipe(creader, uw), pipe(ur, cwriter)) - else: - u = urlparse(target) - host, port = u.hostname, (u.port or 80) - if not host: - cwriter.close(); return - for ph, pp, pu, ppw in pick(): - try: - ur, uw = await asyncio.wait_for(asyncio.open_connection(ph, pp), CONN_TIMEOUT) - auth = base64.b64encode(f"{pu}:{ppw}".encode()).decode() - uw.write(first) - uw.write(f"Proxy-Authorization: Basic {auth}\r\n".encode()) - for h in headers: - if not h.lower().startswith(b"proxy-"): - uw.write(h) - await uw.drain() - log(f"HTTP {host}:{port} via {ph}") - await asyncio.gather(pipe(creader, uw), pipe(ur, cwriter)) - return - except Exception: - continue - cwriter.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") - cwriter.close() - - -async def main(): - if not PROXIES: - log("FATAL no proxies loaded from " + PROXY_FILE) - raise SystemExit(1) - server = await asyncio.start_server(handle, LISTEN_HOST, LISTEN_PORT) - log(f"listening on {LISTEN_HOST}:{LISTEN_PORT} with {len(PROXIES)} upstreams") - async with server: - await server.serve_forever() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/privacy/scrubbing_proxy.py b/privacy/scrubbing_proxy.py index 93c7d81..314ab30 100755 --- a/privacy/scrubbing_proxy.py +++ b/privacy/scrubbing_proxy.py @@ -62,8 +62,9 @@ # Curated picker whitelist served at /model-catalog.json. ghost is an UNRESTRICTED harness, so it # only offers OPEN-WEIGHT, steerable models. Closed, safety-tuned refusers (Claude, GPT, Gemini, # Grok, Seed) are served by the gateway but deliberately excluded -- they refuse/moralize and -# can't be steered. This list is the single source of truth for both the picker and the bridge's -# allow-list (_ALLOWED_GATEWAY_MODELS). +# can't be steered. GLM-5.2 is open-weight but excluded for the same reason: the gateway injects a +# safety system prompt for it that the steer can't override (verified 2026-06-24). This list is the +# single source of truth for both the picker and the bridge's allow-list (_ALLOWED_GATEWAY_MODELS). # # SUPPORTED-MODEL REFERENCE (gateway-verified by probing /v1/chat/completions, 2026-06-24): # the gateway's open-weight models are hermes-4-405b, hermes-4-70b, deepseek-v4-pro, glm-5.2. @@ -73,7 +74,6 @@ _CATALOG_MODELS = [ ("deepseek/deepseek-v4-pro", "DeepSeek V4 Pro โ€” strongest open reasoning + coding; best for agentic work (default)"), ("nous/hermes-4-405b", "Hermes 4 405B โ€” flagship uncensored open model, most steerable"), - ("zai/glm-5.2", "GLM 5.2 โ€” strong open agentic MoE (Z.ai)"), ("nous/hermes-4-70b", "Hermes 4 70B โ€” fast, low-cost open-weight model"), ] @@ -105,13 +105,15 @@ def log(m): from scrub_patterns import EMAIL_RE, SSN_RE, CC_RE, IP_RE, PHONE_RE, SECRET_RES # noqa: E402 PASS_PATHS_SENTINEL = os.path.expanduser("~/.ghost/privacy/.pass_paths") -# Filesystem paths are protected from redaction by DEFAULT so agentic file work is -# not blinded: the user's name often appears inside ~/ paths, and redacting it to -# [REDACTED_PII] breaks path navigation. Names/secrets in prose are still scrubbed. -# Create ~/.ghost/privacy/.full_redaction to redact paths too (maximum privacy). +# Redaction is OPT-IN and OFF BY DEFAULT. ghost is a full-fidelity agent (it does real work, +# including authorized pentesting, so its own privacy layer must NOT silently mangle secrets it +# legitimately reads). The ~/.ghost/privacy/.scrub marker (ghost --scrub) turns ON outbound +# PII + secret redaction; absent (default) = pure pass-through. Privacy of the hosted path comes +# from og-veil (OHTTP + TEE enclave) regardless of this toggle. +SCRUB_SENTINEL = os.path.expanduser("~/.ghost/privacy/.scrub") +# When scrubbing IS on, filesystem paths are protected from redaction by default so agentic file +# work is not blinded. Create ~/.ghost/privacy/.full_redaction to redact paths too. FULL_REDACTION_SENTINEL = os.path.expanduser("~/.ghost/privacy/.full_redaction") -NO_SCRUB_SENTINEL = os.path.expanduser("~/.ghost/privacy/.no_scrub") # PII redaction is OPTIONAL: this marker (ghost --no-scrub) turns off name/PII -# redaction; secrets (API keys, JWTs, private keys) are ALWAYS scrubbed regardless. PATH_RE = re.compile(r"(?:~|/[\w.\-]+)(?:/[\w.\-]+)+") @@ -186,10 +188,10 @@ def _walk_strings(node, fn): def scrub_body(obj): """Legacy regex scrubber over the WHOLE request body (used when Presidio is off / fails).""" - if not isinstance(obj, dict): - return obj, 0 + if not isinstance(obj, dict) or not os.path.exists(SCRUB_SENTINEL): + return obj, 0 # scrubbing is opt-in (.scrub marker); default = pass-through pp = not os.path.exists(FULL_REDACTION_SENTINEL) # default: protect filesystem paths - pii = not os.path.exists(NO_SCRUB_SENTINEL) # PII redaction optional; secrets always scrubbed + pii = True # when scrubbing is on, redact both PII and secrets total = 0 def fn(s): @@ -202,14 +204,16 @@ def fn(s): def _anonymize_request(obj): - """Anonymize the request body -> (obj, count, mapping). With Presidio enabled (.presidio - marker) use NER + reversible placeholders, returning {placeholder: original} for response - de-anonymization. Otherwise, or on any error, fall back to the legacy regex scrubber.""" + """Anonymize the request body -> (obj, count, mapping). Redaction is OPT-IN: with no .scrub + marker (default) this is a pure pass-through (full fidelity). With .scrub on and Presidio + enabled (.presidio marker) use NER + reversible placeholders; else the legacy regex scrubber.""" + if not os.path.exists(SCRUB_SENTINEL): + return obj, 0, {} # scrubbing opt-in; default = pass-through if not (_PRESIDIO_OK and os.path.exists(PRESIDIO_MARKER)): obj, n = scrub_body(obj) return obj, n, {} try: - pii = not os.path.exists(NO_SCRUB_SENTINEL) + pii = True # scrubbing is on -> redact PII + secrets mapping, total = {}, 0 def fn(s): @@ -309,10 +313,9 @@ def _apply_model_steer(obj): # โ”€โ”€ Upstream: og-veil's local OpenAI-compatible server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# The scrubber -> og-veil hop is plaintext localhost, so it must never go through -# the rotating proxy (that would defeat the localhost assumption and add latency); -# trust_env=False ignores any ambient HTTPS_PROXY. og-veil does the IP-masking on -# its *own* egress to chat-api. +# The scrubber -> og-veil hop is plaintext localhost. trust_env=False ignores any +# ambient HTTPS_PROXY so this hop always stays on loopback. og-veil owns the egress +# to chat-api (OHTTP-encrypted). def _veil_client(): return httpx.Client(timeout=300.0, trust_env=False) diff --git a/profile/.env.example b/profile/.env.example index 35fd262..7a51db3 100644 --- a/profile/.env.example +++ b/profile/.env.example @@ -2,16 +2,7 @@ # hosted models authenticate with the Supabase session captured by `ghost-login`, which is # held by og-veil (not via env here). # -# DIRECT BY DEFAULT: ghost does not route through a proxy. The engine's own egress (web -# search, fetches) goes out directly, and the hosted-model path reaches the local scrubber -# (127.0.0.1:8788) over localhost; og-veil then talks to chat-api itself. Content is still -# private (og-veil OHTTP-encrypts it + the TEE enclave separates identity) -- only IP-masking -# is skipped. -# -# To IP-mask via the Webshare rotating proxy, reinstall with GHOST_PROXY=1 (which uncomments -# the lines below; ddgs honors DDGS_PROXY -- it ignores HTTPS_PROXY): -# HTTPS_PROXY=http://127.0.0.1:8899 -# HTTP_PROXY=http://127.0.0.1:8899 -# ALL_PROXY=http://127.0.0.1:8899 -# DDGS_PROXY=http://127.0.0.1:8899 -NO_PROXY=127.0.0.1,localhost,::1,0.0.0.0 +# ghost does not route through any proxy. The engine's egress (web search, fetches) goes out +# directly, and the hosted-model path reaches the local scrubber (127.0.0.1:8788) over +# localhost; og-veil then talks to chat-api itself. Content is still private -- og-veil +# OHTTP-encrypts it and the TEE enclave separates identity. diff --git a/profile/config.yaml b/profile/config.yaml index 55b0bb3..7af5982 100644 --- a/profile/config.yaml +++ b/profile/config.yaml @@ -30,7 +30,7 @@ toolsets: - hermes-cli max_concurrent_sessions: null agent: - max_turns: 40 + max_turns: 200 gateway_timeout: 1800 restart_drain_timeout: 60 api_max_retries: 3 @@ -352,7 +352,7 @@ dashboard: session_ttl_seconds: 0 public_url: '' privacy: - redact_pii: true + redact_pii: false tts: provider: piper edge: @@ -515,7 +515,7 @@ hooks_auto_accept: false personalities: {} security: allow_private_urls: false - redact_secrets: true + redact_secrets: false tirith_enabled: true tirith_path: tirith tirith_timeout: 5 diff --git a/tests/test_scrub.py b/tests/test_scrub.py index bd7958a..f4a76c0 100644 --- a/tests/test_scrub.py +++ b/tests/test_scrub.py @@ -36,15 +36,17 @@ def presidio_on(tmp_path, monkeypatch): marker.write_text("") monkeypatch.setattr(sp, "PRESIDIO_MARKER", str(marker)) monkeypatch.setattr(sp, "_PRESIDIO_OK", True) - monkeypatch.setattr(sp, "NO_SCRUB_SENTINEL", str(tmp_path / ".no_scrub")) # absent -> pii on + scrub = tmp_path / ".scrub"; scrub.write_text("") # present -> scrubbing ON (opt-in) + monkeypatch.setattr(sp, "SCRUB_SENTINEL", str(scrub)) monkeypatch.setattr(sp, "FULL_REDACTION_SENTINEL", str(tmp_path / ".full_redaction")) @pytest.fixture def presidio_off(tmp_path, monkeypatch): - """Force the legacy regex path (Presidio marker absent).""" + """Force the legacy regex path (Presidio marker absent), scrubbing ON.""" monkeypatch.setattr(sp, "PRESIDIO_MARKER", str(tmp_path / ".presidio")) # absent - monkeypatch.setattr(sp, "NO_SCRUB_SENTINEL", str(tmp_path / ".no_scrub")) + scrub = tmp_path / ".scrub"; scrub.write_text("") # present -> scrubbing ON (opt-in) + monkeypatch.setattr(sp, "SCRUB_SENTINEL", str(scrub)) monkeypatch.setattr(sp, "FULL_REDACTION_SENTINEL", str(tmp_path / ".full_redaction")) @@ -101,12 +103,15 @@ def test_canary_secret_never_leaves_box_pii_on(presidio_on): assert any(v == SECRET for v in mapping.values()) -def test_canary_secret_never_leaves_box_pii_off(presidio_on, monkeypatch): - # PII off (the user's default): names may pass, but SECRETS must STILL never leave. - monkeypatch.setattr(sp, "NO_SCRUB_SENTINEL", os.devnull) # exists -> pii off - obj, _, _ = sp._anonymize_request(_request_with_secret_everywhere()) +def test_default_off_is_full_fidelity(presidio_on, tmp_path, monkeypatch): + # DEFAULT = no .scrub marker: nothing is redacted, so the agent keeps full fidelity + # (it can read/use secrets during real work, e.g. authorized pentesting). Privacy of the + # hosted path still comes from og-veil's OHTTP + TEE, not from this bridge. + monkeypatch.setattr(sp, "SCRUB_SENTINEL", str(tmp_path / ".absent")) # absent -> scrub off + obj, n, mapping = sp._anonymize_request(_request_with_secret_everywhere()) wire = json.dumps(obj) - assert SECRET not in wire, "SECRET leaked with PII off (secrets must always be scrubbed)" + assert SECRET in wire, "with scrubbing off (default), the real secret must pass through" + assert n == 0 and mapping == {}, "scrub-off must be a pure pass-through" def test_canary_legacy_path_also_scrubs_whole_body(presidio_off): @@ -269,7 +274,7 @@ def test_stream_multiline_data_frame_deanonymized(): def test_catalog_is_open_weight_only(): # ghost must only offer/allow OPEN-WEIGHT models; no closed/refusing ones. allowed = sp._ALLOWED_GATEWAY_MODELS - assert allowed == {"hermes-4-405b", "hermes-4-70b", "deepseek-v4-pro", "glm-5.2"} + assert allowed == {"hermes-4-405b", "hermes-4-70b", "deepseek-v4-pro"} blob = json.dumps(sp._CATALOG_MODELS).lower() for closed in ("claude", "gpt-", "gemini", "grok", "anthropic", "seed-"): assert closed not in blob, f"closed model '{closed}' must not be in the catalog"