diff --git a/AGENTS.md b/AGENTS.md index 8edd7ad2..11868f86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ Gateway daemon │ granted catalog skills) │ resolve runtime: normal → ensure the channel container; admin-authenticated `/sudo` thread → host │ (container: HOME volume, work folder + clean workspace + artifact dir at identical paths, - │ control socket read-only, bridge network; host: daemon OS account and native HOME/toolchain) + │ control socket read-only, --network none + the per-channel egress proxy socket; + │ host: daemon OS account and native HOME/toolchain) │ resolve session: thread key → engine session id (resume) | fresh (memory catalog prepended) │ build MCP config: gateway control (socket bridge) + composio-user (author) + composio-agent │ (channel → org token) + selected catalog/plugin servers → a 0600 file in the artifact dir @@ -78,7 +79,7 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u the changed keys), `dead-fields.js` (retired fields stripped on every write). - `src/db/` — `index.js` (the one lazy `node:sqlite` connection: WAL, `busy_timeout`, `foreign_keys`, migrations on open, the one-time legacy JSON import behind `_meta` flags), - `migrations.js` (versioned on `PRAGMA user_version`, currently 29 — append, never edit), + `migrations.js` (versioned on `PRAGMA user_version`, currently 30 — append, never edit), `import-legacy.js`, `fts.js` (the optional FTS5 `channel_memory_fts` index; without FTS5 memory search degrades to a scan). - `src/gateway/run.js` — the run orchestrator: engine adapter selection and precedence (per-run @@ -118,7 +119,7 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u writes) and `admin` (Worker for members; `--dangerously-skip-permissions` only for a trusted admin author), the two independent options **Auto** (`autoMode`: tool requests auto-approved) and **Lean** (`cleanMode`: no optional skills or connectors; an admin author in an Admin channel - keeps full context), the orthogonal advisory network switch, plus `isAuthorized()` (who may + keeps full context), the orthogonal network switch (enforced by the egress proxy), plus `isAuthorized()` (who may talk) and `canManage()` (who may change a channel's access settings). A mode is a TOOL preset; what the container mounts is decided by the runtime backend alone. - `src/gateway/skills/` + `access-grants.js` + `plugin-runtime.js` + `run-grant-artifacts.js` — @@ -146,11 +147,13 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u `claude setup-token` → the OPERATOR's `$CLAUDE_CONFIG_DIR`/`~/.claude` login → a login signed in to the gateway's engine home → `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` → a named remedy. The operator's login is NEVER copied, linked or mounted; every container run receives a RELAY of the - resolved login's current ACCESS token in `CLAUDE_CODE_OAUTH_TOKEN`, refreshed by a cheap turn in - that login's own config dir. The container credential modes, the health probe, the boot log, + resolved login's current ACCESS token in `CLAUDE_CODE_OAUTH_TOKEN` (behind the egress proxy, a + placeholder the proxy swaps for it), refreshed by a cheap turn in that login's own config dir. The container credential modes, the health probe, the boot log, `/status` and the hourly login watch (which DMs admins at most once a day per message class - while the login is expiring or missing) all read the same resolver. Codex's twin is - `src/engines/codex-auth.js`. + while the login is expiring or missing) all read the same resolver. Codex's twins are + `src/engines/codex-auth.js` (is Codex signed in) and `src/gateway/codex-token-relay.js` (which + login a container run relays, its cheap-turn refresh in the login's own `CODEX_HOME`, and the + access-only `auth.json` a container receives). - `src/gateway/{background,scheduler,followups,nudges,loops,api-runs}.js` — daemon-side automation: background shell jobs and background agents that outlive the turn and report back into the thread, cron/one-time schedules (with the daily-thread delivery mode), pending-response @@ -167,6 +170,24 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u `channel_instructions`) that never expire and re-post after a restart, and signed single-use browser links (`/approve/`: GET decides nothing, POST decides exactly once and re-checks authority) for surfaces without Block Kit and for automation. +- `src/gateway/egress/` — the **egress proxy** (container-secrets P2), a proxy-mode container's + only network. The core (no database, no settings: `proxy.js` the HTTP/1.1 CONNECT proxy that + TLS-terminates with the per-deployment CA of `ca.js`/`x509.js`, `policy.js` the destination + policy with SSRF-checked pinned addresses, `rules.js` the placeholder → value swap in headers and + query, `scrub.js` the response scrubber, `placeholders.js` the `cgph_…` format; `index.js` is its + barrel) and the integration: `service.js` (boot, one unix listener per channel under + `/eg/<12 hex>/`, the per-request policy from the channel's CURRENT meta, the swap gate, + audit, the container backend's hook), `grants.js` (migration-30 `egress_grants`: placeholder → + scope/channel/owner/name, never a value; live value resolution; revocation; + `resolveEgressRunEnv`, `containerClaudeCredential` and `containerCodexCredential` for every spawn + site), `liveness.js` + (turns, jobs, reviews, SSH sessions per channel — personal grants swap only while their owner is + live and no other person's turn, job or SSH session is live there), `catalog-rules.js` (built-in rules per + credential name + validation of an entry's "used on hosts"), `engine-hosts.js`. The container + half: `src/runtimes/container/egress-hook.js` (the plan a target carries), `egress-env.js` (the + ONE proxy/CA env list), `src/mcp/egress-forwarder.js` (staged into the image as + `bin/cg-egress.mjs`) and `src/mcp/egress-connect.js` (the SSH `ProxyCommand` helper, staged as + `bin/cg-egress-connect.mjs` behind the POSIX shim `containers/bin/cg-egress-connect`). - `src/gateway/ssh-access.js` + `ssh-broker.js` + `ssh-session.js` + `src/mcp/tools/ssh-access.js` — SSH access to channel containers (`docs/SSH-ACCESS.md`): the per-person key registry (`ssh_keys`), the per-channel `sshUsers` grant list, the host `authorized_keys` export (every line @@ -207,8 +228,9 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u network-off, no MCP, outside the failover graph; its CLI is not shipped in the runtime image). Shared: `stream.js` (NDJSON → deltas/events), `watchdog.js` (the ONE stall watchdog), `child-env.js` (the child env and passthrough list; `buildClaudeEnv`/`buildCodexEnv`/ - `buildOpenCodeEnv` live in their runners), `network-policy.js` (the advisory switch checked - against the engine's declared `networkModes`), `model-discovery.js`, `engine-health.js`, + `buildOpenCodeEnv` live in their runners), `network-policy.js` (the switch checked against the + engine's declared `networkModes`, and `networkEnforcedFor(target)` — enforced by the egress proxy, + advisory only under the legacy bridge mode or a channel's `rawNetwork`), `model-discovery.js`, `engine-health.js`, `codex-auth.js` (is Codex signed in — reads the CLI's own `auth.json`), `loop-wakeup.js`, `runtime-target.js`. `adapters.js` reads `claude-login.js` lazily because a static import would close a cycle through `config/settings.js`. @@ -229,15 +251,23 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u `versions.json` (`imageSpecVersion` + every pin: Claude Code, Codex, `mcp-remote`, `vercel`, `supabase`, Playwright + `agent-browser` with Chromium, Python with OpenCV and faster-whisper; apt adds `gh`, `git`, `ffmpeg`, `ripgrep`, `psql` and friends) and `bin/` (the POSIX `cg-*` - helpers: init, exec, probe, signal, sweep, the MCP socket bridge shim). `scripts/build-image.mjs` - stages only the import closure of the engine-spawned helpers, never the checkout. + helpers: init, exec, probe, signal, sweep, the MCP socket bridge shim; `cg-init` also starts the + egress forwarder under `CG_EGRESS=proxy`). `scripts/build-image.mjs` stages only the import + closure of the engine-spawned helpers plus the verbatim socket bridge and egress forwarder, + never the checkout. - `src/mcp/` — `gateway-server.js` is a thin assembler over `tools/` (`background`, `channel-admin`, `license`, `schedules`, `skills`, `slack-native`, `tokens`, `workspace-read`), each registering its tools behind the verified capability; `socket-server.js` serves it on the daemon side over `~/.channelgate/run/mcp.sock` (bind-mounted read-only at `/run/channelgate`, no DB mount, no port), `socket-bridge.js` is the container-side stdio↔socket pipe copied into the - image verbatim, `secret-env-bridge.js`/`remote-secret-bridge.js` launch credentialed stdio and - remote MCPs from a 0600 bundle so a secret never rides argv. + image verbatim, `secret-env-bridge.js` launches credentialed stdio MCPs from a 0600 bundle so a + secret never rides argv (`remote-secret-bridge.js` was retired in container-secrets P4). In a container the header-bearing + remotes (Composio token mode, the toolboxes) are the socket's `remote-mcp` service instead: + `remote-mcp-registry.js` holds their URL + headers in daemon memory under the capability's jti, + `remote-relay.js` dials them and relays the tools, so the credential never enters the container. + `egress-forwarder.js` is the container-side half of the egress proxy (127.0.0.1:3128 → the + channel's egress socket), copied into the image verbatim like `socket-bridge.js`; + `egress-connect.js` is the SSH `ProxyCommand` over that forwarder (`CONNECT host:port`). - `src/platforms/` — the CHAT-SURFACE layer, to chat platforms what `src/engines/` is to engines. `contract.js` (the closed capability spec + fail-closed adapter validation), `registry.js` + `adapters.js` + `slack.js`/`googlechat.js`/`msteams.js` (per-platform FACTS: what renders, what @@ -289,7 +319,8 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u `cg-ssh-attach.mjs` + `cg-ssh-authorized-keys`, `install-whisper.mjs`), updates (`update.sh` → `update-runner.mjs`), backup and restore (`backup-config.sh`, `restore-config.sh`, `restore-drill.sh`, `runtime-maintenance.mjs`), the image - (`build-image.mjs`), the checks (`run-tests.mjs`, `static-check.mjs`, `secret-scan.mjs`, + (`build-image.mjs`), the checks (`run-tests.mjs`, `static-check.mjs` + `static-secret-writes.mjs` + — no secret-resolver value written under an artifact dir, `secret-scan.mjs`, `security-coverage.mjs`, `check-dco.mjs`), the nightly CLI canaries, release (`release-artifacts.mjs`, `check-release-candidate.mjs`, `reviewed-artifact-fixtures.json`), the landing lock (`with-landing-lock.mjs`, a Git ref with liveness checks) and the one-time @@ -301,8 +332,10 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u anything that opens the database; the runner fails if a test touched the real home), `fixtures/` (fake engines, plugins, a fake runtime backend), and two `*.live.test.js` files that self-skip unless `CG_LIVE_CONTAINER=1` / `CG_LIVE_PLUGIN_CONTAINER=1`. -- `~/.channelgate/` — the runtime root (`gateway.db`, `config/`, `channels///` - metadata mirrors, `logs/`, `run/mcp.sock`, `run-tmp/`, `engine-state/`, `clean-workspaces/`, +- `~/.channelgate/` — the runtime root (`gateway.db`, `config/` (with `egress-ca/`), + `channels///` metadata mirrors, `logs/`, `run/mcp.sock` + `run/egress-ca.pem` + (the trust bundle), `eg/<12 hex>/egress.sock` (per-channel egress sockets), `run-tmp/`, + `engine-state/`, `clean-workspaces/`, `skill-backups/`, backups, update state). `~/ChannelGate///` — the visible work folders (or a per-channel custom path), with the hidden `.runtime//` artifact tree beside them. The `` component is the adapter's `folderName` fact (`slack` / @@ -357,15 +390,23 @@ Config that stays as **files** (read wholesale / bootstrap, hand-editable): volume at `/home/agent` (engine sessions, CLI logins, installed tools) and, bind-mounted at their identical absolute paths, ONLY the channel's work folder, its clean workspace and its artifact dir (`~/ChannelGate/.runtime//`, which also backs `/tmp` and - `/var/tmp`), plus the read-only control socket. Nothing else exists on that side: no host home, - no gateway root, no `gateway.db`, no other channel's folder, no daemon checkout, no operator - `~/.claude`/`~/.codex`. Every container runs on the default bridge network: the per-channel - *Allow network* switch (`allowNetwork`) tells the engines whether the channel is meant to have - network and is checked against the engine's declared `networkModes` (OpenCode refuses "on", - Codex read mode refuses network on its own), but there is no domain filtering and, in this - release, no egress cut-off (`NETWORK_POLICY_ENFORCED = false`) — the boundary today is the - container's filesystem and process isolation, not its egress; a container-side egress proxy is - the planned follow-up. Every channel folder still gets the lockdown file + `/var/tmp`), plus the read-only control socket, the channel's OWN read-only egress socket + directory and the CA trust bundle. Nothing else exists on that side: no host home, + no gateway root, no `gateway.db`, no other channel's folder or egress socket, no daemon checkout, + no operator `~/.claude`/`~/.codex`. Every container runs with `--network none`: its only way out + is the daemon's egress proxy (`src/gateway/egress/`) over that per-channel socket, whose PATH is + the channel identity. The per-channel *Allow network* switch (`allowNetwork`) is the proxy's live + policy — off admits only the engine endpoints and the channel's selected remote MCPs, on admits + public destinations, and private/loopback/metadata addresses are always refused — and is also + checked against the engine's declared `networkModes` (OpenCode refuses "on", Codex read mode + refuses network on its own); there is no per-domain allow-list. A proxy-mode container holds no + ruled secret and no Claude login: it holds placeholders the proxy swaps for real values only on + their declared hosts and only while the channel has live work (`NETWORK_POLICY_ENFORCED = true`, + asked per target through `networkEnforcedFor`). Two operator/admin escapes make the switch + advisory again and are reported as such everywhere: the gateway-wide LEGACY + `containerEgressMode = "bridge"` (the open bridge and raw secret values) and a channel's + admin-set `rawNetwork` (the bridge beside the proxy). A proxy that cannot start never opens the + bridge: proxy-mode runs fail closed with the remedy. Every channel folder still gets the lockdown file (`autoMemoryEnabled:false`, `autoDreamEnabled:false`, curated `permissions.allow`, the MCP allowlist, the Stop hook) — it carries POLICY, never a `sandbox` block, and nothing a run can do changes what its container mounts. The one process-boundary escape hatch is typed Slack @@ -454,14 +495,22 @@ Config that stays as **files** (read wholesale / bootstrap, hand-editable): pool fingerprint), a background job resolves at ITS own spawn because it outlives the run, and values are redacted out of replies, the live stream, and job output — write-only in the UI is not write-only at runtime: a run's process can read its own environment, and every attempt is - told the injected NAMES so it can use them without printing them. + told the injected NAMES so it can use them without printing them. In a proxy-mode container a + secret with an egress rule (`src/gateway/egress/catalog-rules.js`, or the entry's own "used on + hosts") is only its PLACEHOLDER — stable per scope/channel/owner/name, swapped for the live value + by the proxy on the declared hosts — so rotation no longer needs to retire anything; the + redactor still carries every REAL value, and a secret with no rule is raw and listed unprotected + (withheld under `containerEgressSecretsStrict` — on for new installs, pinned off at boot for installs that predate it; `list_secrets` reports each such name as a finding). Every spawn site resolves through + `resolveEgressRunEnv`, never `resolveRunEnv` directly. - **The gateway uses the operator's own Claude login, and never copies a credential file.** Which login answers a turn is decided ONLY by `src/gateway/claude-login.js`; no other module may stat, read, link or copy a `.credentials.json`. Claude Code writes that file by RENAME and rotates the refresh token on every refresh, so any second copy that refreshes logs the first one out — that is exactly how the gateway's own engine-home copy silently expired while the operator stayed signed in. A run receives a RELAY of the resolved login's ACCESS token instead, gateway-owned and - applied last in the child env so a channel secret cannot displace it. A turn with no resolvable + applied last in the child env so a channel secret cannot displace it — and in a proxy-mode + container not even that: the channel's relay PLACEHOLDER (`containerClaudeCredential`), which + the egress proxy swaps for the current access token on `api.anthropic.com` only. A turn with no resolvable login fails closed with the remedy named; it never runs on a guessed credential. A new consumer asks the resolver; it never adds a second notion of "the login". The ONE login file the gateway writes is an interactive SSH session's (`src/gateway/ssh-session.js`): an ACCESS-ONLY @@ -470,7 +519,14 @@ Config that stays as **files** (read wholesale / bootstrap, hand-editable): written through the relay, refreshed with it, and removed when the channel's last session ends. Claude Code labels a token in the environment "Claude API" and hides the plan, the usage windows and the plan's default model; only a file login shows them, which is what a developer in a - terminal needs to see. + terminal needs to see. Codex is the twin (`src/gateway/codex-token-relay.js`): behind the egress + proxy a container never mounts the operator's `auth.json`; the runner writes an ACCESS-ONLY + `auth.json` into the channel's HOME volume before every Codex run (and an SSH session) whose + access token is the channel's relay placeholder in JWT shape — the real claims, `cgph_r…` as the + signature — and whose refresh token is empty, the proxy swaps the WHOLE token on the OpenAI/ChatGPT + hosts only, and the daemon renews the real login with a cheap ephemeral `codex exec` in the + login's own `CODEX_HOME`. It is written by rename, never through a mount. Only the legacy bridge + mode and an API-key login keep the shared read-write file mount. - **Only admins get `--dangerously-skip-permissions`**, and only in an Admin-mode channel (a live Slack turn by an admin author, or a live HTTP run API turn, whose key is an admin credential acting as the `api` principal — `src/config/api-principal.js`). Everyone else runs with the folder's `permissions.allow` allowlist and answers tool requests diff --git a/CHANGELOG.md b/CHANGELOG.md index 010cbeb4..af48d8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,85 @@ product overview. ## Unreleased +- **Codex's sign-in no longer sits in every channel container.** Until now each channel container + had the gateway host's real Codex login file, refresh token included, mounted read-write, so one + channel could read it and every channel shared it. Behind the egress proxy a container now gets + its own access-only sign-in: a stand-in token the proxy swaps for the real one on OpenAI's and + ChatGPT's servers, and no refresh token at all. The gateway keeps the real login fresh with a + cheap Codex turn on the host when it is two days from expiring. Keep the host signed in with + `codex login` as before. Each channel container is recreated once on its next turn. The legacy + open-network mode and an API-key Codex login still mount the real file. Rebuild the image + (`npm run build:image`, still spec 1.6.0): `cg-init` now also removes an old copied Codex login + from a channel's home. +- **Secrets without an egress rule are withheld by default on new installs.** On a fresh install + *Withhold unprotected secrets* starts on: a secret with no built-in rule and no *Used on hosts* is + not given to channel containers. Existing installs keep their current behavior; turn it on in + Settings → Container runtime. `list_secrets` now ends with a **Finding** that names every secret + without a rule and says how to protect it. +- **A check keeps secrets out of the files channel containers can read.** `npm run check:static` + now fails when code writes a token, a relay's token or a resolved run environment into a + channel's artifact folder. The unused `remote-secret-bridge` helper is removed. + +- **SSH and VS Code sessions hold placeholders, not secrets.** A developer's SSH session into a + channel container now gets the same `cgph_…` placeholders a turn gets, and Claude's login in the + session (and the editor's token file) is the channel's login placeholder. Nothing a session + writes holds a real protected value. Tools such as `gh` and `vercel` work as before through the + gateway's egress proxy. The proxy settings reach every SSH shell, VS Code terminal and your own + `agent-browser`. The editor token file is now also removed when the channel's last session ends. +- **Your personal secrets pause while someone else is attached.** While another person has an SSH + session open in a channel, your personal secrets stop working there. Everyone else's stop working + while you are attached. The agent's credential note says they are paused rather than failing + with an unexplained 403. +- **Outbound SSH from a session goes through the egress proxy.** The image ships + `/opt/channelgate/bin/cg-egress-connect`, an SSH `ProxyCommand` through the egress proxy. A + session's `GIT_SSH_COMMAND` already uses it (github.com only, with *Allow network* on). For your + own `ssh`, add `-o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p'`. The proxy + cannot supply an SSH key. `ssh -L` forwards to hosts outside the container no longer work; + forwards to the container's own ports and `-R` are unchanged. Rebuild the image + (`npm run build:image`, still spec 1.6.0) to get the helper. + +- **Channel containers now reach the internet only through the gateway's egress proxy.** Every + channel container runs with no network of its own. A small forwarder inside it hands each + connection to the gateway, which enforces the channel's *Allow network* switch on every request + (off: only the AI engines and the channel's selected connectors; on: public hosts, never private, + loopback or cloud-metadata addresses) and logs every refused or blocked destination. The switch + is no longer advisory, and `/mode`, `/status` and the run record say so. A flip applies to the + next request without recreating anything. +- **Secrets become placeholders the container cannot use elsewhere.** A GitHub, Vercel, Supabase, + Make or Composio token, and any secret an admin marks with **Used on hosts**, reaches the + container as a `cgph_…` placeholder. The gateway swaps in the real value only on that secret's + hosts and only while the channel has work running; a personal secret only while its owner is the + one working, and never while another person's turn, background job or SSH session is active + there. The relayed Claude + login is a placeholder too. Rotation takes effect on the next request; removing a secret kills + its placeholder. Secrets without a rule are still injected as before and are marked + **unprotected** in `list_secrets`, the admin UI and the agent's own credential note. The new + *Withhold unprotected secrets* setting keeps them out of containers entirely. +- **New container settings.** Settings → Container runtime gains *Legacy open network (no egress + proxy)* — off by default; on restores the old open bridge network and raw secrets — and + *Withhold unprotected secrets*. The admin API accepts `rawNetwork` (a channel that needs raw + sockets beside the proxy) and `egressRawHosts` (hosts whose SSH/Postgres ports are tunnelled). + If the proxy cannot start, container runs stop with the reason instead of running unprotected. + After a restart, containers that kept running get their network back immediately. Avoid + multi-tenant host suffixes such as `*.vercel.app` in *Used on hosts*. A Qwen provider key and a + daemon `CODEX_API_KEY` are still given to containers as real values, and a self-hosted Qwen + endpoint on a private address cannot be reached in proxy mode. + TLS clients in the container trust the gateway's own CA through the usual CA variables; a tool + that reads none of them needs `/run/channelgate/egress-ca.pem`. After updating, run + `npm run build:image` (image spec 1.6.0 now also ships the forwarder; the updater does this for + you). +- **Fix: Codex in Composio SDK mode works in containers.** A Codex run in a channel container with + Enterprise SDK-mode Composio started its Composio connection without the run's grant, so the + gateway refused it and Codex had no Composio tools. It now connects like the other relayed + servers. +- **Composio and toolbox tokens no longer enter a channel container.** In a container, the + `composio-user`, `composio-agent`, MakeItFuture toolbox and Make toolbox connections are now + reached through the gateway itself: the engine holds only its signed run grant, and the daemon + dials the service with the real token and relays the tools. Before, the token sat in a file in + the channel's run folder that any process in the container could read. This applies to Claude + and Codex turns and to SSH sessions. Direct-host `/sudo` threads are unchanged. A rotated token + still restarts the warm Claude process. After updating, run `npm run build:image` (image spec + 1.6.0; the updater does this for you). - **Agents can now explain how to set up a channel's VPN.** The chat operating manual has a channel VPN page. It covers who may turn the VPN on or off, read its status or query the database. It also has the host-operator runbook for another channel, with the exact diff --git a/FEATURES.md b/FEATURES.md index eda1206c..e7cbf4da 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -152,8 +152,10 @@ revisions on failed sync. Details and compatibility limits: `docs/SKILLS.md`. - **Current network policy accompanies every attempt:** fresh, resumed, recovered and fallback prompts state the resolved network switch, including Clean runs. An off switch instructs the engine to explain the current restriction rather than present a cached response as a new request. - This remains advisory policy, not container egress enforcement; other tool restrictions still - apply. The added fact contains no credential inventory or values. → TEST-PLAN: Network policy on resumed turns. + The note states whether THIS attempt's container enforces the switch (the egress proxy is its + only network) or only advises it (the legacy bridge mode, a `rawNetwork` channel, a `/sudo` host + thread); other tool restrictions still apply. The added fact contains no credential inventory or + values. → TEST-PLAN: Network policy on resumed turns. - **Control-plane results report received human approval:** a tool that awaited an approving decision returns a receipt alongside its original outcome, including returned refusals or error @@ -232,7 +234,8 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. await every server before its first model request. → TEST-PLAN: MCP startup budgets. - **Accurate channel-control descriptions:** MCP mode/network descriptions and confirmations - describe the container boundary, advisory networking and separately resolved operator-home + describe the container boundary, the egress proxy's enforcement of the network switch (advisory + only where the proxy is not the channel's network) and the separately resolved operator-home grant. They do not promise domain filtering or automatic access to host credentials. Auto distinguishes engine tool approval from explicit control-plane sign-offs. → TEST-PLAN: Channel-control description accuracy. @@ -761,7 +764,9 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. token never appears in Codex's argv or environment. Remote servers still get `startup_timeout_sec=120` (gateway control server 60) as a ceiling on the handshake itself. Before this, the `mcp-remote` bridge needed ~2.4 s to answer `tools/list` and made only the cold - window, so the SECOND turn of a Codex thread had no Composio tools at all. + window, so the SECOND turn of a Codex thread had no Composio tools at all. Since the + remote-MCP relay (below, Container runtime) this native transport is the sudo-host shape; in a + channel container the same servers are relayed by the daemon over the control socket. → TEST-PLAN: Background jobs, MCP. - Resilient image/file attachments: every accepted Slack trigger is hydrated from the exact canonical root/reply before processing; `message` and `app_mention` delivery is deduplicated by @@ -1592,7 +1597,7 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. - **Retired 2026-09-03 (Linux + containers only):** the host permission profiles, the semantic network modes and the `network_proxy` compilation — inside its container Codex runs `--sandbox read-only` in read mode (which refuses network on its own) and `danger-full-access` for write modes, with no permission profiles; the container itself - is on the bridge network. Codex confinement mirrors the channel via Gateway-owned permission profiles (never the legacy + has no network of its own and goes out through the egress proxy. Codex confinement mirrors the channel via Gateway-owned permission profiles (never the legacy broad-read sandbox modes): `gateway-readonly` (root denied, minimal runtime paths + workspace readable, nothing writable) by default, `gateway-workspace` (adds workspace write + a private per-run scratch dir as TMPDIR; `.git`/`.codex` stay read-only) when Bash/Auto mode is on, profile @@ -1665,9 +1670,8 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. `NODE_USE_ENV_PROXY=1` so Node-based CLIs honor its destination-restricted network proxy; this changes proxy consumption, not the approved-domain boundary. → TEST-PLAN: CLI integrations. - **Retired 2026-09-03 (Linux + containers only):** the tool, the card and `extraNetworkDomains` went with the host sandbox's allow-list — *Allow - network* stays as a per-channel switch the engines are told about, with no domain filtering and, - in this release, no egress cut-off in the container (every container is on the bridge network; - a container-side egress proxy is the planned follow-up). **Per-channel domain approvals (`request_network_domain`)**: when a sandboxed command fails on a + network* stays as a per-channel switch the engines are told about, with no domain filtering; the + container-side egress proxy enforces it (off/on, private addresses always refused). **Per-channel domain approvals (`request_network_domain`)**: when a sandboxed command fails on a blocked host, the agent may request ONE named domain; the gateway posts an Approve/Deny card that ANY authorized user can approve (the human click is the control — injected content can request but never click). Approved domains persist in the channel meta, join that channel's @@ -2194,18 +2198,19 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. (Bash + Write/Edit with writes kept away from the gateway root, `.ssh`, `.aws`, `.config`, `.claude`, keychains and every other channel's folder) is the container boundary instead. → TEST-PLAN: Sandbox boundaries. -- **Allow Network is an ADVISORY per-channel switch, not an egress boundary.** Every channel's - container runs on the default bridge network and the gateway polices no egress per channel, so - "off" does not cut the container off — it states the channel's intent. That intent is now said - out loud everywhere instead of being inferred from a missing suffix: the mode label carries the - state in BOTH directions (`Read-only · network off` / `Bash · network on`), `/mode` and `/status` - add the caveat (`network off (advisory — not enforced by the container yet)`), the gateway-managed - block at the top of every conversation's instruction file states the mode and the network switch - to the engine itself, and the `run_config` event records `networkEnforced: false` beside - `networkPolicy` so an operator reading it after an incident cannot mistake `"off"` for "this turn - could not reach the internet". A container-side egress proxy that actually enforces the switch is - a later slice; `NETWORK_POLICY_ENFORCED` in `src/engines/network-policy.js` is the one flag every - surface reads. → TEST-PLAN: Sandbox boundaries. +- **Allow Network is enforced by the egress proxy, and says so where it is only advisory.** A + proxy-mode container (the default) has `--network none`, and the per-channel switch is the + proxy's live policy (see Container runtime → egress proxy). Under the legacy + `containerEgressMode = "bridge"`, a channel's `rawNetwork` escape or a `/sudo` host thread the + switch only states intent, and that is said out loud everywhere instead of being inferred from a + missing suffix: the mode label carries the state in BOTH directions (`Read-only · network off` / + `Bash · network on`), `/mode` and `/status` add the caveat (`network off (advisory — not enforced + for this container)`) only where it applies, the gateway-managed block at the top of every + conversation's instruction file states the switch AND whether it is enforced, and the + `run_config` event records `networkEnforced` and `egress` beside `networkPolicy` so an operator + reading it after an incident cannot mistake one for the other. `NETWORK_POLICY_ENFORCED` in + `src/engines/network-policy.js` is now `true`; every surface asks `networkEnforcedFor(target)`. + → TEST-PLAN: Sandbox boundaries; Egress proxy, placeholders and `--network none`. - **Retired 2026-09-03 (Linux + containers only):** nothing replaces it — with no host sandbox there is no user namespace for AppArmor to restrict, and rootless Podman brings its own uid mapping (`--userns=keep-id`, `/etc/subuid`). **Linux hosts keep their Bash sandbox under Ubuntu's AppArmor userns restriction**: Ubuntu 23.10+ (24.04 LTS included) stacks any unconfined process that creates a user namespace into a @@ -2321,9 +2326,10 @@ are retired, bullet by bullet; everything else stands. sides: the channel's resolved workdir, the clean workspace (so clean mode works in a container), and the per-channel artifact dir `~/ChannelGate/.runtime//` (0700) that holds this run's engine-facing files. The channel's HOME is a **named volume** at `/home/agent`; the daemon's - MCP socket directory is mounted **read-only** at `/run/channelgate`; the Codex `auth.json` is a - single-FILE mount (the one deliberate exception to "mount directories, never single files" — see - the credential bullet); `/tmp` and `/var/tmp` are per-channel bind mounts too, for durability (own + MCP socket directory is mounted **read-only** at `/run/channelgate`; outside the egress proxy the + Codex `auth.json` is a single-FILE mount (the one deliberate exception to "mount directories, + never single files" — see the credential bullet; behind the proxy Codex is relayed with no mount, + container-secrets P4); `/tmp` and `/var/tmp` are per-channel bind mounts too, for durability (own bullet below). Hardening: tmpfs `/run` only (64m, noexec — pid files and the socket mount, which must be fresh at every start); `--cap-drop ALL` plus only `DAC_OVERRIDE`/`CHOWN`/`FOWNER`; `--security-opt no-new-privileges`; @@ -2411,7 +2417,8 @@ are retired, bullet by bullet; everything else stands. the thread-bound background, progress and approval tools) with `composio-user` for the developer's own accounts, `composio-agent` for the channel's and the selected catalog servers, `--strict-mcp-config` so the operator's own claude.ai connectors never load, and the run - environment (`resolveRunEnv` + `safeSpawnEnv`) sourced by the `claude` wrapper. Codex gets the + environment (`resolveEgressRunEnv` + `safeSpawnEnv`, placeholders behind the egress proxy — see + "SSH and VS Code sessions on placeholders") sourced by the `claude` wrapper. Codex gets the same session through its own `codex` wrapper (QA-0925: over SSH it had no gateway MCP, no Composio and no secrets): the session also writes `codex-args.sh` — exactly the `-c mcp_servers.*` / `apps.*` overrides a chat turn's Codex gets, with a Codex-minted gateway capability and the @@ -2529,12 +2536,14 @@ are retired, bullet by bullet; everything else stands. at all, and no forked refresh chain: an access token cannot rotate anything, whereas a copy that refreshes would log the gateway out (it did, live, on 2026-09-02). The daemon's own `ANTHROPIC_API_KEY` is the third mode, for service installs with no login. With none of them the - mode is **missing** and a Claude turn fails closed. Codex is the opposite case — it rewrites `auth.json` - IN PLACE, so a copy would fork the refresh chain and one side would eventually lose the race — and - therefore gets a shared read-write FILE mount of the gateway's real auth file. What is ISOLATED + mode is **missing** and a Claude turn fails closed. Codex rewrites `auth.json` IN PLACE, so a copy + would fork the refresh chain and one side would eventually lose the race; behind the egress proxy + it is therefore RELAYED like Claude (an access-only file with a placeholder token, written by the + runner — container-secrets P4), and only the legacy bridge mode or an API-key login still gets a + shared read-write FILE mount of the gateway's real auth file. What is ISOLATED per channel: the whole HOME volume — CLI logins, npm prefix, dotfiles, caches, the Claude config dir (transcripts, prompt history, todos, shell snapshots) and `CODEX_HOME` (sessions, history). - What is SHARED: the Codex sign-in file, and nothing else; `describe()` compares its inode so a + What is SHARED (bridge mode / API-key login only): the Codex sign-in file, and nothing else; `describe()` compares its inode so a container still holding a login the host has since replaced is visible rather than silently stale, and `/status` carries the caveat in words. → TEST-PLAN: Container runtime (v0.8 P1). - **Inside the container, each engine's OWN sandbox is off** — the vendor-sanctioned pattern, and the @@ -2572,7 +2581,7 @@ are retired, bullet by bullet; everything else stands. anyway (every remote MCP is already bridged to stdio for it), so an HTTP control plane would have needed a bridge regardless. → TEST-PLAN: Container runtime (v0.8 P1). - **The socket's wire protocol is one line, then MCP.** The client writes a newline-terminated hello - (`{channelgate:"hello", v:1, service:"gateway"|"composio-sdk", cap, engine, toolset, + (`{channelgate:"hello", v:1, service:"gateway"|"composio-sdk"|"remote-mcp", cap, engine, toolset, progressReport, args, framed}`) and the daemon answers on the same socket with the MCP stream, preceded — only for a client that opted into `framed` — by one `{channelgate:"ready"}` line. A refusal is ALWAYS announced as `{channelgate:"error", reason}` before the socket closes, so the @@ -2593,6 +2602,226 @@ are retired, bullet by bullet; everything else stands. container cannot see. An unbindable socket path (over the 100-byte `sockaddr_un` budget, a read-only root) logs one line and never fails the boot — container runs then fail closed with their own message. → TEST-PLAN: Container runtime (v0.8 P1). +- **A remote MCP credential never enters a channel container (container-secrets P1).** The four + header-bearing remote servers a run may receive — `composio-user` and `composio-agent` (legacy + token mode and an explicit `endpoint.url` + `endpoint.headers`), `makeitfuture-toolbox` and + `make-toolbox` — used to reach a containerized engine WITH their token: Claude's `cg-mcp-*.json` + and Codex's per-run secret bundle (plus its `http_headers_helper` scripts) sit in the artifact + dir, which the container bind-mounts read-write, so any process in the box could read them. On an + isolated target they are now a third socket service, `remote-mcp`: the engine's entry is the same + socket bridge the gateway entry uses (`CG_MCP_SERVICE=remote-mcp`, the server NAME as its + argument; Codex reaches it through the secret-env-bridge so the capability still comes from the + 0600 bundle), the signed capability's optional `remoteMcps` claim lists the names, and the real + URL + headers are registered in the daemon's in-memory registry + (`src/mcp/remote-mcp-registry.js`) under the capability's `jti`, for exactly the capability's + lifetime at most (a turn's 6 h, an SSH session's 12 h; ≤ 16 servers, header values ≤ 8 KB, + never logged) and usually far less: the minting turn holds it until it settles, every open relay + connection holds it too, and it goes when the last hold is released — so a cold turn's grant + ends with the turn, a warm Claude process keeps its grant exactly as long as its bridge + connections stay open, and a turn that merely reused a warm process drops its own unused grant. + An SSH refresh releases the previous preparation's grants (a `claude` already running keeps + its relays through its open connections); the developer's last session ending drops them all; + clearing a personal Composio or Toolbox token (`clear_my_*_token`, or the admin UI) drops every + grant minted for that person's runs at once. A hello is relayed only when the claim names the + server AND the daemon holds a live registration for it; at most 8 relay connections per grant + and server exist at once (a slot is held until the upstream dial has settled, so hello-then-hang-up + cannot fan out dials), and a container that hangs up mid-dial never leaves the upstream client + open. The daemon dials the server (Streamable HTTP, HTTP+SSE on a 4xx; https only) and forwards + `tools/list` and `tools/call`, re-authorizing each, passing the engine's cancellation upstream and + upstream progress back (`src/mcp/remote-relay.js`). Refusals are fixed sentences that quote no + URL, header or upstream error; a failed forwarded request reaches the engine as `remote MCP + request failed` unless the remote itself answered with a JSON-RPC error, which passes unchanged. + A gateway hello carrying arguments (a pre-1.6.0 image's broker dropped the relay selection) is + refused with the rebuild remedy instead of serving the control plane under a relayed name. Codex's container bundle now holds ONLY + `gatewayCapability` and no headers helper is written; an SSH session's `mcp.json` and Codex + bundle follow the same rule. The warm pool's fingerprint carries a value-free sha256 digest of + each relayed URL + header, so a rotated Composio or toolbox token still retires the warm process. + A remote the relay cannot carry — plain http (a `COMPOSIO_MCP_URL`/`TOOLBOX_MCP_URL` override) or + a malformed header (not text, over 8 KB, a line break, a bad name, more than 16) — is dropped + from an isolated run on its own and named in the skipped-MCP note; the rest of the turn is + unaffected and the token is never handed to the container instead. Host (sudo) targets keep the direct http entries and headers + helpers byte for byte. Image spec 1.6.0 (the image's broker forwards `CG_MCP_SERVICE` / + `CG_MCP_SOCKET`). Where the egress proxy is the container's network (below) it also decides whether + the container can reach those endpoints at all with a credential of its OWN; either way it can no + longer read the gateway's. → TEST-PLAN: Remote MCP relay (container-secrets P1). +- **The egress proxy core (container-secrets P2).** `src/gateway/egress/` is an in-house HTTP/1.1 + proxy in Node built-ins only, served per connection over a unix socket the caller owns (the + socket PATH is the channel identity): `CONNECT` → the destination policy → a TLS-terminated + tunnel with a leaf from the per-deployment CA (`ca.js`: created once under + `config/egress-ca/`, `ca.key` 0600, ECDSA P-256 leaves for 24 h in a 512-entry LRU) → every + request forwarded over https to the PINNED address with the CONNECT host as SNI, headers swapped, + bodies streamed unbuffered, WebSocket upgrades swapped then piped, absolute-form plain http + forwarded, and a raw CONNECT tunnel (no TLS termination) for declared SSH/Postgres host:ports. + The policy (`policy.js`) resolves EVERY address and refuses a destination when any of them is + loopback, private, link-local (the metadata address included), CGNAT or reserved — IPv4-mapped + forms too — then dials the first address as a literal, so DNS rebinding is useless; mode off + admits only engine hosts. A placeholder (`placeholders.js`: `cgph_<32 base32>`, 160 + random bits; an `sk-ant-oat01-` shape for engines that check one) is swapped (`rules.js`) only on + a declared host, in a declared header, at a declared position (bearer, raw, the password or user + half of Basic), never partially, never across a Host-header mismatch, over plain http only when + the grant opts in, in query parameters only when listed; anything else is forwarded unchanged. A + `canUse` refusal answers 403 naming the secret and the reason (never naming it for a placeholder + presented from another channel). Every request is PINNED to the approved host: inside a tunnel an + absolute-form request line is 400 `absolute-form-in-tunnel`, a Host header naming another host + than the CONNECT/URL host is 403 `host-mismatch` (no domain fronting), a request without Host is + never swapped into, and the upstream always receives the approved host. A swapped request asks + for `accept-encoding: identity`, and its response is scrubbed (`scrub.js`) of every swapped value + in the header values and in any body that is text-like or declares no type — including a non-101 + answer to an Upgrade, now sent through Node's HTTP client — even across chunks; declared binary + bodies pass through. Deadlines and caps: DNS 5 s (504 `dns-timeout`), 16 destination decisions in + flight per channel (503 `too-many-lookups`), connect 15 s, response headers 60 s after the request + was sent (504). Upstream TLS is verified (`rejectUnauthorized`) against Node's roots plus the + host's CA bundle. Audit events carry names, counts and reasons only. HTTP/1.1 only (ALPN), request + bodies are never swapped, trailers are dropped. → TEST-PLAN: Egress proxy, placeholders and + `--network none`. +- **Channel containers run with `--network none` behind the egress proxy (container-secrets P2).** + The gateway setting `containerEgressMode` (Settings → Container runtime) is `"proxy"` by default: + every channel container is created with no network of its own, its channel's egress socket + directory (`/eg/<12 hex of sha256(platform|slug)>/egress.sock`, 0700/0600 — NOT under the + `run/` dir every container mounts) bind-mounted read-only at `/run/channelgate/egress`, and the + CA trust bundle (`/run/egress-ca.pem`, the host's system roots + the egress CA, written in + place at boot so a running container's file mount stays current) at + `/run/channelgate/egress-ca.pem`. `cg-init` starts the image's forwarder (`bin/cg-egress.mjs`, a + verbatim copy of `src/mcp/egress-forwarder.js`) on `127.0.0.1:3128` under `CG_EGRESS=proxy`; it + pipes each connection to the socket and RESETS the client when the daemon is unreachable. One + helper (`src/runtimes/container/egress-env.js`) sets `HTTP(S)_PROXY` (both cases), `NO_PROXY` + (loopback), `NODE_USE_ENV_PROXY=1`, ten CA variables (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, + `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `PIP_CERT`, `NPM_CONFIG_CAFILE`, + `CARGO_HTTP_CAINFO`, `AWS_CA_BUNDLE`, `DENO_CERT`) and `CG_EGRESS`, and removes `ALL_PROXY` — at + create time, FORCED in every exec env-file (a daemon `HTTPS_PROXY` cannot route a run around the + proxy) and in the gateway-owned last group of the Claude and Codex env; every one of those names + is a reserved secret name. Chromium gets `--proxy-server` and the CA's SPKI pin through + `AGENT_BROWSER_ARGS`. The daemon's service (`src/gateway/egress/service.js`) starts right after + the control socket; a failure logs ONE line and leaves the boot running, and every proxy-mode + run, background job and SSH container then fails before spawning with `egress proxy unavailable: + …` and the remedy — a down proxy never opens the bridge. `ensureUp` binds the channel's listener + before creating or starting the container, holds at most 256 connections per channel socket, and + at boot re-binds the listener of every proxy-mode container that kept running across the restart + (a recovered job, an attached editor or a process left from an SSH session keeps its network). + The forwarder half-closes cleanly (`allowHalfOpen` both ways) and logs to `/run/cg/egress.log`. + The network mode is in the MOUNT fingerprint too, so a network change (clearing `rawNetwork`) is + never deferred behind running work. Per request the policy reads the channel's CURRENT + meta: *Allow network* off admits the engine endpoints (Claude, Codex, a configured Qwen endpoint) + and the remote MCP hosts this channel's runs were handed; on admits any public destination; raw + tunnels go to `github.com:22` and the admin-declared `egressRawHosts` on 22/5432/6543. Audit rows + (`egress` events) only for a swap of a channel/organization/personal secret, a refusal, a blocked + destination or a raw tunnel — the relayed Claude login's swap on every API call is only counted + (`egressStatus()`, `/api/health` → `containerRuntime.egress`). Two escapes, both reported as + advisory everywhere: the LEGACY `containerEgressMode = "bridge"` (the open bridge and raw values, + for a host that cannot run the proxy) and a channel's admin-set `rawNetwork` (the bridge beside + the proxy, proxy env still set). Switching either recreates the container at its next idle moment + (the network mode is in the create-time fingerprint; the egress mounts are in both). + → TEST-PLAN: Egress proxy, placeholders and `--network none`. +- **A proxy-mode container holds placeholders, not secrets (container-secrets P2).** Every spawn site + — a turn, a background job, the memory reviewer — resolves its environment through + `resolveEgressRunEnv` (`src/gateway/egress/grants.js`): a secret with a swap rule becomes its + placeholder, stable per (scope, channel, owner, name) in migration 30's `egress_grants` + (organization: shared by every channel; channel; personal: per channel AND author) — the table + never holds a value. Rules are built in for credential NAMES (`catalog-rules.js`, fed by the CLI + catalog's new `swap` blocks: GitHub tokens and PATs on api.github.com / github.com / + uploads.github.com / *.githubusercontent.com incl. Basic-password for git over https; Vercel and + Supabase access tokens; Make API keys with `Token ` or x-api-key; `COMPOSIO_API_KEY`) or + declared by an admin on the entry ("Used on hosts" in the admin secrets editor, `hosts`/ + `headers`/`format` on `set_secret`: ≤ 16 DNS names or `*.suffix`, ≤ 8 lowercase headers, one of + bearer/raw/basic-password/basic-user), kept across a value rotation. Configuration names + (`GH_REPO`, `VERCEL_ORG_ID`) and raw-protocol passwords (`SUPABASE_DB_PASSWORD`) have no rule. A + name without a rule is injected raw and flagged `unprotected` — or withheld under + `containerEgressSecretsStrict` ("Withhold unprotected secrets"). The proxy resolves the CURRENT + value per request from the store that owns it (rotation is live; the relay cached 60 s), and only + while `canUse` passes: the placeholder's own channel (the organization's from any), live work in + that channel (a turn, a job, a review or an SSH session — `liveness.js`), and for a personal + secret its OWNER live there with NO other person's turn, job or SSH session live there (403 + `another-author-active` / `another-person-ssh-session`; a memory review is ownerless and pauses + nothing). Removing a secret revokes its placeholder (the config-change listener, every run's own + reconcile — keyed by the STORED names, so a secret that briefly resolves empty keeps its + placeholder — and the resolver itself); re-adding mints a new one. Limits: a Qwen provider key and + a daemon `CODEX_API_KEY` still reach a proxy-mode container raw (Codex's own sign-in is the shared + `auth.json`, a later phase), a self-hosted Qwen endpoint on a private address is refused by the + proxy, and an admin should never declare a multi-tenant suffix such as `*.vercel.app` as "Used + on hosts" (the editor and `set_secret` say so). The relayed Claude login becomes the channel's relay placeholder in + the `sk-ant-oat01-` shape (`containerClaudeCredential`), swapped on `api.anthropic.com` only. + The per-attempt credential note names each protected name with its hosts, the unprotected ones + and the withheld ones; `list_secrets` and the admin listings show "protected via egress proxy + (hosts)" / "unprotected (raw)"; the output redactor keeps every REAL value; `run_config` records + `networkEnforced`, `egress` (`proxy` / `proxy+raw` / `bridge` / `unavailable` / `host`) and the + unprotected/withheld names. → TEST-PLAN: Egress proxy, placeholders and `--network none`. +- **SSH and VS Code sessions on placeholders (container-secrets P3).** A developer's SSH session + resolves its environment through the same `resolveEgressRunEnv` a turn uses (this channel, the + developer as a trusted principal, the session's target), so behind the egress proxy the session + `env` file under `/ssh/users//` exports a `cgph_…` placeholder for every ruled + secret; an unruled one stays raw and flagged (withheld under strict mode). The status line and + `ssh_session_start` report `secrets` as `{ name, scope, protected }`. Claude's access-only + `.credentials.json` and the host-side `/vscode/claude-token` hold the channel's relay + placeholder (`containerClaudeCredential`; expiry, scopes, subscription and rate tier stay the + real login's), and the token file is now removed when the channel's LAST SSH session ends, not + only when the `npm run vscode` launcher exits. Because sshd starts a clean environment, the + complete `egressEnv(target)` map plus `AGENT_BROWSER_ARGS` and a gateway-owned + `GIT_SSH_COMMAND="ssh -o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p'"` ride the + session's ONE `SetEnv` line (from the target's own plan, over a stale create-time value; + `ALL_PROXY` dropped) and lead the session `env` file after an `unset ALL_PROXY all_proxy`, so + `. "$CG_SESSION_ENV"` re-asserts them. `cg-egress-connect` (image spec 1.6.0, unreleased, no + separate bump: a POSIX shim over `bin/cg-egress-connect.mjs`, staged verbatim from + `src/mcp/egress-connect.js`, node built-ins only) speaks `CONNECT host:port` to the in-container + forwarder and pipes stdio, so `git@github.com` works through the proxy's raw tunnel (github.com:22 + and the declared raw hosts, Allow network on); a refusal is one stderr line with the proxy's + reason and exit 1. The proxy cannot inject an SSH key. The session's `session.md` names the proxy, + the CA bundle, the protected/unprotected/withheld names, the personal-scope pause rule and the + ProxyCommand — never a value or a placeholder. Personal pause: `resolveEgressRunEnv` returns + `personalPaused` when the author holds personal placeholders and a DIFFERENT person's SSH session + is open in the channel (`liveness.otherSshOpen`, which reads the broker's `liveSshSessions()` — + the session is registered before its files are prepared), and the per-attempt credential note + then says those names are paused (the proxy answers `403 secret-refused … + another-person-ssh-session`). An operator's `npm run vscode` window — its own process, so no + liveness mark — keeps channel, organization and relay grants swapping through its signed editor + lease (`canUseGrant`), never a personal one. `show_channel_ssh` and the `gateway-usage` + administration page say so. → TEST-PLAN: SSH and VS Code sessions on placeholders + (container-secrets P3). +- **The Codex login is relayed, not mounted (container-secrets P4).** Behind the egress proxy a + channel container no longer bind-mounts the operator's real `~/.codex/auth.json` (refresh token + included, one file shared by every channel). Before every Codex spawn — turn, failover, background + agent, schedule, update smoke — and when an SSH session is prepared, the runner writes an + ACCESS-ONLY `/home/agent/.codex/auth.json` into the channel's own HOME volume through the backend's + new optional `writeHomeFile` (staged 0600 through the artifact dir, `mv -f` into place, refused + while the destination is still a mount): `auth_mode: "chatgpt"`, the access token = the channel's + relay placeholder in JWT SHAPE (`placeholders.js`: the real token's header and claims — plan, + account, expiry, which the CLI reads locally — with `cgph_r…` as the signature segment, because + codex-cli rejects a non-JWT), the id_token's claims with a non-signature, the account id, + `refresh_token: ""` and `last_refresh` = now. The proxy's new `jwt` format replaces the WHOLE token + with the live access token in the Authorization header on `api.openai.com`, `chatgpt.com` and + `auth.openai.com` only (grant `relay`/`CODEX_ACCESS_TOKEN`, 60 s cache, channel-live liveness like + Claude's); a jwt grant never swaps a bare placeholder and a bearer grant never a JWT-shaped one. + The daemon owns the refresh (`src/gateway/codex-token-relay.js`, the twin of + `claude-token-relay.js`): when the access token has less than 48 h left it runs one cheap + `codex exec --ephemeral --ignore-user-config -m gpt-5.6-luna` turn in the login's own `CODEX_HOME` + under a keyed lock, and backs off 6 h when the CLI declines to renew a still-valid token. The + container's Codex mode is `relay` (`credentialError` refuses a run with no relayable ChatGPT + sign-in, naming `codex login`); the mount leaves the fingerprint, so each container is recreated + once. `cg-init` removes, under the proxy, a leftover Codex login carrying a refresh token (never a + mounted file). Still the shared file (the documented remaining exposure): the LEGACY bridge egress + mode and an API-key `auth.json`; a daemon `OPENAI_API_KEY`/`CODEX_API_KEY` still rides the + container env raw. Spike: codex-cli 0.156.1 runs a turn on an access-only file, does not refresh on + `last_refresh` age, and a refresh attempt with the empty refresh token is rejected (400 + `empty_string`). → TEST-PLAN: Codex login relay, strict secrets, static check (container-secrets P4). +- **Unruled secrets are withheld by default on new installs (container-secrets P4).** + `containerEgressSecretsStrict` reads an absent value as ON, and the daemon stores it once at boot: + `true` for a brand-new install, `false` for an install whose operator had configured it before + this release (read before the first-boot password is saved), so an upgrade never silently + withholds a secret a working channel uses; a stored choice is never changed. `list_secrets` ends + with a **Finding** line naming every listed secret that has no egress rule and what happens to it + (withheld under strict, raw otherwise, raw today under the legacy bridge) and how to declare its + hosts; the settings UI says the same. → TEST-PLAN: Codex login relay, strict secrets, static check + (container-secrets P4). +- **A static check keeps secrets out of the artifact dir (container-secrets P4).** + `npm run check:static` runs `scripts/static-secret-writes.mjs` over `src/`: a `writeFile*` / + `appendFile*` / `writePrivate` / `writeSecretFile` call whose path mentions `artifactDir`, + `runArtifactRoot`, `sshUserDir`, `sshUsersDir`, `channelArtifactDir` or a name derived from one + (to a fixpoint, per file) fails the check when its content mentions `composioUserToken`, + `composioToken`, `toolboxToken`, `makeToolboxKey`, `relay.token`, `resolvedRunEnv` or + `realValues`; a reviewed exception says `static-check: allow-secret-artifact-write ` (today + only the VS Code token file, which holds the relay placeholder behind the proxy). The retired + `src/mcp/remote-secret-bridge.js` and its `mcp-remote` runtime helper are gone: since the P1 + relay nothing bridged a remote MCP through them. → TEST-PLAN: Codex login relay, strict secrets, + static check (container-secrets P4). - **Liveness crossed a pid namespace, so the watchdog learned a third answer.** A container child's pid names the host-side `exec` CLIENT, never the engine, so liveness and signals are asked of the backend: a probe execs `cg-probe ` against the process-group leader `cg-exec` recorded diff --git a/INSTALL.md b/INSTALL.md index 8889a4fd..a06719fe 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -20,8 +20,9 @@ see [README.md](./README.md). claude login # or set ANTHROPIC_API_KEY claude --version # must work ``` -- **(Optional) OpenAI Codex CLI** — only if you'll use the Codex engine. Its host sign-in file is - bind-mounted into the channel containers (sessions stay per channel; the sign-in is shared): +- **(Optional) OpenAI Codex CLI** — only if you'll use the Codex engine. Its host sign-in is + relayed into the channel containers through the egress proxy — each container gets an access-only + stand-in, never the host's login file (sessions stay per channel): ```bash npm install -g @openai/codex codex login # or set OPENAI_API_KEY diff --git a/README.md b/README.md index 6768fdb3..f32c3e8f 100644 --- a/README.md +++ b/README.md @@ -284,14 +284,19 @@ background and scheduled engine run uses the same runtime boundary. work/runtime mounts. Other channel workspaces and the operator's home are excluded by default. An explicit, off-by-default Full-access home-sharing option exposes the operator's whole home to admitted authors in those channels; choose shared work folders and this option deliberately. -2. **Network access is not an egress firewall.** Containers use bridge networking. The - *Allow network* switch communicates policy to the engines; there is no domain filtering or - container-level egress cut-off in this release. -3. **Usable credentials have runtime exposure.** Claude's host credentials file is never copied - or mounted; runs receive a relay of its access token or a configured credential. Codex's host - sign-in file is shared with its containers while sessions stay per channel. Protected transient - MCP artifacts can contain credentials. Channel environment secrets have no reveal endpoint, - but an agent using them can access their runtime values; masking and redaction are not a vault. +2. **Egress goes through a per-channel proxy.** Containers run with no network of their own; the + daemon's egress proxy is their only way out. It enforces the *Allow network* switch on every + request (off: engine endpoints and selected connectors only), always refuses private, loopback + and cloud-metadata addresses, and has no per-domain allow-list when the switch is on. The + legacy open-bridge mode and a channel's raw-socket escape make the switch advisory again. +3. **Usable credentials have runtime exposure, reduced by placeholders.** Claude's host credentials + file is never copied or mounted; containers receive a placeholder for the relayed access token, + swapped in by the egress proxy on the Anthropic API only. Secrets with an egress rule (built in + for GitHub, Vercel, Supabase, Make and Composio tokens, or declared "used on hosts") are + placeholders too; any other secret is injected raw and flagged unprotected. Codex's host sign-in + file is still shared with its containers while sessions stay per channel. Channel environment + secrets have no reveal endpoint, but an agent can use whatever it is given; masking and + redaction are not a vault. 4. **Authorization is checked before a run.** Channel use/manage policies and guest grants are enforced; unknown users without a guest grant are denied, and DMs require approval or admin status. Admin bypass needs an admin author and Admin mode. API callers cannot claim personal connector identities or widen a channel's durable tool permissions. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 5bd52733..9f86e76d 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,622 @@ # ChannelGate — Test Plan +## Codex login relay, strict secrets, static check (container-secrets P4) + +Fixtures: the scratch runtime root of `test/helpers.js` (real `egress_grants` rows, a real +`settings.json`), synthetic ChatGPT `auth.json` files built at runtime (JWTs whose header, claims and +`real-signature-*` segment are generated in the test; refresh token `rt-real-refresh-*`), the fake +container CLI recorder (`test/container-fake-cli.js`), the fake runtime backends +(`test/runtime-fake.js`, `test/fixtures/fake-runtime-backend.js`) with an injected `writeHomeFile`, +a fake egress provider for an ACTIVE plan, and a fake `spawn` for the refresh turn. No podman on the +development host: the live gates at the end are UNEXECUTED. + +### Spike (required before the design; executed 2026-09-26, this host, codex-cli 0.156.1, network on) + +Safety: scratch `CODEX_HOME`s `/tmp/cg-spike/codex-{a,b,c}` (umask 077) seeded with ONLY the +operator's current access token, id_token and account id copied from the read-only +`~/.codex/auth.json`, `refresh_token: ""`; all deleted afterwards; the operator's file was never +written (mtime still 2026-09-25 07:03:42). No token value was printed. + +- Operator file facts: access-token claims `aud, client_id, https://api.openai.com/auth, + https://api.openai.com/mfa, https://api.openai.com/profile, iss, pwd_auth_time, scp, session_id, + sl, sub, iat, exp, jti, nbf`; `iat` 2026-09-25T04:03:42Z, `exp` 2026-10-05T04:03:42Z (240 h); + the id_token lives 1 h (already expired — the CLI does not care); `last_refresh` = `iat`. +- (a) PASS — access-only file, `last_refresh` = now: `codex exec --skip-git-repo-check --ephemeral + --ignore-user-config -m gpt-5.6-luna -c model_reasoning_effort="low" -s read-only "Reply with + exactly OK" `; a non-JWT or a non-object header is refused. A `jwt` grant replaces the WHOLE token on + `chatgpt.com`, `api.openai.com` and `auth.openai.com` (the scrub map maps the live token back + to the whole placeholder), leaves it unchanged on another host (`host`) or another header; a + bare placeholder is refused by the jwt grant (`format`) and the JWT one by a bearer grant — + never a partial swap. `CODEX_RELAY_RULE` is jwt-only, Authorization-only, equal to + `ENGINE_HOSTS.codex`; `relayRuleFor` knows only the two relays. +- [x] The daemon's login (`test/codex-token-relay.test.js`): `resolveCodexLogin` → `chatgpt` / + `api-key` / `none` over the candidate list; `readDaemonCodexAccessToken` returns expiry, + account and plan and never the refresh token; a token inside 48 h triggers ONE refresh for two + concurrent callers and both get the renewed token; a fresh token none; a refresh the CLI + declines is not retried for 6 h (then retried); an expired, unrenewed token → no token and a + `codex login` remedy. The refresh turn is `codex exec --ephemeral --ignore-user-config + --skip-git-repo-check -s read-only -m gpt-5.6-luna`, `CODEX_HOME` = the login's own dir, stdin + closed, cwd not the login dir. The container file has the placeholder token, the id_token's + claims with `CODEX_ID_TOKEN_SIGNATURE`, `refresh_token: ""`, `last_refresh` = now. +- [x] The grant (`test/codex-token-relay.test.js`): `containerCodexCredential` binds a per-channel + placeholder (`codexRelayPlaceholderFor`), different per channel, whose grant resolves to the + LIVE token with format `jwt` on the Codex hosts; the container file carries no real signature + and an empty refresh token; no egress → an error, no relay → the error, never a real token. +- [x] Modes and mounts (`test/container-credentials.test.js`): egress active + ChatGPT login → + `relay`, `codexAuthFile: ""`; bridge → `shared-file` with the mount; an API-key login → + `shared-file` even behind the proxy; `credentialError` in relay mode needs a relayable login + NOW (signed out → the `codex login` remedy); the notes say relay, not shared; `ensureUp` + under an active plan (fake provider) settles `relay`, mounts no `codex-auth` and the create + argv names no `auth.json`. `writeHomeFile` refuses a path outside HOME (and `..`), refuses a + mounted destination (`/proc/self/mountinfo` guard), stages 0600, `cp`s to `.cg-tmp`, + `mv -f`s into place (never `cp` onto the destination) and removes the staging copy. +- [x] The runner (`test/codex-auth.test.js`): in relay mode the file is written through + `writeHomeFile` to `/home/agent/.codex/auth.json` BEFORE the spawn; a missing relay fails the + turn pre-spawn as a replay-safe `authentication` failure (no spawn). + `installRelayedCodexLogin` refuses while a `codex-auth` mount is still on the target and + without `writeHomeFile` (`test/codex-token-relay.test.js`). +- [x] SSH (`test/ssh-session.test.js`): a relay-mode session places the login first (Codex ready); + a failure to place it makes `codex.ready` false with the reason while Claude is unaffected; + shared-file mode writes nothing. +- [x] `cg-init` (`test/container-image.test.js`): the Codex block, re-pointed at a scratch file, + leaves a refresh-token file alone outside proxy mode, removes it under `CG_EGRESS=proxy`, and + keeps the daemon's access-only file. +- [x] Strict default (`test/egress-run.test.js`, `test/secrets-tool.test.js`): a pinned-off install + keeps `RAW_THING` raw and flagged (Claude and Codex); strict withholds it — absent from the + env, named in the "Withheld by the gateway's strict egress setting" line — while a ruled + secret stays its placeholder; `pinEgressSecretsStrictDefault` stores `false` for a configured + install, `true` for a new one and never touches a stored value. `list_secrets` ends with a + **Finding** line naming the unruled secrets (not the ruled `GITHUB_TOKEN`) as WITHHELD under + strict / injected RAW when off, and none when every secret is ruled. +- [x] Static rule (`test/static-secret-writes.test.js`, `npm run check:static`): a fixture writing + `composioUserToken` through two derived names, `relay.token` under `sshUserDir(...)` and + `resolvedRunEnv` via `writePrivate` → three findings with file:line; another path, a + non-secret content and a marked reviewed exception → none; the repository's `src/` → none. +- [x] Retired helper (`test/container-credentials.test.js`): `helperCommand(t, "mcp-remote")` is an + unknown helper; every remaining helper resolves inside the image. + +### Live gates — all UNEXECUTED + +Common setup: this branch deployed, `npm run build:image` (spec 1.6.0; `cg-init` changed), the host +signed in with `codex login` (ChatGPT), Settings → Container runtime with *Legacy open network* OFF, +a Slack test channel `p4-codex` on engine Codex with *Allow network* OFF, and a second channel +`p4-other`. Note before starting: `stat -c '%y' ~/.codex/auth.json` and +`jq -r '.tokens.access_token|split(".")[1]|@base64d|fromjson|.exp' ~/.codex/auth.json`. + +- [ ] UNEXECUTED (Codex) — no real login in the container. Send "reply with the word pong" in + `p4-codex`. Evidence: the reply `pong`; `podman inspect --format '{{range + .Mounts}}{{.Destination}} {{end}}'` lists no `/home/agent/.codex/auth.json`; `podman exec + sh -c "jq '{r: .tokens.refresh_token, s: (.tokens.access_token|split(\".\")[2][0:6])}' + /home/agent/.codex/auth.json"` → `{"r": "", "s": "cgph_r"}`; `/api/health` → + `containerRuntime.egress` counters show swaps for the channel; the host file's mtime and `exp` + unchanged by the turn. Pass: all four. +- [ ] UNEXECUTED (Codex) — network OFF still answers. Same channel, *Allow network* OFF (the + default): a two-turn conversation ("remember 7", then "what number?"). Evidence: both answer; + `egress` events may show `ab.chatgpt.com` / `*.oaiusercontent.com` blocked + (`network-off`) — record which. Pass: both turns answer; if either fails, the blocked host is + the finding (add it to `ENGINE_HOSTS.codex`). +- [ ] UNEXECUTED (Codex) — an existing container is recreated once. On a channel created BEFORE + this deploy: note `podman inspect -f '{{.Id}}' `, run a Codex turn. Evidence: the + container ID changed exactly once (a second turn keeps it), and the file inside is the + access-only one. Pass: both. +- [ ] UNEXECUTED (Codex) — the placeholder is worthless elsewhere. From `p4-other`'s container: + `podman exec sh -c 'curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: + Bearer " https://chatgpt.com/backend-api/codex/models'` + → 403 (`secret-refused`, reason `other-channel`, audited); the same `curl` from the HOST (no + proxy) → 401. Pass: neither succeeds. +- [ ] UNEXECUTED (Codex) — the refresh. When the host token is inside 48 h of `exp` (or on a scratch + install signed in 8+ days earlier), send a Codex turn. Evidence: the daemon log shows the + refresh turn (or its `[codex-relay] host refresh turn exited …` warning), and either the host + file's `exp` moved forward, or it did not and no second refresh turn runs within 6 h while + turns keep answering; after the real `exp` passes, the next turn refreshes (host `exp` moves) + and answers. Pass: turns never fail because the relay went stale. +- [ ] UNEXECUTED (Codex) — SSH before the first turn. On a new relay channel with SSH granted: + `ssh `, then `codex exec "reply pong" + sh -c 'printf "{\"tokens\":{\"refresh_token\":\"x\"}}" > /home/agent/.codex/auth.json'`, then + `podman restart `: the file is gone. Pass: gone. +- [ ] UNEXECUTED (Claude AND Codex) — strict default. On a FRESH install (empty runtime root): the + Settings page shows *Withhold unprotected secrets* checked; add channel secret `RAW_THING` + (no hosts) and ask each engine "is RAW_THING set? answer yes or no, never print it" → "no"; + `list_secrets` ends with `**Finding:** 1 secret has no egress rule — \`RAW_THING\`: WITHHELD + …`. On an UPGRADED install (a pre-P4 runtime root): the box is unchecked after the first boot, + `RAW_THING` is set in the run, and the finding says RAW. Pass: all. + +## SSH and VS Code sessions on placeholders (container-secrets P3) + +Fixtures: the scratch runtime root of `test/helpers.js` (real `egress_grants` rows, real org / +channel / personal secret stores), the fake runtime backend (`test/fixtures/fake-runtime-backend.js`) +with an ACTIVE egress plan on the target (`socketDir`, `caBundle`, `caSpki: "c3BraQ=="`), a fake +container CLI recorder for every `exec`, the real `installVscodeClaudeRelay` with an injected relay +(`sk-ant-oat01-test-access-token`, plan facts `max` / `default_claude_max_20x`), the egress service's +real sockets and TLS (`test/egress-service.test.js`), the real SSH broker on a short `/tmp/cgssh-*` +attach dir (`test/ssh-broker.test.js`) and a fake forwarder/proxy on `127.0.0.1:0`. Token fixtures +are synthetic (`*_real_value_ssh_p3_*`, `b-personal-real-value-*`). No podman on the development +host: the live gates at the end are UNEXECUTED. + +- [x] Session on placeholders (`test/ssh-session.test.js`): with an active plan the session `env` + holds `cgph_c…` / `cgph_o…` / `cgph_p…` for the channel, organization and personal secrets + (the personal grant owned by THIS developer in THIS channel), the unruled `RAW_THING` raw; + `result.secrets` is `{ name, scope, protected }` and `result.egress` lists the unprotected + name; sourcing the file under a hostile `HTTPS_PROXY` / `ALL_PROXY` yields the proxy URL, no + `ALL_PROXY`, the CA bundle, `CG_EGRESS=proxy`, the Chromium args and the exact + `GIT_SSH_COMMAND`, with the proxy block before the secrets; the `.credentials.json` written + into the container holds `sk-ant-oat01-cgph_r…` (a relay grant) with the REAL expiry, + scopes, subscription and tier; `/vscode/claude-token` holds the same placeholder; + NO file under `ssh/users//` (Codex's `codex-args.sh` and `codex-secrets.json` included), + the token file or the login input carries a real protected value or the real relay token, and + the raw value appears only in `env`; `session.md` names the proxy, the CA path, placeholders, + `printenv`, `another-person-ssh-session`, the ProxyCommand, "cannot add an SSH key" and the + unprotected name — never a value or a placeholder; release removes the developer's dir and, + with the channel's last session only, the token file (also when the container is gone). + Strict mode: the unruled name is withheld, every injected name protected, and none of the + resolver's `realValues` is anywhere. Another developer attached (liveness seam): the + session's `personalPaused` is true and the note says PAUSED; the developer's own session + alone does not pause. Without a plan: the resolver is called with this channel, this + developer, `untrustedPrincipal: false` and the target; secrets are listed unprotected and the + note has no proxy paragraph; `renderSessionEnvFile` keeps its exact legacy output and, with + an egress map, leads with the comment, `unset ALL_PROXY all_proxy` and the proxy exports, + which win over a same-named entry. +- [x] sshd `SetEnv` (`test/ssh-access.test.js`): an active plan puts every `egressEnv(target)` name + (over a stale create-time `HTTPS_PROXY`), `AGENT_BROWSER_ARGS` and `GIT_SSH_COMMAND` into the + ONE `SetEnv` line and drops `ALL_PROXY`/`all_proxy`; `sessionEgressEnv` is exactly that map; + an inactive plan adds nothing and leaves the container's env as it was. +- [x] Liveness reads the broker (`test/ssh-broker.test.js`): during a real brokered session the + developer is live (already inside `prepareSession`), the channel is live work, + `otherSshOpen(channel, someone else)` is true and false for the developer and for another + channel; the hang-up ends all of it. +- [x] Personal pause end to end (`test/egress-service.test.js`): developer A attached, author B's + turn live with B's personal placeholder → `resolveEgressRunEnv` says `personalPaused`, the + credential preamble names `B_PERSONAL_KEY` as PAUSED, the proxy answers `403 + secret-refused` naming `another-person-ssh-session` without reaching the upstream (audited + as refused), the channel's own placeholder still swaps; once A leaves the same placeholder + swaps to the real value on the next request. An operator's editor lease on the channel's + container wakes relay and channel grants (`canUseGrant`), not a personal one, not another + container's, and ends with the lease. +- [x] Resolver and preamble (`test/egress-grants.test.js`, `test/channel-credentials.test.js`): + `personalPaused` only for an author WITH personal placeholders while another person's + session is open (the owner's own session does not pause; the default reads the real liveness + module); `clean` and inactive answers carry `personalPaused: false`; the pause line names + only the personal placeholders and appears only when paused. +- [x] `cg-egress-connect` (`test/egress-connect.test.js`, `test/container-image.test.js`): exact + `CONNECT github.com:22 HTTP/1.1` + `Host`; bytes that ride with the 200 reach stdout; stdin → + tunnel → stdout; the client's EOF half-closes and the server's last bytes still arrive; a + tunnel closed by the far side ends the helper while stdin stays open; a 403 is one stderr line + `… refused example.com:22 (403 Forbidden) — network-off: …`, exit 1, empty stdout; an + unreachable forwarder exit 1 naming `ECONNREFUSED`; a missing port exit 2 with usage; IPv6 + authorities are bracketed and header-injection / option-shaped hosts refused; node built-ins + only; `build-image.mjs` stages it as `bin/cg-egress-connect.mjs`; the shim is executable, + POSIX sh and `exec`s it. + +### Live gates (Claude AND Codex unless marked) — all UNEXECUTED + +Common setup: this branch deployed, `npm run build:image` (spec 1.6.0), SSH access installed +(`scripts/install-ssh-access.sh`), Settings → Container runtime with *Legacy open network* OFF, a +Slack test channel with *Allow network* ON, channel secrets `GITHUB_TOKEN` (fine-grained PAT with +read access to one private repo) and `VERCEL_TOKEN`, a personal secret of developer A with *Used on +hosts* = `api.github.com` (`A_GH_TOKEN`, a second PAT), SSH granted to developers A and B, and a +throwaway GitHub deploy key (read-only on the private repo) whose private half A copies into the +session's `~/.ssh/cg-deploy`. Record `podman inspect --format '{{.HostConfig.NetworkMode}}'` +(`none`). + +- [ ] UNEXECUTED (engine-independent) — placeholders only. A: `ssh ` then + `printenv | grep -E '^(GITHUB_TOKEN|VERCEL_TOKEN|A_GH_TOKEN)='` and `printenv | grep cgph_`. + Evidence: each of the three values starts `cgph_` (`c`, `c`, `p`), `grep cgph_` shows only + placeholders, `printenv HTTPS_PROXY` is `http://127.0.0.1:3128`, `printenv ALL_PROXY` is + empty, `grep -r "" ///ssh ///vscode` + on the HOST finds nothing, `jq -r .claudeAiOauth.accessToken /home/agent/.claude/.credentials.json` + starts `sk-ant-oat01-cgph_r`. Pass: no real value anywhere in the session or its files. +- [ ] UNEXECUTED — `vercel whoami` and `gh api user` with placeholders. In A's session run both, + then start `claude` and `codex` in the channel folder and ask each "run gh api user and tell + me the login". Evidence: `vercel whoami` prints the token's user; `gh api user` returns the + PAT's login; both engines answer with it; the `egress` events show + `swapped: [{secretName: "GITHUB_TOKEN"}]` / `VERCEL_TOKEN` rows for this channel. Pass: all + succeed and no real token appears in the terminal or any reply. +- [ ] UNEXECUTED (engine-independent) — `git clone git@github.com:…` through the helper. In A's + session: `GIT_SSH_COMMAND="$GIT_SSH_COMMAND -i ~/.ssh/cg-deploy" git clone + git@github.com:/.git /tmp/p3-clone` (accept GitHub's host key when asked). + Evidence: the clone completes; an `egress` event with `tunnel: true`, host `github.com`, + port 22. Then switch *Allow network* OFF and repeat into another folder: it fails with + `cg-egress-connect: the gateway's egress proxy refused github.com:22 (403 Forbidden) — + network-off: …`. Pass: both observations; delete the deploy key at GitHub afterwards. +- [ ] UNEXECUTED (engine-independent) — the personal pause. Network ON. With only A attached, in + A's session `curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $A_GH_TOKEN" + https://api.github.com/user` → `200`. Now B attaches (`ssh ` from B's laptop) and A + repeats → `403`, body names `A_GH_TOKEN` and `another-person-ssh-session`; the same call with + `$GITHUB_TOKEN` still `200`. Meanwhile A sends a Slack message in the channel "print the + credential note's paused line": the reply quotes "Personal secrets are PAUSED". B + disconnects; A's call → `200` again. Pass: all four observations. +- [ ] UNEXECUTED (engine-independent) — forwards under `--network none`. From a laptop: + `ssh -L 5433::5432 ` then `psql -h localhost -p 5433` + → the forward fails (the session's sshd logs a connect failure); `python3 -m http.server + 3000` in the session plus `ssh -L 3000:localhost:3000 ` → `curl localhost:3000` + on the laptop works; `ssh -R 9000:localhost:22 ` then `nc -z localhost 9000` in the + session → open. Pass: external `-L` fails, container-local `-L` and `-R` work. +- [ ] UNEXECUTED (engine-independent) — VS Code Remote-SSH, an unlisted client version. Use a VS + Code client whose commit is NOT in `containers/versions.json` → `vscodeServers`, connect with + Remote-SSH (network ON): the server installs (its `curl` through the proxy — `egress` shows + `update.code.visualstudio.com`), the integrated terminal's `printenv HTTPS_PROXY` is the + proxy and `claude` there opens signed in as the plan's account. Network OFF, a fresh HOME + volume, client setting `"remote.SSH.localServerDownload": "always"` → the install still + succeeds. After closing every session: `ls ///vscode/` on the host + is empty. Pass: all three observations. +- [ ] UNEXECUTED (engine-independent) — the operator's `npm run vscode`. With no SSH session and + no turn: `npm run vscode -- `, in its terminal run `claude -p "reply pong"` → it + answers (the editor lease keeps the relay placeholder live); `cat + ///vscode/claude-token` on the host starts `sk-ant-oat01-cgph_r`; + close the window → the file is gone. Pass: all three. + +## Egress proxy, placeholders and `--network none` (container-secrets P2) + +Fixtures: real sockets and real TLS on 127.0.0.1 — an https upstream whose leaf chains to a +throwaway "public root" the proxy is told to trust (`upstreamCa`), fake hostnames (`upstream.test`, +`api.anthropic.com`, …) resolved by an injected lookup, and the TEST-ONLY `allowLoopbackHosts` that +lets exactly those names reach loopback (production refuses loopback outright); unix listeners in +SHORT `/tmp/cgeg-*` / `/tmp/cgfw-*` dirs; the scratch runtime root of `test/helpers.js` for the +database; the fake container CLI (`test/container-fake-cli.js`) and the fake runtime backends +(`test/runtime-fake.js` with an `egress` plan, `test/fixtures/fake-runtime-backend.js`); a fake +egress provider registered through `src/runtimes/container/egress-hook.js` where the container +side is tested alone. Token fixtures are synthetic (`ghp_*_value_*`, `real-*-value-*`); no podman +exists on the development host, so every container-side fact below is pinned over the fake CLI and +the live gates at the end are UNEXECUTED. + +### The proxy core (engine-independent: transport code, no engine in the loop) + +- [x] CA and leaves (`test/egress-ca.test.js`): a CA and its per-host ECDSA leaves parse, chain and + match their host; a client trusting only the CA completes a verified TLS handshake; `openssl` + verifies the chain and decodes the extensions (skipped where openssl is absent); the store + writes `ca.key` 0600, `ca.pem` 0644 in a 0700 dir once and reuses them; `leafFor` mints per + host, caches in an LRU, re-mints an hour before expiry and refuses invalid names. +- [x] Destination policy (`test/egress-policy.test.js`): the metadata address is refused by name + and as a literal; private, loopback, ULA, mixed public+private answers and IPv4-mapped forms + are refused; a public destination is allowed with its address pinned; mode off admits only + engine hosts; an allowlist narrows mode on; a raw tunnel only for its exact host:port and + only when on; invalid names/ports, DNS failures. +- [x] Swap rules (`test/egress-rules.test.js`): placeholder format, scope letters, the + `sk-ant-oat01-` shape and detection; exact and one-level wildcard hosts that never match the + apex; bearer, raw, basic-password (decode → swap → re-encode); another host, header or + position leaves the placeholder unchanged and reports why; a `canUse` refusal leaves the header + alone; malformed Basic never throws; never a partial swap, never a header-breaking value, + never across a Host-header mismatch; plain http only for an opted-in grant; query parameters + only when the grant lists them. +- [x] Response scrub (`test/egress-scrub.test.js`): a value split across chunks is still replaced; + only a possible prefix is held back; longest value wins; multibyte survives; short values and + binary/unknown types pass through. +- [x] End to end (`test/egress-proxy.test.js`): the upstream sees the REAL value while the client + sent the placeholder; a shaped relay token swaps whole and a streamed body goes through; a + non-declared host receives the placeholder unchanged; an echoed value comes back scrubbed + (compressed bodies untouched); a `canUse` refusal is 403 naming the secret and never reaches + the upstream; refused destinations are 403 with a category; an unreachable upstream is 502; + plain http forwarding; a raw tunnel; a WebSocket upgrade swapped then piped; a 431 for an + oversized head; audit events never carry a header value, placeholder or real value; `close()` + leaves nothing open. + +### The integration + +- [x] Grants (`test/egress-grants.test.js`): the catalog rules GitHub/Vercel/Supabase/Make/Composio + names and leaves `GH_REPO`, `GITHUB_REPOSITORY`, `VERCEL_ORG_ID`, `SUPABASE_DB_PASSWORD`, + OpenAI/Anthropic names unruled; an entry's own `hosts` win, its `headers`/`format` refine; bad + declarations (an IP, a two-level wildcard, 17 hosts, `host` as a header, 9 headers, an unknown + format) are refused; a stored rule survives a value rotation and is cleared only by an explicit + empty list. One stable placeholder per key; another channel → another placeholder; the + organization's shared by every channel; personal per channel AND author; the table has no value + column (migration 30 and both indexes present). Revoke kills a placeholder; re-adding mints a + new one. `resolveEgressRunEnv`: inactive → exactly the real resolve, clean → empty, no target → + real values; active → ruled names are `cgph_[ocp]…` placeholders of the right scope, the unruled + `SUPABASE_DB_PASSWORD` is raw and listed `unprotected`, `realValues` holds all five real values, + placeholders are stable across runs, another author shares the org/channel ones but not + Alice's, the HTTP run API (untrusted) gets no personal one; strict mode withholds the unruled + name (still redacted). The resolver returns the CURRENT value (rotation), and a removed secret + is revoked by the resolver itself and by the next run's reconcile. The Claude relay: a shaped + `sk-ant-oat01-cgph_r…` per channel, keeping source/expiry, the real relay when inactive or + API-key-only; the relay grant resolves the live token with the 60 s cache. +- [x] Service (`test/egress-service.test.js`): boot writes the trust bundle (system roots first, then + the CA; 0644) and rewrites it IN PLACE (same inode) when the system roots change; container + targets carry an active plan with the CA's SPKI hash; one listener per channel under + `/<12 hex>/egress.sock` (≤ 107 bytes, dir 0700, socket 0600), idempotent, whose ctx is + that channel. Through the socket with real TLS against the written bundle: no live work → 403 + `secret-refused` naming `MY_API_KEY`/`channel-idle`, one `egress` event with no value and no + placeholder in it; a live turn → the upstream sees the real value and one `swapped` event; a + request with no placeholder → counted, no event; the same placeholder from ANOTHER channel's + socket → `other-channel`. The policy reads CURRENT meta: switch off → CONNECT refused + `network-off` and audited `blocked`; the engine hosts and a noted remote-MCP host stay in the + off policy; `github.com:22` is always a raw tunnel; switched on → the very next request passes. + `canUseGrant`: channel/org need live work in that channel; personal needs its owner live, and + is refused while another person's SSH session is open (the owner's own session is fine); an + SSH session alone is live work. The config-change listener revokes a removed secret's + placeholder. Service down: `egressError` names the remedy, the container backend's + `credentialError` fails the run, `ensureChannelEgress` refuses, the legacy bridge mode never + asks, and a fresh target keeps `--network none`. A CA that cannot load: one warning line, the + boot continues, runs fail closed. +- [x] Container side (`test/container-cli.test.js`, `test/container-lifecycle.test.js`, + `test/container-credentials.test.js`): `--network none` for network on AND off; `bridge` only + under `egressMode: "bridge"` or `meta.rawNetwork`; with the provider registered the mounts + include `egress` (bind, ro, the channel's socket dir — never under the shared control-socket + dir) and `egress-ca` (bind-file, ro) and both fingerprints move with them; the create argv + carries both `-v …:ro` and the proxy env; `ensureUp` binds the listener before creating, never + creates the CA file source as a directory, and fails closed (nothing created) when the + listener cannot bind or the service is down; the network switch does not move the + fingerprint, `rawNetwork` does; `prepareTarget` stays pure with an inactive plan when no + service is registered. +- [x] Environment (`test/container-egress-env.test.js`, `test/browser-env.test.js`): `egressEnv` is + `{}` unless active, then `HTTP(S)_PROXY`+lowercase = `http://127.0.0.1:3128`, `NO_PROXY` + loopback only, `NODE_USE_ENV_PROXY=1`, the ten CA variables = `/run/channelgate/egress-ca.pem`, + `CG_EGRESS=proxy`, no `ALL_PROXY`; every one of those names (and `SSL_CERT_DIR`) is reserved; + the create argv carries them; the exec env-file FORCES them over a host `HTTPS_PROXY` and drops + `ALL_PROXY` (legacy: the host value passes); `buildClaudeEnv` / `buildCodexEnv` apply them in + the last group over the daemon's own proxy and a channel secret named `HTTPS_PROXY`; Chromium + gets `--proxy-server` + the SPKI pin in `AGENT_BROWSER_ARGS` only with an active plan. +- [x] Forwarder (`test/egress-forwarder.test.js`): TCP → unix → TCP round trip; a failed unix + connect RESETS the client (`ECONNRESET`), is logged once per category per minute, and the same + forwarder serves again once the socket is back; run as the image runs it (`node `, + `CG_EGRESS_PORT`, `CG_EGRESS_SOCKET`); node built-ins only; `build-image.mjs` stages it as + `bin/cg-egress.mjs` and `cg-init` starts it in the background under `CG_EGRESS=proxy` before + `exec`. +- [x] A turn (`test/egress-run.test.js`, Claude AND Codex through the production orchestrator on + the fake runtime with an active plan): the engine's env holds a `cgph_c…` placeholder for + `GITHUB_TOKEN` (a live grant row) and the raw value for an unruled name, never the real GitHub + token; Claude's `CLAUDE_CODE_OAUTH_TOKEN` is the `sk-ant-oat01-cgph_r…` relay placeholder, never + the setup token; the prompt names the protected name with its hosts and the unprotected one; + the turn is live while it spawns and released after; `run_config` records + `networkEnforced: true`, `egress: "proxy"`, `egressUnprotected: ["RAW_THING"]`. +- [x] Surfaces (`test/network-policy.test.js`, `test/modes.test.js`, + `test/channel-credentials.test.js`, `test/channel-env.test.js`, + `test/channel-policy-audit.test.js`): `NETWORK_POLICY_ENFORCED` is `true`, the header names the + advisory exceptions and the retired "no network at all" claim stays gone; + `networkEnforcedFor` is true only for an active plan on `--network none`; the detailed label + drops the caveat with the proxy running and keeps it for `rawNetwork` and the legacy mode; the + credential preamble's protected / unprotected / withheld lines (names and hosts only); the + masked listing carries `protected` + `hosts`; an admin save that does not send `rawNetwork` / + `egressRawHosts` records no change to them. +- [x] Codex Composio SDK mode in a container (`test/codex-args.test.js`, + `test/engine-runtime-isolated.test.js`): each SDK session is `secret-env-bridge + gatewayCapability CG_GATEWAY_CAPABILITY cg-mcp-bridge ` with + `env.CG_MCP_SERVICE="composio-sdk"`, no `url`, no host root, no keyless SDK bridge script, and + no entry at all without a bundle; the sudo-host shape is unchanged. + +### Review fixes (2026-09-26) + +- [x] Pinning, scrub, deadlines, trust (`test/egress-proxy-hardening.test.js`, engine-independent): + an absolute-form `GET https://tenant.vercel.app/…` inside the tunnel is 400 + `absolute-form-in-tunnel` and never reaches the upstream; no Host header → the placeholder + arrives unswapped and the upstream Host is `upstream.test:`; `Host: front.test` over a + CONNECT to `upstream.test` (and over plain http against the absolute URL's host) is 403 + `host-mismatch`, audited as blocked; a wrong-channel placeholder's 403 names no secret; a + swapped request reaches the upstream with `accept-encoding: identity` and the real value is + scrubbed out of `Location` and `WWW-Authenticate`; a body with no Content-Type is scrubbed, + `application/octet-stream` is not; a 401 answer to an Upgrade is scrubbed in header and body; + a lookup that never answers is 504 `dns-timeout`, a second concurrent lookup over the per-channel + cap is 503 `too-many-lookups` and the slot frees afterwards; an upstream that never answers is + 504 `upstream-timeout`; an upstream is trusted through a root in the host bundle and refused + (502 `upstream-tls`) without it. `test/egress-rules.test.js`: a request with no Host swaps + nothing (`host-header-mismatch`). +- [x] Integration (`test/egress-service.test.js`, `test/egress-grants.test.js`, + `test/memory-review.test.js`, `test/channel-credentials.test.js`, + `test/container-lifecycle.test.js`, `test/egress-forwarder.test.js`, + `test/admin-ui-controls.test.js`): a personal grant is refused `another-author-active` while + another author's turn or job is live in the channel, resumes when it ends, ignores other + channels and ownerless work, and a memory review is marked live with an EMPTY owner; each + channel socket has `maxConnections` 256; a listener is rebound when the channel id changes and + a close waits for an in-flight bind; `bindRunningChannelEgress` restores the listener of a + running proxy-mode container (and skips stopped ones and the legacy mode); a secret that + briefly resolves empty keeps its placeholder and the next run gets the same one; the preamble + says personal placeholders pause; the network mode moves the MOUNT fingerprint; the forwarder + delivers the whole answer after a client half-close (fails without `allowHalfOpen`) and + `cg-init` logs it to `/run/cg/egress.log`; the hosts field and `set_secret` warn against + `*.vercel.app`. +- [ ] UNEXECUTED (engine-independent) — restart restore. Start a background job that curls a public + URL every 10 s through the proxy (`run_in_background`, network ON), restart the gateway, and + within 30 s check the job log: the requests keep succeeding and the daemon log shows + `[egress] restored 1 running container listener(s)`. Pass: no gap longer than one interval + after the daemon is back. +- [ ] UNEXECUTED (Claude) — personal pause. Author A sets a personal `GH_TOKEN`; in the same channel + author B starts a long turn (`sleep 60 then say done`) while A asks for `gh api user`. Expected: + A's call answers 403 `another-author-active` naming the reason and A's reply says the personal + secret is paused; the same request after B's turn ends succeeds. + +### Live gates (Claude AND Codex unless marked) — all UNEXECUTED + +Common setup for every gate: this branch deployed, `npm run build:image` (spec 1.6.0), +Settings → Container runtime with *Legacy open network* OFF, a Slack test channel in Worker mode +with Auto on, and the daemon log open. Record `podman inspect --format +'{{.HostConfig.NetworkMode}}'` (expected `none`) and the `/api/health` → `containerRuntime.egress` +block (`running: true`) before starting. + +- [ ] UNEXECUTED — Engine turns under `--network none`. Network switch OFF. Send "reply with the + word pong" once with the thread on Claude and once on Codex (`/model`, just this thread). + Evidence: both answer; `podman exec printenv CLAUDE_CODE_OAUTH_TOKEN` starts + `sk-ant-oat01-cgph_r`; `podman exec sh -c 'cat /proc/net/dev'` lists only `lo`; the + `egress` events table has no row for `api.anthropic.com` / `chatgpt.com` (relay swaps are + counted, not logged). Pass: both engines answer, including Codex's WebSocket transport, with + no real token in the container env. +- [ ] UNEXECUTED — `gh api user`, `git clone https://github.com/`, `vercel whoami`. + Set the channel secrets `GITHUB_TOKEN` (a fine-grained PAT with read access to one private + repo) and `VERCEL_TOKEN`; switch network ON. Ask the agent to run the three commands. + Evidence: `podman exec printenv GITHUB_TOKEN` is `cgph_c…`; all three succeed (the clone + uses Basic with the placeholder as password); three `egress` rows with + `swapped: [{secretName: "GITHUB_TOKEN"|"VERCEL_TOKEN"}]`. Pass: all three succeed on both + engines and no real token is in the container env or any reply. +- [ ] UNEXECUTED — agent-browser on an HTTPS page (agent-browser 0.36.0 is not installed on the + development host, so whether it passes `AGENT_BROWSER_ARGS` to Chromium is unverified). Network + ON; ask "open https://example.com in the browser and read me the heading". Evidence: the + heading is returned; `podman exec printenv AGENT_BROWSER_ARGS` shows `--proxy-server` and + `--ignore-certificate-errors-spki-list`. Pass: the page loads with no certificate error. Fail + on a certificate error or a direct-connect attempt. +- [ ] UNEXECUTED (engine-independent) — bypass proof from inside the container. `podman exec + sh -c 'curl --noproxy "*" -sS https://example.com; echo rc=$?'` → a connect failure (no + route); `podman exec getent hosts example.com` → no answer; `podman exec cat + /proc/net/dev` → only `lo`; `podman exec curl -sS --max-time 5 http://10.88.0.1/` + (the podman bridge gateway) and `curl -sS https://169.254.169.254/` through the proxy → a + failure / `403 blocked-address`. Pass: every bypass fails. +- [ ] UNEXECUTED (engine-independent) — a placeholder sent to a non-declared host arrives + unswapped. With a request bin you control (e.g. a `https://.example` endpoint that + echoes headers), `podman exec sh -c 'curl -sS -H "Authorization: Bearer $GITHUB_TOKEN" + https:///'`. Evidence: the bin records `Bearer cgph_c…`, never the PAT; an `egress` row + with `refused: [{secretName: "GITHUB_TOKEN", reason: "host"}]`. Pass: the real value never + leaves. +- [ ] UNEXECUTED (engine-independent) — rotation live without recreate. Note the container id, + rotate `GITHUB_TOKEN` in the admin UI to a second valid PAT, revoke the first at GitHub, then + run `gh api user` again. Evidence: same container id; the call succeeds as the second PAT's + user; the placeholder in `printenv` is unchanged. Then REMOVE the secret and replay the old + placeholder by hand (`curl -H "Authorization: Bearer " + https://api.github.com/user`) → 401 from GitHub (the placeholder is revoked and forwarded as + the useless string it is). Pass: rotation needed no recreate and the removed secret's + placeholder is dead. +- [ ] UNEXECUTED (engine-independent) — the switch is live and fail-closed. Network OFF: `curl -sS + https://example.com` → `403 {"error":"network-off"}`; flip ON in the admin UI; the same command + within seconds succeeds with no recreate. Stop the daemon's egress service by starting it with + an unreadable `config/egress-ca/ca.key` (then restore): the next message ends with `egress + proxy unavailable: …` before any engine starts, and the container is not recreated onto the + bridge. Pass: all three observations. +- [ ] UNEXECUTED (engine-independent) — the legacy escape. Turn *Legacy open network* ON, send a + message: the container is recreated at its next idle moment with `NetworkMode=bridge`, + `printenv GITHUB_TOKEN` is the raw PAT, `/status` shows "network off (advisory — not enforced + for this container)" when off. Turn it OFF again and confirm the return to `none` + + placeholders. Pass: both transitions observed. + +## Remote MCP relay (container-secrets P1) + +Fixtures: the fake container backend (`test/fixtures/fake-runtime-backend.js`, isolated, image +helper table), the host backend, a daemon socket under a short `/tmp/cgsock-*` dir, a fake remote +MCP server (in-memory transport behind the relay's injectable `connectRemote`) and a loopback +HTTP MCP server reached through an https URL plus a rewriting `fetch`. Token fixtures: +`ck_user_*`, `ck_shared_*`, `tb-*`, `mk-*`. + +- [x] Capability (`test/mcp-capability.test.js`): the optional `remoteMcps` claim round-trips with a + caller-fixed `jti`; a token without it still verifies (no relay granted); a string, a + non-string member, an upper-case / leading-dash / 65-char name, a duplicate, a 17th name or an + object is refused at mint AND at verify (`invalid remote MCP grants`); an empty list is a valid + empty grant. Engine-independent: signing code only. +- [x] Registry (`test/remote-mcp-registry.test.js`): lookups answer per jti + name and return + copies; an entry dies at its `exp` (swept on lookup) and on `clearRemoteMcps`; an empty + registry keeps no timer; > 16 servers, a header value over 8 KB, a non-string or CR/LF header, + plain http, credentials in the URL and a bad name are refused with messages that quote no + value or host. Exactly 8 KB is accepted. Engine-independent. +- [x] Socket service (`test/mcp-socket-server.test.js`): through the real reference bridge + (`src/mcp/socket-bridge.js`, `CG_MCP_SERVICE=remote-mcp`, argv `composio-user`) a relayed + `tools/list` and `tools/call` reach the fake remote, the remote's instructions reach the client, + and the remote was dialled with exactly the REGISTERED URL + header. Refused with the fixed + `remote MCP is not authorized for this run` (and nothing dialled): a claimed name with no + registration under the jti; a registered name the claim omits; a claim-less token; an unknown + and a path-shaped name; a registration past its expiry. An expired capability is refused at + `capability rejected`. Clearing the registration mid-connection makes the next `tools/call` + fail with "not authorized" without reaching the remote. A dial failure answers only + `remote MCP unavailable`. Codex's exact chain (`secret-env-bridge.js` reading the capability + from a 0600 bundle → socket bridge → relay) completes a `tools/call`. Pass: all green, and no + refusal line contains a token, `composio.dev` or `make.com`. +- [x] HTTP leg (`test/mcp-remote-relay.test.js`): Streamable HTTP carries the header on every + request; a 405 on the Streamable POST falls back to HTTP+SSE with the header on the GET stream + and every POST; a 502 is not retried on SSE and fails as `remote MCP server unavailable` + (the upstream body quoting a key is not surfaced); http and credential-bearing URLs are + refused; `runRemoteRelay` authorizes before dialling and on every forwarded request, and + upstream progress reaches the engine under the engine's own progress token. +- [x] Claude config (`test/mcp-config.test.js`): an isolated target with all four remotes gives each + the exact entry `{command: node, args: [cg-mcp-bridge, ], env: {CG_MCP_SERVICE: + "remote-mcp", CG_GATEWAY_CAPABILITY}, default_tools_approval_mode: "approve"}`; the JSON + contains no token, no `x-consumer-api-key`, no `Bearer `, no `headers`; the claim lists the + four names and the registry holds each URL + header under the claim's jti; `relayDigest` is + `sha256:<64 hex>` per server with no value in it. A non-SDK endpoint with its own headers is + relayed while an SDK session stays on `composio-sdk`. A non-https `TOOLBOX_MCP_URL` is dropped + from the isolated run and listed in `rejectedRemotes`, never handed over; the host keeps it. + Host and local targets: the four entries are byte-identical to the pre-change shape, no relay + claim, nothing registered. +- [x] Warm pool (`test/run-engine-mcp.test.js`): same headers → same fingerprint across fresh + capabilities; rotating any of the four tokens or the Make URL changes it; no token appears in + the JSON or the fingerprint. +- [x] Codex (`test/codex-args.test.js`, `test/engine-runtime-isolated.test.js`): in a container each + remote is `secret-env-bridge gatewayCapability CG_GATEWAY_CAPABILITY cg-mcp-bridge + ` with `env.CG_MCP_SERVICE="remote-mcp"`, `env.CG_ENGINE="codex"`, approval `approve`, + `startup_timeout_sec=120`, no `url`, no `http_headers_helper`, no helper spec; "every connector + secret stays out of Codex argv and child env" also asserts the isolated bundle is exactly + `{gatewayCapability}`; a real `runCodex` on the fake container writes a bundle equal to + `{"gatewayCapability":"signed-cap"}` and no `.cjs` beside it. A sudo-host target keeps the + native URL + headers helper shape. `test/secret-env-bridge.test.js`: the broker forwards + `CG_MCP_SERVICE`, `CG_MCP_SOCKET` and argv, and nothing else from its env. +- [x] SSH (`test/ssh-session.test.js`): the session's `mcp.json` relays `composio-user` and + `composio-agent` with no token in the file; the registry holds the developer's and the + channel's tokens under the session capability's jti (12 h); the Codex bundle's only key is + `gatewayCapability`, no `*.headers.cjs` exists, and the overrides select `remote-mcp`; a second + preparation registers a fresh jti while the first stays live. +- [x] Review fixes (engine-independent unless noted): + `test/mcp-socket-server.test.js` — a client that says hello and hangs up while the upstream + dial is pending: once the dial settles the upstream client is closed and the relay slot freed; + 8 in-flight dials per grant + server, the 9th hello refused with `too many remote MCP + connections for this run` and nothing dialled, another server on the same grant unaffected; + an open relay keeps its grant after the minting caller releases it and the grant goes when + the connection closes; hellos with a crafted object in `args[0]` (remote-mcp, composio-sdk) + or as `service` are refused and closed (no unhandled rejection, which a mutation of the + catch reproduces); a `gateway` hello with `args` is refused naming `npm run build:image`; + `clear_my_composio_token` / `clear_my_toolbox_token` (approved control-plane call over the + socket) drop the author's grants and nobody else's. `test/remote-mcp-revocation.test.js` — + a real cold `runMessage` on the fake container backend relays both Composio identities while + the engine runs and leaves the registry empty once it settles (red with the `finally` + release removed); the admin UI's `PUT /api/users/:id` with `clearToolboxToken` drops every + grant for that author in every channel and origin, an unrelated edit drops nothing. + `test/remote-mcp-registry.test.js` — holds, `clearRemoteMcpsWhere` (held or not, metadata + only, a throwing predicate drops nothing), `remoteMcpServerProblem` per server and value-free. + `test/mcp-remote-relay.test.js` — an upstream `Error POSTing to endpoint (HTTP 401): …ck_…` and + a DNS failure reach the engine only as `remote MCP request failed`; an upstream `McpError` + (`Unknown tool`) passes with its code. `test/mcp-config.test.js` / `test/codex-args.test.js` + — a CR/LF, non-string, > 8 KB, badly named or 17-header endpoint drops only `composio-user` + (reported in `rejectedRemotes`, reason value-free) while `composio-agent` and the toolbox still + relay, and Codex skips the same server. `test/ssh-session.test.js` — a refresh releases the + previous Claude and Codex grants unless a running process holds one, and the session's end + drops them all, held or not. +- [x] Image contract: `imageSpecVersion` and `IMAGE_SPEC_VERSION` are both `1.6.0` + (`test/container-image.test.js` pins them equal). +- [ ] UNEXECUTED live gate — Composio tool call through the relay on Claude and on Codex in a + container; the artifact dir grepped for token strings during the run. Setup: `npm run + build:image` (spec 1.6.0), a Slack test channel in Worker mode with a channel Composio token + and the tester's personal token set, the Toolbox token set if available. Action, once with + the channel on Claude and once on Codex (`/model` for the thread): "search my Gmail for the + last message from and tell me its subject", then "use your own account to + list its Composio connections". While the turn runs, on the host: `grep -rF -e '' -e '' -e '' ~/ChannelGate/.runtime/slack//` + and, inside the container, `podman exec sh -c 'grep -rF /tmp + /var/tmp ~ 2>/dev/null'`. Expected evidence: both answers carry real data from the right + identity; the `cg-mcp-*.json` (Claude) and `run/cg-codex-secrets-*.json` (Codex) exist and + contain `remote-mcp` / only `gatewayCapability`; every grep prints nothing. Then rotate the + channel token in Settings and send a second message on Claude: the warm process is retired + (a new `claude` pid) and the call succeeds on the new token. Pass rule: all of the above on + BOTH engines; any token hit, a refused relay, or a stale-token success after rotation fails. + ## One worktree per task is stated as a prohibition in all three places that carry it - [x] `test/git-worktree-guide.test.js`: the always-on `gateway-usage` rule 7 says *never edit the @@ -1141,8 +1758,9 @@ The skipped/live cases below remain unverified; this branch is not a release can Repeat the off observation in a fresh thread and, where supported, a Clean thread, without adding optional credentials to Clean. Restore the original switch and any owned fixture state. Pass requires actual policy delivery, no new outbound request under off, and an accurate final - explanation in each tested variant. A model refusal does not prove kernel egress isolation; - the container remains on the bridge network and no egress-blocking behavior is added here. + explanation in each tested variant. A model refusal does not prove kernel egress isolation; the + egress proxy's own enforcement of the switch is gated separately (Egress proxy, placeholders and + `--network none`), and this case stays about the delivered policy text. ## Control-plane approval receipts @@ -3431,8 +4049,9 @@ mode; the gateway's run API key; an admin Slack id): **Retired 2026-09-03 (Linux + containers only):** the entries below that exercise Codex's host permission profiles, the semantic network compiler / `network_proxy`, the macOS seatbelt probes and the credential/toolchain re-grants describe the retired host sandbox and are kept as history. Inside -its container Codex states `--sandbox read-only` / `danger-full-access` per mode; the container is on -the bridge network and *Allow network* is only a switch the engines are told about. +its container Codex states `--sandbox read-only` / `danger-full-access` per mode; the container has +no network of its own and the egress proxy enforces *Allow network* (see Egress proxy, +placeholders and `--network none`). - [ ] Codex 0.147+ resume state: `CODEX_HOME` is stable and contains only linked auth/session state; private granted skills live under the disposable synthetic `HOME/.agents/skills`; cleanup @@ -4053,8 +4672,9 @@ the bridge network and *Allow network* is only a switch the engines are told abo **Retired 2026-09-03 (Linux + containers only):** the host sandbox is gone. The boundary is the channel's container (only the work folder mounted — `~/.ssh`, the gateway root and sibling folders do not exist inside it; see *Container runtime → channel isolation inside the containers*). *Allow -network* is a per-channel switch the engines are told about — no domain filtering and, in this -release, no egress cut-off — so the network entry has no container equivalent yet. Kept as history. +network* is a per-channel switch with no domain filtering; since container-secrets P2 the egress proxy +enforces it (off/on, private addresses refused) — see Egress proxy, placeholders and `--network +none` for its cases and live gates. Kept as history. - [ ] Bash channel: write inside the folder works; writing `~/.ssh`, `~/.aws`, the gateway root, or a sibling channel folder is denied; reading the home root / gateway root is denied. - [ ] Network off by default; allow-network + allow-bash → `gh`/`git push` succeed, a non-allowlisted @@ -4062,7 +4682,8 @@ release, no egress cut-off — so the network entry has no container equivalent - [x] Unit (CTO-04 regression): `modeLabel` states the network switch in BOTH directions — a channel with it off renders `Read-only · network off`, not a bare `Read-only`, so "off" is no longer indistinguishable from "never configured"; `{ detail: true }` adds - `(advisory — not enforced by the container yet)` for the off state only, and an engine that + `(advisory — not enforced for this container)` for the off state only, and only where the + egress proxy is not the channel's network (P2), and an engine that does not declare the `on` mode still reads `network unsupported` (`test/modes.test.js`). - [x] Unit (CTO-04 regression): `/status` carries the channel's own switches — `formatCapabilityLine` @@ -5335,7 +5956,8 @@ are the v0.8 production deployment gate and are executed in the QA loop that fol - [x] Unit: the mount contract — the kinds are exactly workdir/clean/artifacts/home/socket/ codex-auth; every bind source is absolute; nothing under `config/`, never `gateway.db`, never the gateway root itself, never the per-channel metadata folder; the socket mount is read-only; - the Codex credential is a single file mount; and workdir/clean/artifacts have IDENTICAL source + the Codex credential is a single file mount (only outside the egress proxy since P4 — behind + it Codex is relayed with no mount); and workdir/clean/artifacts have IDENTICAL source and target paths (automated: `test/container-lifecycle.test.js`). - [x] Unit: the fingerprint covers create-time configuration only — a new image ID changes it, a network-mode change changes it, a per-exec meta change does not (automated). @@ -5620,7 +6242,7 @@ are the v0.8 production deployment gate and are executed in the QA loop that fol copied or mounted; no token and no login anywhere is `missing`; a readable login settles to `relay` whether it is the OPERATOR's own `~/.claude` or one signed in to the gateway's engine home, with nothing copied or mounted either way; Codex resolves to `shared-file` or `missing` - in candidate order. + in candidate order (and to `relay` behind the egress proxy — see P4). `credentialError()` returns null when the engine can run and otherwise names the exact remedy per engine — including when it is called BEFORE `ensureUp` has settled the target (automated: `test/container-credentials.test.js`). @@ -5629,7 +6251,8 @@ are the v0.8 production deployment gate and are executed in the QA loop that fol and mounts — with no Codex login there is NO `codex-auth` mount on the run argv, and a login that appears between two passes brings the mount back without duplicating it (automated). - [x] Unit: `helperCommand()` returns image-bundle paths only and never a checkout path (the Stop - hook, the Codex secret-env bridge, the remote-secret bridge, and the gateway MCP bridge — + hook, the Codex secret-env bridge, and the gateway MCP bridge (the remote-secret bridge was + retired in P4) — which Composio SDK mode reuses as a second service); an unknown helper throws; the returned object is a copy, so a caller cannot corrupt the table (automated). - [x] Unit: spawn before `ensureUp` emits an `error` event on the child instead of throwing; @@ -5877,8 +6500,9 @@ are the v0.8 production deployment gate and are executed in the QA loop that fol commit, and push with the channel's own `GH_TOKEN` (or a HOME-volume `gh` login) from inside the container; the identical-path mount keeps every absolute path the model prints valid on the host. -- [ ] Live: **Codex in a container, and honest failover** — a Codex turn answers using the shared - sign-in file mount; a Claude turn that hits a provider outage fails over to Codex in the same +- [ ] Live: **Codex in a container, and honest failover** — a Codex turn answers using the relayed + access-only login behind the egress proxy (the shared sign-in file mount only in the legacy + bridge mode — see P4); a Claude turn that hits a provider outage fails over to Codex in the same container; and a turn on a user-PINNED harness fails with that harness's own error plus the manual-switch hint instead of being answered by the other engine. - [ ] Live: **the idle reaper and a scheduled cold start** — an idle channel's container stops after diff --git a/containers/bin/cg-egress-connect b/containers/bin/cg-egress-connect new file mode 100755 index 00000000..9db0bd10 --- /dev/null +++ b/containers/bin/cg-egress-connect @@ -0,0 +1,9 @@ +#!/bin/sh +# cg-egress-connect — an SSH ProxyCommand through the gateway's egress proxy (docs/SSH-ACCESS.md). +# A `--network none` channel container has no route of its own; this speaks `CONNECT host:port` to +# the in-container forwarder (127.0.0.1:3128) and pipes stdio through the raw tunnel the proxy +# allows (github.com:22, plus the channel's declared raw hosts, with Allow network on). An SSH +# session's GIT_SSH_COMMAND already uses it; for your own ssh: +# ssh -o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p' user@host +# The implementation is bin/cg-egress-connect.mjs, staged verbatim from src/mcp/egress-connect.js. +exec node /opt/channelgate/bin/cg-egress-connect.mjs "$@" diff --git a/containers/bin/cg-init b/containers/bin/cg-init index 30b606a2..e8189815 100755 --- a/containers/bin/cg-init +++ b/containers/bin/cg-init @@ -45,6 +45,28 @@ if [ -d "$VSCODE_SHARED" ] && [ ! -e "$VSCODE_HOME/.cg-no-shared-server" ]; then done fi +# The egress forwarder (container-secrets P2). A proxy-mode container is created with +# `--network none`: its only way out is 127.0.0.1:3128 (HTTPS_PROXY), which cg-egress pipes to the +# daemon's per-channel egress socket. Started in the background on EVERY start (a stop kills it with +# everything else), only when the daemon created this container with CG_EGRESS=proxy. It never +# exits on an error, and a second copy just finds the port taken. Its failure lines (at most one +# per category a minute) go to /run/cg/egress.log on the /run tmpfs: readable with `podman exec`, +# gone at the next start. +if [ "${CG_EGRESS:-}" = "proxy" ] && [ -f /opt/channelgate/bin/cg-egress.mjs ]; then + node /opt/channelgate/bin/cg-egress.mjs /dev/null 2>>/run/cg/egress.log & +fi + +# Codex behind the egress proxy (container-secrets P4) is RELAYED: before each Codex run the daemon +# writes an ACCESS-ONLY auth.json here (a placeholder token, an EMPTY refresh token). So under the +# proxy a Codex login file in this volume that carries a refresh token is never the daemon's — it is +# a leftover from before the relay (an old copy of a real login) — and is removed on every start. +# A mounted file (the shared-file mode an API-key login keeps) is never touched. +CODEX_AUTH=/home/agent/.codex/auth.json +if [ "${CG_EGRESS:-}" = "proxy" ] && [ -f "$CODEX_AUTH" ] && ! grep -q " $CODEX_AUTH " /proc/self/mountinfo 2>/dev/null \ + && grep -q '"refresh_token"[[:space:]]*:[[:space:]]*"[^"]' "$CODEX_AUTH" 2>/dev/null; then + rm -f "$CODEX_AUTH" +fi + # Claude credentials are NEVER copied in here: the daemon hands the engine a `claude setup-token` # value through the exec environment (CLAUDE_CODE_OAUTH_TOKEN). A copy of the gateway's own login # would refresh, rotate the refresh token and log the gateway itself out (2026-09-02 incident). diff --git a/containers/versions.json b/containers/versions.json index a1abe144..1fb74dd7 100644 --- a/containers/versions.json +++ b/containers/versions.json @@ -1,5 +1,5 @@ { - "imageSpecVersion": "1.5.1", + "imageSpecVersion": "1.6.0", "comment": "Pinned toolchain for the channel image. Keep `claude` and `codex` at the versions the gateway host runs, so a channel behaves identically on either runtime backend; bump deliberately, rebuild, and let the image-id fingerprint recreate the containers. `imageSpecVersion` is the image CONTRACT (paths, PATH, baked-in packages), not the pins: bump it when the image gains or moves something the daemon relies on, keep it equal to IMAGE_SPEC_VERSION in src/runtimes/container/image-paths.js (a test pins them together), and the daemon will tell an operator at boot when the built image is older.", "base": "node:22-bookworm-slim", "npm": { diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index f5edd893..2dfe211d 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -29,7 +29,7 @@ definitions are in `TEST-PLAN.md` and `docs/RELEASE-ACCEPTANCE.md`. | Operating system | Linux (systemd) with rootless Podman; Ubuntu 24.04 tested | CI on Ubuntu | | Node.js | 22.13 minimum; 24 LTS. SQLite FTS5 (channel-memory search index) is present from the later 22.x builds and 24; on a build without it the daemon boots and memory search uses a plain scan | Full matrix CI | | Claude Code | Pinned nightly target `2.1.281` (official installer) | Real CLI surface + stub transport | -| Codex CLI | Pinned nightly target `0.156.1` | Real CLI surface + stub transport | +| Codex CLI | Pinned nightly target `0.156.1`. The container login relay relies on three behaviors verified on 0.156.1 (2026-09-26 spike, `TEST-PLAN.md` P4): a turn runs on an `auth.json` with an access token and an empty refresh token; a valid access token is not refreshed because of `last_refresh` age; `login status` and a turn accept a JWT whose signature segment is a placeholder. `codex exec` needs a closed stdin. Re-run the spike on a Codex bump | Real CLI surface + stub transport; the relay spike before promotion | | SQLite | Built-in `node:sqlite` | Migrations + backup/restore quick-check | | VS Code (editor attach) | Any client. Servers for `1.139.0` and `1.138.0` are shared in the channel image (image spec 1.5.0); another version downloads its own into the channel volume as before | Live image test (`npm run test:live-container`) + a real Remote-SSH / attach connection before promotion | | Slack | Socket Mode Slack app manifest in repository | Real workspace canary before promotion | diff --git a/docs/ENGINE-CAPABILITIES.md b/docs/ENGINE-CAPABILITIES.md index 8319669c..73444143 100644 --- a/docs/ENGINE-CAPABILITIES.md +++ b/docs/ENGINE-CAPABILITIES.md @@ -6,7 +6,7 @@ the Admin API/UI consume that registry. | Capability | Claude | Codex | Qwen harnesses (Claude Code CLI) | | --- | --- | --- | --- | | Filesystem confinement | Per-conversation container mounts by default; Claude permissions control tools. Admin-only Slack `/sudo` threads deliberately run on the host | Same default container boundary; Read-only mode adds a CLI read-only sandbox. `/sudo` deliberately runs on the host | Identical to Claude — same CLI, same lockdown file, same resolved runtime | -| Network policy | Advisory off/on; container bridge networking by default, direct daemon-account network in `/sudo`; no domain filtering or egress firewall | Same resolved-runtime policy; CLI Read-only mode also restricts its own network access outside bypass | Same advisory off/on as Claude | +| Network policy | Off/on enforced by the per-channel egress proxy (`--network none` container; no per-domain filtering when on); advisory under the legacy bridge egress mode or a `rawNetwork` channel; direct daemon-account network in `/sudo` | Same resolved-runtime policy; CLI Read-only mode also restricts its own network access outside bypass | Same as Claude (the configured provider endpoint is reachable with the switch off) | | Warm process / steer | yes | no; one-shot resume | no; cold runs only, so a rotated provider key can never be served by a warm process | | Session identity | gateway-minted UUID | CLI-minted thread ID, persisted after the turn | gateway-minted UUID (same CLI, same transcript layout, so session carry works unchanged) | | Permission prompts | Interactive Slack tool approvals; automatic approval with Auto | Headless deny or eligible automatic review with Auto | Interactive Slack tool approvals, as Claude | @@ -15,6 +15,7 @@ the Admin API/UI consume that registry. | Skills | Organization/channel repository skills plus per-author grants | Native organization/channel repository skills plus a per-run personal skill catalog; personal delivery does not register slash commands | Same as Claude (`CLAUDE.md`, `.claude/skills`, plugin dirs) | | Usage/cost | provider-reported cost | token usage with configured rate estimate | token usage only — the CLI's Anthropic-priced figure is dropped and no rate is inferred | | Health | adapter-owned `--version` boot probe | adapter-owned `--version` boot probe | adapter-owned `--version` boot probe plus "is a QwenCloud key configured" | +| Container login | a relay of the host's Claude access token in `CLAUDE_CODE_OAUTH_TOKEN` — behind the egress proxy the channel's `sk-ant-oat01-cgph_r…` placeholder, swapped on `api.anthropic.com`; refreshed by a cheap host turn (`src/gateway/claude-token-relay.js`); never a file | behind the egress proxy an ACCESS-ONLY `auth.json` in the channel HOME, written before each run: a JWT-shaped `cgph_r…` placeholder swapped whole on `api.openai.com`, `chatgpt.com`, `auth.openai.com`, empty refresh token; refreshed by a cheap ephemeral host turn (`src/gateway/codex-token-relay.js`). Legacy bridge mode or an API-key login: the shared read-write file mount | the gateway's provider key in `ANTHROPIC_AUTH_TOKEN`, raw (not relayed) | The Qwen column covers every harness generated from the Anthropic-compatible **provider table** in `src/engines/qwen.js` — today `qwen` (QwenCloud) and `qwen-eu` (Alibaba Cloud Model Studio, EU diff --git a/docs/OPENCODE-ADAPTER.md b/docs/OPENCODE-ADAPTER.md index 81706751..fa8fcb5a 100644 --- a/docs/OPENCODE-ADAPTER.md +++ b/docs/OPENCODE-ADAPTER.md @@ -24,7 +24,8 @@ rejected before process spawn. Unknown capabilities remain denied by the adapter Like every engine, OpenCode is spawned through the shared runtime target inside the channel's rootless Podman container. Container mounts and process isolation provide the OS boundary; OpenCode's read-action policy restricts access within that boundary. The network-off setting is -engine policy, not an egress cut-off. Broader adapter admission still requires its own acceptance +enforced by the channel's egress proxy (the container has no network of its own) unless the +gateway runs its legacy bridge egress mode. Broader adapter admission still requires its own acceptance evidence; the container does not make unsupported tools or permissions available. ## Supported runtime contract diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index df67a47b..6613a305 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -360,6 +360,8 @@ runs too, and the next successful update settles it. | Process / memory / CPU limit | `--pids-limit` (default 1024), `--memory` (e.g. `2g`), `--cpus` (e.g. `1.5`); blank = no limit | | Claude token for container runs | the output of `claude setup-token` on the gateway host — write-only | | Full-access channels see the gateway home | off by default; on = every Full-access channel's container also mounts the gateway user's whole home read-write (see below) | +| Legacy open network (no egress proxy) | off by default (`containerEgressMode = "proxy"`); on (`"bridge"`) = the pre-proxy behavior: the open bridge network and raw secret values (see **Network** below) | +| Withhold unprotected secrets | `containerEgressSecretsStrict`: ON for a new install, OFF for an install that existed before this default (stored once at boot; your choice is never changed); on = a secret with no egress rule is not given to proxy-mode containers at all, off = it is injected raw and listed as unprotected | Values that would reach the container CLI's argv are validated at the boundary: a flag, a space or a shell metacharacter in the image/memory/cpu fields is rejected with an error, not silently cleaned. @@ -368,17 +370,109 @@ shell metacharacter in the image/memory/cpu fields is rejected with an error, no Claude login — its current access token, in `CLAUDE_CODE_OAUTH_TOKEN` — so keeping `claude` signed in on the host is all a channel needs. Nothing is copied or mounted. Optionally run `claude setup-token` on the gateway host and paste the value into *Claude token for container runs*: -that token is then used instead and never needs refreshing. Codex is different — it rewrites -`auth.json` in place, so every container shares a read-write mount of the gateway's real auth file; -keep the host signed in with `codex login`. Codex *sessions* and history are still per channel. - -**Network.** Every channel container runs on the default bridge network. The per-channel *Allow -network* switch (admin UI → the channel → Advanced, or `set_channel_network` in chat) is kept and -shown: it tells the engines whether the channel is meant to have network access (Codex read mode -refuses network on its own), and that is all it does in this release — there is no per-domain -filtering and no egress cut-off in the container. The boundary today is the container's -filesystem and process isolation, not its egress; a container-side egress proxy that enforces the -switch is the planned follow-up. +that token is then used instead and never needs refreshing. Codex is RELAYED the same way behind +the egress proxy (see **Codex login relay** below): keep the host signed in with `codex login`; +nothing is mounted. Only the legacy open-network mode and an API-key Codex login still bind-mount +the gateway's real `auth.json` read-write into every container (Codex rewrites it in place, so a +copy would fork the refresh chain). Codex *sessions* and history are per channel either way. + +**Network (the egress proxy).** Every channel container runs with `--network none`: its only +interface is `lo`. The one way out is the daemon's egress proxy. `cg-init` starts a small forwarder +(`/opt/channelgate/bin/cg-egress.mjs`) on `127.0.0.1:3128` inside the container, every HTTP(S) +client there is pointed at it (`HTTP_PROXY`/`HTTPS_PROXY` and the lowercase twins, `NO_PROXY` for +loopback only, `NODE_USE_ENV_PROXY=1`), and the forwarder pipes each connection to the channel's +own unix socket — `~/.channelgate/eg/<12 hex>/egress.sock`, mounted read-only at +`/run/channelgate/egress/`. The socket path IS the channel's identity: the proxy never trusts what +the container says about itself, and no container can see another channel's socket. The proxy +terminates TLS with the deployment's egress CA (created once under +`~/.channelgate/config/egress-ca/`, `ca.key` 0600 never leaves the daemon) and applies the +channel's policy to every request: + +- *Allow network* **off**: only the engine endpoints (`api.anthropic.com`, `claude.ai`, + `statsig.anthropic.com`, `api.openai.com`, `chatgpt.com`, `auth.openai.com`, a configured Qwen + endpoint) and the remote MCP servers this channel's runs were handed are reachable; anything + else answers `403 {"error":"network-off"}`. +- *Allow network* **on**: any PUBLIC destination. Loopback, private, link-local (the cloud metadata + address included), CGNAT and reserved ranges are always refused, checked on every resolved + address and pinned for the connect, so DNS rebinding does not help. +- The switch is read on every request: a flip applies to the next connection, no recreate. +- **Raw sockets** (`ssh`, `psql`) get a plain CONNECT tunnel only while the network is on and only + to `github.com:22` plus the hosts an admin lists in the channel's `egressRawHosts` (admin API: + `PUT /api/channels//meta` with `{"egressRawHosts":["db.example.com"]}`), on ports 22, 5432 + and 6543. A client has to be pointed at the proxy for that (a `ProxyCommand`); nothing else + leaves the container. +- A channel that genuinely needs arbitrary raw sockets can be given the open bridge BESIDE the + proxy: `{"rawNetwork": true}` on the same admin API. The proxy env stays set, so proxy-aware + tools still use placeholders, but the switch is then advisory for that channel and every surface + says so. Changing it recreates that channel's container at its next idle moment. + +**The CA trust bundle.** At boot the daemon writes `~/.channelgate/run/egress-ca.pem` (0644): the +host's own `/etc/ssl/certs/ca-certificates.crt` followed by the egress CA, rewritten in place only +when its bytes change. It is mounted at `/run/channelgate/egress-ca.pem` and named by +`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, +`PIP_CERT`, `NPM_CONFIG_CAFILE`, `CARGO_HTTP_CAINFO`, `AWS_CA_BUNDLE` and `DENO_CERT`; Chromium +(agent-browser) gets `--proxy-server` plus the CA's SPKI pin through `AGENT_BROWSER_ARGS`. A tool +that reads none of these fails TLS with an unknown-issuer error — point it at the bundle. Rotating +the CA means deleting `config/egress-ca/` and restarting; every container picks the new bundle up +at its next start. + +**Secrets through the proxy.** A proxy-mode container never holds a channel, organization or +personal secret that has an egress rule, nor the relayed Claude login: it holds a placeholder +(`cgph_…`; the Claude token keeps its `sk-ant-oat01-` shape). The proxy swaps the placeholder for +the real value only on that secret's declared hosts and headers — GitHub, Vercel, Supabase, Make +and Composio token names have built-in rules; any other secret is protected once an admin fills +**Used on hosts** in the secrets editor (or passes `hosts` to `set_secret`) — and only while the +channel has live work (a turn, a background job, a memory review or an SSH session); a personal +secret additionally only while its owner is the one working there and NO other person has live +work there — another author's turn, background job or SSH session pauses it (`403 +another-author-active` / `another-person-ssh-session`) until that work ends. Everything else about +a placeholder is forwarded unchanged: a placeholder sent to a host it was not declared for arrives +as the useless string it is. When declaring **Used on hosts**, avoid multi-tenant suffixes such as +`*.vercel.app`, `*.herokuapp.com` or `*.github.io`: a wildcard there covers every other customer's +deployment too, and a container could have the real value swapped into a request to a site it +controls. Rotation is live (the proxy +resolves the current value per request); removing a secret revokes its placeholder, and re-adding +mints a new one. A secret with no rule is withheld entirely while *Withhold unprotected secrets* +is on (the default on a new install; the run's credential note names it as withheld), or injected +raw and listed as **unprotected** in the admin UI and the run's own credential note while it is +off. Either way `list_secrets` ends with a **Finding** naming each such secret; declare its *Used on +hosts* (or `hosts` with `set_secret`) to give containers a placeholder instead. +`SUPABASE_DB_PASSWORD` and other raw-protocol passwords can never be swapped: they stay raw +(withheld under strict). + +**What the proxy refuses on the way.** Inside a tunnel the request target must be a path (an +absolute-form `GET https://other/…` line is `400 absolute-form-in-tunnel`); a `Host` header naming +another host than the one the connection was approved for is `403 host-mismatch` (no domain +fronting through an allowed CDN host); a request with no `Host` gets the approved host and no +secret. A request that carried a swapped secret asks the upstream for an uncompressed answer, and +the real value is scrubbed back to the placeholder in response headers and in any readable body +(text, JSON, or no declared type) — a declared binary body, or one the upstream compresses anyway, +passes through unscrubbed. Deadlines: DNS 5 s (`504 dns-timeout`), at most 16 destination lookups +in flight per channel (`503 too-many-lookups`), 256 open connections per channel socket, connect +15 s, response headers 60 s after the request was sent (`504 upstream-timeout`). Upstream TLS is +always verified, against Node's bundled roots plus the host's `/etc/ssl/certs/ca-certificates.crt`. + +**After a daemon restart** the running proxy-mode containers get their channel listeners back at +boot (`[egress] restored N running container listener(s)`), so a recovered background job, an +attached editor or a process left running from an SSH session keeps its network without waiting +for the channel's next turn. + +**Audit.** Every swap of a channel/organization/personal secret, every refusal, every blocked +destination and every raw tunnel is an `egress` event (names, hosts, reasons and byte counts — +never a value, a header or a placeholder). Ordinary requests, including the relayed Claude login's +swap on every API call, are only counted; `/api/health` → `containerRuntime.egress` shows whether +the proxy is up and how many channel listeners it holds. + +**When the proxy is down.** A boot that cannot load the CA or write the bundle logs one line +(`[egress] proxy unavailable (…)`) and carries on; every proxy-mode run, background job and SSH +container then fails before spawning with `egress proxy unavailable: … Restart the gateway to retry, +or … set Settings → Container runtime → Egress to "bridge"`. A down proxy never silently opens the +bridge. + +**The legacy escape.** *Legacy open network* (`containerEgressMode = "bridge"`) restores the +pre-proxy behavior for the whole gateway: containers on the default bridge network, raw secret +values in their environment, the switch advisory again. It exists for a host that cannot run the +proxy; leave it off otherwise. Switching it recreates each container at its next idle moment. **Admin channels run in containers too.** An admin author's live turn adds the engine's bypass flag; the channel's work folder is bind-mounted read-write like any other's. An admin channel @@ -587,24 +681,88 @@ per-channel or gateway-wide "back to the host" switch: the container is the only - **Per-user Codex skill grants are not delivered in containers.** The per-run Codex skill overlay was built for a synthetic host HOME that a container does not have; a Codex run gets the channel's skills through the mounted workdir, but not that overlay. -- **Codex sessions are per channel, but the sign-in is shared.** Every container mounts the same +- **Codex sessions are per channel; the sign-in is shared only outside the proxy.** Under the + legacy open-network mode (or with an API-key `auth.json`) every container mounts the same `auth.json` the gateway uses. A `codex login` on the host that *replaces* the file leaves a running container holding the old inode — `/status` and `/api/health` report the drift; restart - the channel's container (or let the reaper stop it) to pick the new one up. -- **A relayed access token is readable by the channel's own agent.** It rides the exec - environment, so an agent in that channel can print it. It cannot rotate anything (an access token - carries no refresh half) and it dies within hours, but it is a live credential for that window; - the P3 egress proxy replaces it with an opaque token. -- **Egress is not policed per channel.** Every container runs on the default bridge network, and - the per-channel *Allow network* switch does not cut it — it only tells the engines whether the - channel is meant to have network. The per-domain allow-list of the retired host sandbox has no - container equivalent; the container-side egress proxy that will enforce the switch is a later - slice. Because it is advisory, the switch is now STATED rather than inferred: the mode label - carries it in both directions (`Bash · network off`), `/mode` and `/status` add - "advisory — not enforced by the container yet", the gateway-managed block at the top of each - conversation's `CLAUDE.md` tells the engine the same thing, and every `run_config` event records - `networkEnforced: false` beside `networkPolicy`. `NETWORK_POLICY_ENFORCED` in - `src/engines/network-policy.js` is the single flag to flip when the proxy lands. + the channel's container (or let the reaper stop it) to pick the new one up. Behind the proxy the + login is relayed and a new `codex login` takes effect on the next turn. +- **The relayed Claude login is a placeholder in proxy mode.** A proxy-mode container's + `CLAUDE_CODE_OAUTH_TOKEN` is the channel's relay placeholder, swapped for the current access token + only on `api.anthropic.com` while the channel has live work. Under the legacy bridge mode it is + the real access token again, readable by the channel's own agent (it cannot rotate anything and + dies within hours). A daemon that authenticates Claude with its own `ANTHROPIC_API_KEY` still + passes that key through raw — the proxy does not rewrite it. +- **Engine keys that still reach a proxy-mode container raw.** A Qwen harness's provider key + (`ANTHROPIC_AUTH_TOKEN` pointed at the provider) and a daemon `CODEX_API_KEY`/`OPENAI_API_KEY` + handed to Codex are real values in the container environment; Codex's own sign-in is the shared + `auth.json` mount (the engine-login broker is a later phase). They are reachable only on their + engine endpoints through the proxy, but a process in the container can read them. +- **A self-hosted Qwen endpoint on a private address is refused in proxy mode.** The proxy never + connects to loopback, private, link-local or CGNAT addresses, and a configured Qwen base URL is no + exception: point the harness at a public endpoint, or run that channel under the legacy bridge + mode. +- **Egress is policed per channel by the proxy; the switch is advisory only where the proxy is not + the network.** Under `containerEgressMode = "bridge"` or a channel's `rawNetwork` escape the + container has the open bridge and *Allow network* only tells the engines the channel's intent; + every surface then says so — `/mode` and `/status` add "advisory — not enforced for this + container", the gateway-managed block at the top of each `CLAUDE.md` and the per-attempt note tell + the engine, and `run_config` records `networkEnforced: false` with `egress: "bridge"` or + `"proxy+raw"`. In proxy mode `run_config` records `networkEnforced: true`, `egress: "proxy"`, and + the names of any unprotected (`egressUnprotected`) or withheld (`egressWithheld`) secrets. + `networkEnforcedFor(target)` in `src/engines/network-policy.js` is the one question every surface + asks. +- **Codex's sign-in is a placeholder in proxy mode.** See **Codex login relay** below. What remains + raw: the legacy bridge mode's shared file, an API-key `auth.json`, and a daemon + `OPENAI_API_KEY`/`CODEX_API_KEY`, which reaches the container env as `CODEX_API_KEY` unchanged. +- **SSH and VS Code sessions** run in the same `--network none` container with the proxy env and + hold placeholders like a turn (container-secrets P3, `docs/SSH-ACCESS.md`); SSH `-L` forwards to + external hosts do not work without the network. + +**Codex login relay (container-secrets P4).** Behind the egress proxy a channel container never +sees the host's Codex login. Before each Codex run (and when an SSH session is prepared) the gateway +writes `/home/agent/.codex/auth.json` into the channel's own HOME volume: the access token is the +channel's relay placeholder shaped like a JWT (the real token's claims, `cgph_r…` in place of the +signature — the CLI reads the claims, the provider checks the signature), the refresh token is empty, +`last_refresh` is now. The proxy replaces that whole token with the host's current access token in +the `Authorization` header on `api.openai.com`, `chatgpt.com` and `auth.openai.com` only, while the +channel has live work. The container can use the login but cannot renew, rotate or take it anywhere: +sent elsewhere, or from another channel, it is worthless; a refresh attempt from inside is rejected +by OpenAI (empty refresh token). The host's login is renewed by the gateway itself: when its access +token (10-day lifetime) has less than 48 hours left, the next Codex run first spends one cheap +`codex exec --ephemeral --ignore-user-config` turn (`gpt-5.6-luna`, low effort) in the login's own +`CODEX_HOME` — the engine home when it holds a login, else `~/.codex` — exactly what your own shell +does. If the CLI declines to renew a still-valid token the gateway waits 6 hours before trying +again; a failed refresh logs `[codex-relay] host refresh turn exited …`. What to watch: a Codex turn +that fails with "This channel runs in a container, but … Run `codex login`" means the host is signed +out or its token expired and could not be renewed — run `codex login` on the host. The first Codex +turn after upgrading recreates each channel's container once (the mount is gone from its +fingerprint), and `cg-init` deletes an old copied Codex login carrying a refresh token from the +volume. The legacy open-network mode keeps the shared read-write mount (and says so in `/status`). + +**Remote MCP relay (container-secrets P1).** Composio (`composio-user`, `composio-agent` in token +mode), the MakeItFuture toolbox and the Make toolbox never reach a container with their token. The +engine's MCP entry is the image's socket bridge naming the server (`CG_MCP_SERVICE=remote-mcp`); +the run's signed capability lists those names, and the daemon keeps the real URL and header in +memory (never on disk, never logged) for no longer than the capability's lifetime — 6 hours for a +turn, 12 for an SSH session — and normally only while something uses it: a cold turn's grant ends +with the turn, a warm Claude process's when the process retires, an SSH session's at its end, and a +person clearing their Composio or Toolbox token drops every grant minted for their runs at once. On a `remote-mcp` hello the daemon checks the claim and its own registration, dials the +service (https only; Streamable HTTP, falling back to HTTP+SSE on a 4xx) and relays the tools. What +an operator sees: the artifact dir's `cg-mcp-*.json` and Codex's `run/cg-codex-secrets-*.json` hold +no Composio or toolbox token (Codex's bundle holds only the capability), and a relay that fails +shows in the engine's MCP log as one fixed line — `remote MCP is not authorized for this run` (the +grant expired or was revoked, or the daemon restarted since the run started: in-memory +registrations do not survive a restart, so the next turn simply registers again), `too many remote +MCP connections for this run` (more than 8 at once for one server on one grant) or `remote MCP +unavailable` (the service could not be reached; the upstream error is deliberately not repeated, +and a failed tool call reads `remote MCP request failed`). `the gateway service takes no arguments` +means the channel image predates spec 1.6.0. A +`COMPOSIO_MCP_URL`/`TOOLBOX_MCP_URL` override pointing at plain http cannot be relayed: container +runs skip that server and say so in the thread. The relay removes the credential from the box; the +egress proxy (above) is what polices where the box can connect. It needs image spec 1.6.0 +(`npm run build:image`), which also ships the egress forwarder. Direct-host `/sudo` +threads keep the old shape: the engine dials the service itself. **Claude login in containers:** with no `containerClaudeOauthToken`, each container Claude run receives a RELAY of the gateway's resolved login — normally the host user's own `~/.claude` — as a diff --git a/docs/PRIVACY-AND-DATA-FLOW.md b/docs/PRIVACY-AND-DATA-FLOW.md index 57aab876..5db468ce 100644 --- a/docs/PRIVACY-AND-DATA-FLOW.md +++ b/docs/PRIVACY-AND-DATA-FLOW.md @@ -88,20 +88,39 @@ The optional Full-access whole-home mount is off by default and deliberately exp user's repositories, gateway state and other channels to every author admitted to a Full-access channel. Operators must understand this exception before enabling it. -All containers currently use bridge networking. *Allow network* expresses intended engine policy; -it is not an egress firewall and does not constrain arbitrary destinations at the network layer. -Host-side run-API attachment/webhook requests separately validate and pin public destination IPs. +Channel containers run with no network of their own (`--network none`). Their only egress is the +daemon's per-channel egress proxy, which enforces *Allow network* on every request (off: the engine +endpoints and the channel's selected connectors only; on: public destinations only — private, +loopback and cloud-metadata addresses are always refused) and terminates TLS with a +deployment-local CA so it can swap placeholder credentials. The daemon therefore sees request +headers and URLs in the clear; it audits destinations and credential use (names, hosts, reasons, +byte counts — never a value, a header or a body) and does not store request or response bodies. +Response bodies of text types are scanned in memory to strip a swapped real value that an upstream +echoes back. The operator-selectable legacy mode (`containerEgressMode = "bridge"`) and a channel's +admin-granted `rawNetwork` escape restore an open bridge network, where *Allow network* is advisory +again and not an egress firewall. Host-side run-API attachment/webhook requests separately validate +and pin public destination IPs. Claude runs authenticate with a relay of the host user's own `claude` login — its short-lived access token, refreshed on the host; the credentials file is never copied or mounted — or with a configured `claude setup-token` or the daemon's `ANTHROPIC_API_KEY`. Codex sessions are per channel -while its host sign-in file is bind-mounted into the channel containers, so that sign-in is one -shared identity across channels. Either way the provider identity is organization-wide: that is a +while its host sign-in is relayed the same way behind the egress proxy (an access-only file with a +placeholder token and no refresh token in each channel's home; the host file itself is bind-mounted +only in the legacy open-network mode or for an API-key login), so that sign-in is one shared +identity across channels. Either way the provider identity is organization-wide: that is a shared provider identity, not an assertion of independent per-user billing. Container-native CLI credentials persist in the channel home. Personal/shared MCP credentials are resolved for each run and may be written into protected transient runtime bundles; those bundles must be included in the retention assessment. -Runtime secret redaction reduces accidental output leakage, but an agent given a usable credential -can access that value and use its granted privileges. UI write-only fields do not change this fact. +With the egress proxy active, an environment secret that has an egress rule (the built-in GitHub, +Vercel, Supabase, Make and Composio names, or an admin's "used on hosts") and the relayed Claude +login reach a container only as placeholders; the real value is resolved on the daemon at request +time and inserted only on the declared hosts, while the channel has live work. The placeholder is +recorded in the local `egress_grants` table with the secret's name and scope — never its value. +A secret without a rule is still injected raw (flagged "unprotected"), unless the operator withholds +such secrets. A member with a live placeholder can still use it for anything the real credential +allows on its declared hosts: scope the credential at the provider. Runtime secret redaction +reduces accidental output leakage, but an agent given a usable credential can use its granted +privileges. UI write-only fields do not change this fact. An operator's ability to sign in is not a grant to share that provider account with every gateway user. Provider account terms and any organization agreement determine who may use it. ChannelGate diff --git a/docs/SSH-ACCESS.md b/docs/SSH-ACCESS.md index e192e73b..f173fda7 100644 --- a/docs/SSH-ACCESS.md +++ b/docs/SSH-ACCESS.md @@ -135,19 +135,77 @@ session gets: are not loaded. - **The run environment:** the organization's, your own and the channel's secrets, by the names a turn is told about, sourced by the `claude` wrapper so Claude's tools have them. Your shell can - read them too (`env`), exactly as a turn's process can read its own. + read them too (`env`), exactly as a turn's process can read its own. Behind the egress proxy + (the default) they are **placeholders** — see "Network and secrets inside a session" below. - **The gateway's Claude login, shown as the account it is:** `/status` says "Claude Max account" with the operator's organization and email, `/usage` shows the plan's windows, and the default model is the plan's. It is the operator's login (the same one every turn uses): your session's usage counts against it. Claude reads the login from an access-only file the daemon writes and refreshes; it holds no refresh token and is removed when the channel's last session - ends. `claude -r ` resumes a thread's own session. + ends. Behind the egress proxy its access token is the channel's relay placeholder (the plan, + expiry and rate tier beside it are the real login's — they are facts, not secrets), so the file + is useless outside the container. `claude -r ` resumes a thread's own session. Codex over SSH is unchanged (its login is the shared sign-in mount). "show SSH access" in the channel prints what a session gets. If a part could not be prepared — no Claude login on the host, a channel whose Composio session is unavailable — the attach still succeeds and the daemon log names the part. +## Network and secrets inside a session (the egress proxy) + +Channel containers run with `--network none` and reach the outside only through the gateway's +egress proxy (image spec 1.6.0). An SSH session is inside that container, so the same rules apply: + +- **The proxy environment is set for you.** sshd starts every session with a clean environment, + so the daemon writes the complete proxy/CA set (`HTTP(S)_PROXY` → `http://127.0.0.1:3128`, + `NO_PROXY`, `NODE_USE_ENV_PROXY=1`, the CA-bundle variables → `/run/channelgate/egress-ca.pem`, + `CG_EGRESS=proxy`), Chromium's proxy arguments for your own `agent-browser` + (`AGENT_BROWSER_ARGS`) and `GIT_SSH_COMMAND` into the session's one `SetEnv` line, and again at + the top of the session's env file — so `. "$CG_SESSION_ENV"` (and `with-secrets`, and the + `claude`/`codex` wrappers) re-assert them if a shell changed them. `ALL_PROXY` is unset. The + channel's *Allow network* switch is enforced there: off, only the engine endpoints and the + channel's connectors answer (`curl` gets `403 network-off`); on, any public host. +- **Secrets are placeholders.** Every secret with an egress rule (the built-in GitHub, Vercel, + Supabase, Make and Composio names, or one an admin marked *Used on hosts*) is a `cgph_…` + placeholder in your environment. The proxy swaps in the real value only on that secret's hosts, + so `gh`, `vercel`, `git` over HTTPS and `curl -H "Authorization: Bearer $TOKEN"` work as usual and + `printenv` shows nothing worth copying out. A secret without a rule is still the raw value and + flagged *unprotected* (the session status and `session.md` name it); with *Withhold unprotected + secrets* on it is not in the session at all. +- **Your personal secrets pause while someone else is attached.** A personal placeholder swaps + only while its owner is working in the channel (a turn, a job or your own session) and **no other + person** has an SSH session open there. While one is, the proxy answers + `403 secret-refused … another-person-ssh-session` for your personal secrets — and for every other + member's personal secrets while you are attached — and each turn's credential note says + personal secrets are paused. Channel and organization secrets are not affected. +- **`-L` forwards to external hosts no longer work.** A local forward is dialled from inside the + container, which has no route: `ssh -L 5432:db.example.com:5432 acme-app` fails. Forwards to + container-local ports (`ssh -L 3000:localhost:3000 acme-app`) and every `-R` forward are + unchanged. Reach an external database through the channel's VPN/database helper, or ask an admin + to declare its host as a raw host (`egressRawHosts`, ports 22/5432/6543) and connect from inside. +- **Outbound SSH goes through a helper.** `git clone git@github.com:org/repo.git` works in a + session as is: `GIT_SSH_COMMAND` runs `ssh -o ProxyCommand='/opt/channelgate/bin/cg-egress-connect + %h %p'`, which asks the proxy for a raw tunnel. The proxy allows one only to `github.com:22` and + the channel's declared raw hosts, and only with *Allow network* on. For your own `ssh`, pass the + same option (`ssh -o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p' user@host`) or + add it to a config of your own — the gateway never writes the shared `~/.ssh`. The first + connection asks you to accept the host key as usual. The proxy **cannot inject an SSH key**: you + authenticate with a key you bring (agent forwarding is off, so it has to be in the box, where + everyone in the channel can read it — prefer a deploy key scoped to one repository), and a + destination-restricted key broker is a later item. +- **VS Code servers.** Client versions the image pre-installs need no download. For any other + version the Remote-SSH installer's `curl` goes through the proxy environment, so it needs *Allow + network* on; with it off, set `"remote.SSH.localServerDownload": "always"` on your laptop so the + client downloads the server and copies it in over the session. +- **The editor token file.** `/vscode/claude-token` (read by the `claude` wrapper + outside an SSH session, e.g. a `podman exec` shell or an operator's `npm run vscode` window) holds + the same relay placeholder, and is removed when the channel's last session ends as well as when + the `npm run vscode` launcher exits. An open `npm run vscode` window counts as live work for the + channel's own and organization secrets and the Claude login, never for anyone's personal ones. + +Under the legacy *Legacy open network* mode, a channel's `rawNetwork` escape or a `/sudo` thread +none of this applies: values are real, and the network is the container's own. + ## What the daemon checks on every connection - the presented key is registered (unknown keys are refused before anything else is looked up); @@ -180,13 +238,17 @@ ended); `show_channel_ssh` lists the live ones. container with a live session, and a container rebuild waits for the session like it waits for a run. `ClientAlive` inside the container reaps a dead TCP peer in about three minutes, so a laptop that vanished cannot pin a container forever. -- **A session has the channel's secrets and MCP tokens, like a turn does.** They sit in +- **A session has the channel's secrets, like a turn does — as placeholders.** They sit in per-developer files under the channel's artifact dir (`ssh/users//`), selected by the `CG_SSH_USER` name sshd sets from the developer's own key line and admits nothing else for. The files are per developer for correctness — your `composio-user` is yours — not for secrecy from - each other: everyone in the box is one uid, a shell can read a running turn's + each other: everyone in the box is one uid, and a shell can read a running turn's `/proc//environ`, the CLI logins in `/home/agent`, the Codex sign-in mount and the Claude - login file anyway. Grant SSH as you would grant a login to the project box. + login file anyway. Behind the egress proxy that is bounded: what anyone in the box can read is a + placeholder that only works from inside it, on its declared hosts, while the channel has live + work (a personal one only while its owner is here and nobody else is attached); the MCP tokens + are relayed by the daemon and never in the box at all. Unprotected secrets and the CLI logins in + `/home/agent` are still real. Grant SSH as you would grant a login to the project box. - **The Claude login file holds no refresh token.** It cannot rotate the operator's session or sign the host out; it expires with the access token and is rewritten by the 20-minute refresh. - **Claude never self-updates inside a channel** (`DISABLE_AUTOUPDATER=1` on every container): a @@ -203,7 +265,9 @@ ended); `show_channel_ssh` lists the live ones. | `/var/lib/channelgate-ssh/authorized_keys` | every registered key, `restrict,command=` (daemon-written) | | `/var/lib/channelgate-ssh/attach.sock` | the daemon's attach socket (0660, group = login account) | | `/etc/ssh/sshd_config.d/channelgate.conf` | the Match block for the login account, plus an `AllowUsers`/`AllowGroups` line when the host restricts logins | -| `///ssh/` | `sshd_config`, `authorized_keys`, `host_key` for the container (identical path inside) | +| `///ssh/` | `sshd_config` (its one `SetEnv` carries the container env + the proxy set), `authorized_keys`, `host_key` for the container (identical path inside) | +| `///ssh/users//` | one developer's `env` (proxy set first, then secrets — placeholders behind the proxy), `session.md`, `settings.json`, `mcp.json`, Codex's `codex-args.sh` + `codex-secrets.json` (the capability only) | +| `///vscode/claude-token` | the Claude login the wrapper reads outside an SSH session (the relay placeholder behind the proxy); removed with the channel's last session | | `gateway.db` → `ssh_keys`, `ssh_sessions`, `events` | keys, sessions, audit | - *`channelgate-ssh@…: Permission denied (publickey)`* straight away, with nothing in the daemon @@ -244,5 +308,12 @@ ended); `show_channel_ssh` lists the live ones. means a second gateway on this host is using the same attach directory. - *Host key changed* warnings — the channel's `ssh/host_key` was removed (a deleted channel artifact dir); remove the stale `known_hosts` line. +- *`403 secret-refused … another-person-ssh-session`* — someone else has an SSH session open in the + channel, so personal secrets are paused (above); it clears the moment they disconnect. + `channel-idle` means nothing is live in the channel — which a session itself is, so it only shows + up from a shell outside any session. +- *`cg-egress-connect: the gateway's egress proxy refused github.com:22 (403 Forbidden) — + network-off …`* — outbound SSH needs *Allow network* on; any other host must be a declared raw + host. `kex_exchange_identification: Connection closed` right after it is ssh's own echo of that. - *Connection drops on daemon restart* — expected; reconnect. - `journalctl -u channelgate | grep '\[ssh\]'` shows binds, sessions and relay refresh failures. diff --git a/docs/WHY.md b/docs/WHY.md index 75bfd7a5..202282e7 100644 --- a/docs/WHY.md +++ b/docs/WHY.md @@ -598,10 +598,10 @@ terse catalog of what exists lives in `FEATURES.md`; this is the argument for it - **What:** opt-in shell+writes inside the channel's container (which mounts only the channel's own folder — the gateway root, the operator's `.ssh`/`.aws`/keychain paths and other channels' folders are simply absent) and an opt-in network switch that tells the engines whether the - channel is meant to have network (enables git push / gh / deploy CLIs). Every container is on - the bridge network; the switch does no per-domain filtering and, in this release, no egress - cut-off — the boundary is filesystem and process isolation, with a container-side egress proxy - as the planned follow-up. + channel is meant to have network (enables git push / gh / deploy CLIs). Every container runs + with `--network none` behind the daemon's per-channel egress proxy, which enforces the switch on + every request (no per-domain filtering when it is on; private and metadata addresses always + refused) and swaps placeholder credentials for real ones only on their declared hosts. - **Value:** real development workflows (clone, edit, test, push) in channels that need them, while the daemon's own config and the host's secrets remain out of reach. - **Reason:** bash and network are the two big escape vectors; making each an explicit, separately diff --git a/public/admin-secrets.js b/public/admin-secrets.js index aba6df43..2b44e4f7 100644 --- a/public/admin-secrets.js +++ b/public/admin-secrets.js @@ -27,10 +27,11 @@ export function mountSecretEditor({ const nameInput = root.querySelector(".secret-name"); const valueInput = root.querySelector(".secret-value"); const saveButton = root.querySelector(".secret-save"); + const hostsInput = root.querySelector(".secret-hosts"); let current = Array.isArray(vars) ? vars : []; nameInput.placeholder = namePlaceholder; - for (const el of [nameInput, valueInput, saveButton]) el.disabled = disabled; + for (const el of [nameInput, valueInput, hostsInput, saveButton]) if (el) el.disabled = disabled; const render = () => { state.textContent = current.length ? `${current.length} set` : "none"; @@ -53,7 +54,12 @@ export function mountSecretEditor({ entry.setBy ? `set by ${entry.setBy}` : "", entry.setAt ? new Date(entry.setAt).toISOString().slice(0, 10) : "", ].filter(Boolean).join(" · "); - mask.textContent = `${entry.last4 ? `••••${entry.last4}` : "•••••••"}${trail ? ` · ${trail}` : ""}` + // Egress protection (src/gateway/egress/): a protected secret reaches a container only as a + // placeholder the proxy swaps on these hosts; an unprotected one is injected raw. + const egress = entry.protected === true + ? ` · protected via egress proxy (${(entry.hosts || []).join(", ")})` + : entry.protected === false ? " · unprotected (raw)" : ""; + mask.textContent = `${entry.last4 ? `••••${entry.last4}` : "•••••••"}${trail ? ` · ${trail}` : ""}${egress}` + (entry.resolvable === false ? ` · ⚠️ provider "${entry.provider}" can't be resolved by this build` : ""); row.append(name, mask); if (!disabled) { @@ -108,10 +114,13 @@ export function mountSecretEditor({ saveButton.disabled = true; hint.textContent = "saving…"; try { - const result = await api(endpoint(name), { method: "PUT", body: JSON.stringify({ value }) }); + // "Used on hosts": sent only when typed, so rotating a value keeps the stored rule. + const hosts = hostsInput ? hostsInput.value.trim() : ""; + const result = await api(endpoint(name), { method: "PUT", body: JSON.stringify(hosts ? { value, hosts } : { value }) }); current = result.vars || []; valueInput.value = ""; nameInput.value = ""; + if (hostsInput) hostsInput.value = ""; hint.textContent = savedText(name); render(); } catch (e) { @@ -133,6 +142,7 @@ export function secretEditorMarkup() {
+
`; diff --git a/public/app.js b/public/app.js index 39e6c7bb..36377d6a 100644 --- a/public/app.js +++ b/public/app.js @@ -3505,6 +3505,8 @@ function readSettingsForm() { containerMemory: document.getElementById("set-container-memory").value, containerCpus: document.getElementById("set-container-cpus").value, containerFullAccessHome: document.getElementById("set-container-full-access-home").checked, + containerEgressMode: document.getElementById("set-container-egress-bridge").checked ? "bridge" : "proxy", + containerEgressSecretsStrict: document.getElementById("set-container-egress-strict").checked, // Write-only: send a value only when one was typed; "clear" arms an explicit removal. ...(tokenValue(document.getElementById("set-container-claude-token")) ? { containerClaudeOauthToken: tokenValue(document.getElementById("set-container-claude-token")) } : {}), ...(document.getElementById("clear-container-claude-token").classList.contains("armed") ? { clearContainerClaudeOauthToken: true } : {}), @@ -3636,6 +3638,8 @@ function paintSettings(s) { document.getElementById("set-container-memory").value = s.containerMemory || ""; document.getElementById("set-container-cpus").value = s.containerCpus || ""; document.getElementById("set-container-full-access-home").checked = s.containerFullAccessHome === true; + document.getElementById("set-container-egress-bridge").checked = s.containerEgressMode === "bridge"; + document.getElementById("set-container-egress-strict").checked = s.containerEgressSecretsStrict === true; document.getElementById("container-token-state").textContent = tokenState(s.hasContainerClaudeOauthToken, s.containerClaudeOauthTokenLast4); attachReveal(document.getElementById("set-container-claude-token"), { has: s.hasContainerClaudeOauthToken, last4: s.containerClaudeOauthTokenLast4 || "", fetch: revealSecret("settings", "containerClaudeOauthToken") }); document.getElementById("set-adminpw").dataset.hasPassword = String(s.hasAdminPassword === true); diff --git a/public/index.html b/public/index.html index 5c7e1006..20bf749f 100644 --- a/public/index.html +++ b/public/index.html @@ -932,6 +932,8 @@

Container runtime

+ +