Container secrets: MCP relay, egress proxy with placeholders, SSH sessions, Codex login broker (P1–P4) - #113
Merged
Merged
Conversation
The gateway capability gains an optional `remoteMcps` claim (server names only, at most 16) and accepts a caller-fixed `jti`. A new in-memory registry keyed by that jti holds the real URL and headers of each relayed server for exactly the capability's lifetime, so a containerized run can be handed a name instead of a credential. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
A new `remote-mcp` service on the control socket: the hello names a server, the daemon checks the capability's signed `remoteMcps` claim AND its in-memory registration under the capability's jti, then dials the real URL with the real headers (Streamable HTTP, HTTP+SSE on a 4xx) and relays tools/list and tools/call, re-authorizing each and forwarding cancellation and progress. Refusals are fixed sentences that quote nothing upstream. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
On an isolated target the header-bearing remotes (composio-user and composio-agent in token/endpoint mode, makeitfuture-toolbox, make-toolbox) become socket-bridge entries selecting the `remote-mcp` service. The capability is minted with a fixed jti and the `remoteMcps` names, and the real URL + headers are registered daemon-side under that jti, so the cg-mcp-*.json in the artifact dir carries no credential. Host targets keep the exact direct http entries. The warm-pool fingerprint gains a value-free digest of each relayed URL and header, so a rotated token still retires the warm process, and it now normalizes the capability on every socket-bridged entry, not only the gateway's. A remote whose URL the relay cannot dial (a non-https override) is dropped from an isolated run and reported, never handed over. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…capability In a container, Codex's composio-user/-agent (token and endpoint mode), makeitfuture-toolbox and make-toolbox entries become the same secret-env-bridge -> socket-bridge chain as the gateway entry, selecting the `remote-mcp` service and naming the server. The per-run 0600 bundle then carries only `gatewayCapability` and no headers helper is written, so nothing in the container's artifact dir holds a remote MCP credential. The secret-env-bridge forwards CG_MCP_SERVICE and CG_MCP_SOCKET to the bridge it launches. Sudo-host turns keep the native transport with headers helpers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…x bundle An SSH session's mcp.json already comes from the turn's assembler with the container target, so Composio and the toolboxes are now relayed there too. Its Codex bundle holds only `gatewayCapability` and no headers helper is written; the relay registrations ride the session capability's 12-hour lifetime and a refresh registers under a fresh jti. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
The image's socket bridge and secret-env-bridge now carry the `remote-mcp` service; an older image's broker would drop the service selection, so the daemon must ask for a rebuild. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
The pure core of the P2 egress proxy. A container will hold cgph_ placeholders instead of real credentials; rules.js swaps one for its real value only on the hosts, headers and header positions its grant declares (bearer, raw, Basic user/password, listed query parameters) and while canUse says yes, never partially and never across a Host-header mismatch. scrub.js streams text responses and puts an echoed real value back to its placeholder, holding back only a possible value prefix so SSE stays live. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…dresses Resolves every address of a destination and refuses it when any one is loopback, private, link-local (incl. cloud metadata), CGNAT, multicast or an IPv4-mapped form of those, reusing src/web/ip-policy.js; the first address is pinned for the connect so DNS rebinding cannot move it. Mode off admits only engine hosts, mode on admits all public hosts or an allowlist, and rawPassthrough host:port rules mark plain tunnels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…ss CA Node parses certificates but cannot issue them and we take no new runtime dependency, so x509.js is a minimal DER encoder for exactly two shapes: an ECDSA P-256 CA (basicConstraints CA:true pathLen 0, keyCertSign and cRLSign) and serverAuth leaves with a dNSName or iPAddress SAN and SKI/AKI, signed with ecdsa-with-SHA256. The leaf's issuer is copied byte for byte from the CA subject. Tests prove a verified TLS handshake with only the CA trusted, and openssl verify agrees. ca.js creates the CA once under a 0700 dir (ca.key 0600, ca.pem 0644), repairs modes on load, refuses a mismatched key instead of re-keying, and mints 24 h leaves on demand into a 512-entry LRU, renewed an hour early. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
FEATURES and the socket protocol bullet name the `remote-mcp` service, TEST-PLAN gains the regression cases and the unexecuted live gate (Composio through the relay on Claude and Codex with the artifact dir grepped for tokens), CHANGELOG notes the user-visible change and the image rebuild, OPERATIONS explains what an operator sees, and the stale remote-secret-bridge header no longer claims its bundle sits under a sandbox-denied gateway root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…only Never derive the refusal text from the thrown error, so a future change to the authorization path cannot leak detail into a container. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
createEgressProxy() serves HTTP/1.1 proxy connections handed in by a per-channel Unix-socket listener (serveEgressConnection(socket, ctx)). CONNECT runs the destination policy: refused answers 403 with a JSON category, rawPassthrough rules get a plain tunnel to the pinned address, everything else is TLS-terminated with a leaf from the deployment CA and parsed by an internal http.Server. Each request is forwarded over https to the pinned address with servername set to the CONNECT host, headers and listed query parameters swapped, bodies streamed unbuffered, and an uncompressed text response scrubbed when a grant was swapped. WebSocket upgrades are swapped, then piped. Absolute-form http:// requests swap only grants that opt into plainHttp. A canUse refusal answers 403 naming the secret; a placeholder on an undeclared host is forwarded unchanged. One audit event per request or tunnel carries names, counts and reasons, never a value. Every socket is tracked and close() destroys them all. policyFor, resolveGrant and canUse are callbacks, so the wiring slice supplies channel policy and grants without this module touching the DB. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…osio-sdk service In a container, Codex in Composio SDK mode launched the image's socket bridge bare: no CG_MCP_SERVICE and no capability, so the daemon's composio-sdk service refused every such run. The entry now goes through the same secret-env-bridge -> cg-mcp-bridge chain as the relayed remotes, with CG_MCP_SERVICE="composio-sdk" and the session URL as the trailing argument; without a secret bundle (no capability) no entry is emitted. The sudo-host path is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Registrations now carry {channelId, slug, authorId, origin} and holds:
the minting caller holds one and every open relay connection another,
and the grant goes with the last hold. run.js releases its primary and
fallback grants in the turn's finally, so a cold turn's grant ends with
it and a warm Claude process keeps its own exactly as long as its bridge
connections stay open. An SSH refresh releases the previous
preparation's grants and the developer's last session 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.
Each relayed server is validated on its own before registration: an
invalid URL or header drops and announces only that server instead of
failing the container turn, and Codex skips the same server.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
The socket's close listener is attached before the upstream dial, and whichever of hang-up and dial-settled comes second tears the relay down, so an upstream client is never left open after its container is gone. A relay slot is held until the dial settles, at most 8 per grant and server. Hello fields are read with typeof checks, a handshake error can no longer become an unhandled rejection, and a gateway hello carrying arguments (a pre-1.6.0 image's broker dropped CG_MCP_SERVICE) is refused with the rebuild remedy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
A JSON-RPC error the remote answered with passes unchanged; anything else (the SDK's "Error POSTing to endpoint: <body>", DNS failures) becomes "remote MCP request failed". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…talog The data plane the egress proxy swaps against. Migration 30 adds egress_grants: placeholder -> (scope, channel, owner, secret name), never a value. grants.js mints one stable placeholder per key, resolves the CURRENT value live from the store that owns it (organization settings, channel meta, the user row, the Claude relay cached 60 s), revokes a removed secret's row, and wraps a spawn's env resolution (resolveEgressRunEnv): ruled names become placeholders, unruled ones stay raw and are flagged unprotected, or are withheld under strict mode, with every real value kept for the redactor. containerClaudeCredential hands a proxy-mode container the channel's relay placeholder in the Anthropic token shape. catalog-rules.js holds the built-in rules (GitHub, Vercel, Supabase, Make, Composio; the CLI catalog gains a swap block per integration) and validates an entry's own "used on hosts" declaration, which channel-env now stores, keeps across a value rotation and lists as protected/hosts. The name families are credential names only: GH_REPO, VERCEL_ORG_ID and SUPABASE_DB_PASSWORD stay raw. liveness.js records live turns, jobs and reviews per channel and reads SSH sessions from the broker. The container backend gets a hook module (egress-hook.js) the daemon's service registers with, and egress-env.js is the one list of proxy/CA variables, all of them reserved names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
A proxy-mode channel container (the new default, containerEgressMode
"proxy") has no network of its own. Its only way out is the daemon's
egress proxy over a per-channel unix socket under <root>/eg/<12 hex>/,
bind-mounted read-only at /run/channelgate/egress (deliberately not under
the run dir every container mounts), plus the CA trust bundle at
/run/channelgate/egress-ca.pem. cg-init starts the in-image forwarder
(src/mcp/egress-forwarder.js, staged as bin/cg-egress.mjs) on
127.0.0.1:3128 under CG_EGRESS=proxy; one helper sets the proxy and CA
variables at create, forced into every exec env-file and applied last in
the Claude and Codex env; Chromium gets --proxy-server plus the CA's SPKI
pin through AGENT_BROWSER_ARGS.
The service (src/gateway/egress/service.js) boots right after the control
socket: CA, trust bundle written in place, proxy, backend hook, and a
config-change listener that revokes the placeholders of removed secrets.
Its policy reads the channel's current meta per request (Allow network
off = engine endpoints and the channel's selected remote MCPs only;
github.com:22 plus admin-declared egressRawHosts as raw tunnels when on),
its swap gate is channel binding plus liveness (turns, jobs, reviews and
SSH sessions mark it), and it audits only non-relay swaps, refusals,
blocks and tunnels. A service that cannot start logs one line; proxy-mode
runs, jobs and SSH containers then fail closed naming the remedy.
run.js, background jobs and the memory reviewer resolve secrets through
the grants wrapper and hand Claude the relay placeholder; the credential
preamble says which names are placeholders and where they work, which are
raw, which were withheld; the redactor keeps every real value. The
network switch is enforced (networkEnforcedFor) except under the legacy
bridge mode or a channel's rawNetwork escape, and every surface says so.
list_secrets, set_secret (hosts/headers/format), the admin secrets editor
("Used on hosts") and the admin settings (Egress mode, strict secrets)
expose the protection. Tests pinning the old bridge contract now pin the
new one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
FEATURES gains the proxy core (which its own commit deliberately left for this one), the --network none container contract and the placeholder model, and the old "Allow Network is advisory" entries now say where that is still true. TEST-PLAN gains the regression cases for the core and the integration and eight UNEXECUTED live gates (no podman on the development host): engine turns on both engines, gh/git/vercel with placeholders, agent-browser over HTTPS, the bypass proof, an unswapped placeholder on a foreign host, live rotation and revocation, the live switch and fail-closed boot, and the legacy escape. CHANGELOG, OPERATIONS (network, CA trust bundle, secrets through the proxy, audit, the legacy escape, raw hosts), PRIVACY, README, WHY, the engine and OpenCode notes, the contributor contract (the confinement rule, the src/gateway/egress map, migration 30) and the operating-manual and channelgate skill pages follow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
… cap, hardened hellos) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A --network none container has no route for an ssh client. The new image helper /opt/channelgate/bin/cg-egress-connect (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 on 127.0.0.1:3128 and pipes stdio through the raw tunnel the proxy allows (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; an unreachable forwarder names the error. The proxy cannot inject an SSH key. Image spec stays 1.6.0 (unreleased, same release as the forwarder). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
A developer's SSH session now resolves its environment through
resolveEgressRunEnv like a turn: behind the egress proxy the session env
file exports placeholders for every ruled secret (unruled names stay raw
and flagged, or are withheld in strict mode), and result.secrets reports
{ name, scope, protected }. Claude's access-only login file and the
host-side vscode/claude-token hold the channel's relay placeholder with
the real login's plan facts; the token file is also removed when the
channel's last SSH session ends.
sshd starts a clean environment, so the full egressEnv(target) map plus
AGENT_BROWSER_ARGS and a gateway-owned GIT_SSH_COMMAND through
cg-egress-connect ride the one SetEnv line and lead the session env file
(after unsetting ALL_PROXY). The session note names the proxy, the CA,
the protected/unprotected/withheld names, the personal pause rule and the
ProxyCommand, never a value.
resolveEgressRunEnv now reports personalPaused when the author holds
personal placeholders while another person's SSH session is open
(liveness reads the broker's live sessions), and the per-attempt
credential note says those secrets are paused. An operator's npm run
vscode window keeps non-personal grants swapping through its editor
lease, since that launcher runs in its own process.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
SSH-ACCESS gains the network-and-secrets section: the proxy env inside a session, placeholders, the personal-scope pause rule, -L forwards to external hosts no longer working under --network none (container-local -L and -R unchanged), VS Code server downloads through the proxy env, outbound SSH through cg-egress-connect and the fact that the proxy cannot inject a key. FEATURES, CHANGELOG and the agent-facing administration page follow. TEST-PLAN records the regression cases and seven UNEXECUTED live gates (no podman on the development host). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Review fixes to the proxy core: - An absolute-form request line inside a TLS tunnel (`GET https://other/`) is refused with 400 instead of being forwarded as the upstream path, a request with no Host header is never swapped into, and the upstream always receives the CONNECT/URL host as Host. - A Host header naming another host than the CONNECT or absolute-URL host is refused with 403 host-mismatch (no domain fronting past the policy). - A swapped request asks for accept-encoding: identity; response header values, bodies without a Content-Type and non-101 answers to an Upgrade (now sent through Node's HTTP client) are scrubbed too. Declared binary types still pass through. - DNS lookups race a 5 s deadline (504 dns-timeout), at most 16 policy decisions run per channel at once (503 too-many-lookups), and an upstream gets 60 s after the request was sent to answer (504). - Upstream TLS is verified with rejectUnauthorized against Node's roots plus the host bundle, built once; certificate failures classify as upstream-tls. - An other-channel refusal no longer names the secret. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…teners at boot Review fixes to the integration: - A personal placeholder is refused (another-author-active) while any other person's turn, background job or SSH session is live in the channel, not only another person's SSH session. The memory reviewer marks the channel live with no owner, and the credential preamble says personal placeholders can pause. - At boot the service re-binds the listener of every proxy-mode container that kept running across the restart. Chosen over hooking background-job recovery because jobs are not the only survivors: an attached editor and processes left from SSH sessions need the network back too, and none of them passes through a turn. - Runs reconcile grants by the STORED entry names, so a secret that briefly resolves empty keeps its placeholder instead of being revoked while warm processes still hold it. - The network mode is part of the mount fingerprint, so clearing rawNetwork is never deferred behind running work. - A channel listener is rebound when the channel id behind its slug changes, a close waits for an in-flight bind, and each channel socket accepts at most 256 connections. - The forwarder keeps half-open connections (raw tunnels are no longer truncated when the client half-closes) and cg-init logs its failures to /run/cg/egress.log. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
OPERATIONS and FEATURES describe request pinning, the scrub and deadline rules, the boot restore and the wider personal-secret pause, and list the limits: a Qwen provider key and a daemon CODEX_API_KEY still reach proxy-mode containers raw, and a self-hosted Qwen endpoint on a private address is refused. The secrets editor, set_secret and the operating manual warn against multi-tenant host suffixes such as *.vercel.app. TEST-PLAN gains the regression cases and two unexecuted live gates (restart restore, personal pause). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…proxy Until now every channel container bind-mounted the operator's REAL ~/.codex/auth.json read-write: one channel could read the refresh token, and every channel's sign-in was the same file. Behind the egress proxy Codex is now relayed like Claude: - placeholders.js gains a JWT shape (the real token's header and claims, the core `cgph_r…` as the signature segment, since codex-cli rejects a non-JWT) and rules.js a `jwt` format that replaces the WHOLE token; a jwt grant refuses a bare placeholder and a bearer grant a JWT one. - src/gateway/codex-token-relay.js (the claude-token-relay twin) reads the login the CLI would use, and renews it with one cheap ephemeral `codex exec` turn in the login's own CODEX_HOME under a keyed lock when the access token has < 48 h left, backing off when the CLI declines to renew a still-valid token. - grants.js: the relay scope is keyed by secret name; CODEX_ACCESS_TOKEN swaps on api.openai.com, chatgpt.com and auth.openai.com in the Authorization header only. containerCodexCredential renders the container's access-only auth.json (placeholder token, id_token claims without a signature, EMPTY refresh token, last_refresh now). - credentials.js: Codex mode "relay" when egress is active and the login is a ChatGPT sign-in; the codex-auth mount is dropped (the fingerprint change recreates each container once). Bridge mode and an API-key auth.json keep the shared file (the documented remaining exposure). - The runner writes the file before every Codex spawn through a new optional backend method, writeHomeFile: staged 0600 through the artifact dir, renamed into place inside the HOME volume, and refused when the destination is still a mount. SSH sessions place it too. - cg-init removes, under the proxy, a leftover Codex login that carries a refresh token (an old copy); a mounted file is never touched. Spike (codex-cli 0.156.1): an access-only auth.json runs a turn; a 16-day-old last_refresh triggers no refresh; on a 401 the CLI calls the refresh endpoint, which rejects the empty refresh token; the JWT placeholder passes `codex login status` and a proxied turn (WebSocket included) with the swap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
containerEgressSecretsStrict now defaults ON: a secret with no egress rule is withheld from proxy-mode containers instead of injected raw. The getter reads an absent key as strict, and server.js pins the value once at boot (after the first-boot admin password, from the "operator configured?" answer read before it): a brand-new install stores true, an install that existed before this default stores false, so an upgrade never silently withholds a secret a working channel relies on. A stored choice is never touched. Unreadable settings fail closed. list_secrets reports the remaining unruled secrets as a FINDING line (names, what happens to them under the setting in force, and how to declare hosts), and its footnote follows the strict setting. The settings UI text says an unruled secret is withheld and how to add hosts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Since the remote-MCP relay (container-secrets P1) nothing launches a header-bearing remote MCP through the stdio bridge: a container relays it over the control socket and a host run dials it natively. The bridge, its "mcp-remote" entry in HELPER_COMMANDS and in the host and image helper tables, its BUNDLE_ENTRIES line and the mcp-remote bundle extra package are removed; the helper test now pins that the name is unknown. The image keeps installing the pinned mcp-remote globally as a tool (containers/ is unchanged by this commit). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…act dirs The artifact dir is bind-mounted into the channel container, so every member's turn can read what the daemon writes there. check:static gains a deliberately simple AST rule (scripts/static-secret-writes.mjs, src/ only): a name is artifact-derived when its declaration or assignment mentions artifactDir/runArtifactRoot/sshUserDir/sshUsersDir/ channelArtifactDir or another derived name (to a fixpoint); a writeFile*/appendFile*/writePrivate/writeSecretFile call whose path is artifact-derived is flagged when its content mentions composioUserToken, composioToken, toolboxToken, makeToolboxKey, relay.token, resolvedRunEnv or realValues. A reviewed exception carries `static-check: allow-secret-artifact-write <why>` on one of the two lines above: the VS Code token file, which holds the relay PLACEHOLDER behind the proxy (a real access token only in the legacy bridge mode, where every turn's env holds it anyway). Fixture tests prove a violation is caught through derived names and that the tree is clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
OPERATIONS gains the Codex login relay section (the access-only file, the JWT-shaped placeholder, the swap hosts, the cheap-turn refresh and its back-off, what an operator sees) and the strict default's new-install/upgrade rule; its caveats now say where the shared Codex file remains (legacy bridge, API-key login) and that SSH sessions hold placeholders. ENGINE-CAPABILITIES gains a container-login row, COMPATIBILITY records the three Codex 0.156.1 behaviors the relay relies on. FEATURES, CHANGELOG, INSTALL and the privacy note follow. AGENTS.md: the Codex twin sentence in the "operator's own Claude login" rule, the strict default in the secrets rule, and the module map gains codex-token-relay.js, containerCodexCredential, egress-connect.js and static-secret-writes.mjs (remote-secret-bridge.js is gone). TEST-PLAN records the spike results verbatim, the regression cases and nine UNEXECUTED live gates (no podman on the development host). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…ranch Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…le for Codex with the network off Observed on the 2026-09-26 codex-cli 0.156.1 spike turn; the login relay still swaps only on the three API hosts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Container secrets, MCP relay, egress proxy, SSH sessions and the Codex login broker
Implements all four phases of
docs/CONTAINER-SECRETS-PLAN.md(branchdocs/container-secrets-network) as one stack:feat/mcp-relay(P1) →feat/egress-proxy(P2) →feat/ssh-placeholders(P3) →feat/engine-auth-broker(P4, this PR). 33 signed-off commits, 131 files.Design in one paragraph
A channel container never holds a real credential. Header-bearing remote MCPs (Composio user/agent, toolbox, Make) are relayed by the daemon over the existing control socket, keyed by the signed run capability (P1). Ordinary containers run
--network none; egress exists only through a per-channel Unix socket to the daemon's TLS-terminating proxy (src/gateway/egress/), which swapscgph_…placeholders for the live value only on each secret's declared hosts and headers, refuses private/metadata destinations, enforces the Allow-network switch live, scrubs responses and audits swaps (P2). SSH and VS Code sessions get the same placeholders, the proxy env viaSetEnv, and acg-egress-connectProxyCommand helper for outbound SSH (P3). Codex's realauth.jsonis no longer mounted: containers get an access-only file whose bearer is a placeholder JWT swapped by the proxy; a daemon relay owns refresh, unruled secrets are withheld by default on new installs, and a static check refuses secret-resolver values written under artifact dirs (P4). Oneagentuid per container; no per-developer OS users. Personal secrets pause while another author's turn, job or SSH session is active in the channel.Reviews folded in
Two read-only reviews (P1, P2) found 6 + 13 defects; all fixed with regression tests (grant release with holds and a dial cap on the relay; absolute-form request-line host escape; Host-header fronting; personal-secret concurrency; scrub bypasses; DNS/connection/response-header bounds; listener restore at boot; explicit upstream
rejectUnauthorized).Checks (final stacked HEAD)
Live evidence executed here (no container CLI on this host)
CLAUDE_CODE_OAUTH_TOKENreaches the API (401);HTTPS_PROXYhonoured.auth.jsonsucceeds; an oldlast_refreshtriggers no refresh; a placeholder JWT (real header+claims,cgph_r…signature) passescodex login statusand completes a real turn THROUGH this branch's proxy with 23 header swaps (incl. the WebSocket turn), none toauth.openai.com.openssl verify.UNEXECUTED live gates — must run before merge (TEST-PLAN.md, 48 markers)
Requires a Linux host with rootless Podman and
npm run build:image(spec 1.6.0; containers are recreated once). Per phase: Composio through the relay on Claude and Codex; engine turns under--network none;gh api,git cloneover HTTPS andvercel whoamiwith placeholders; agent-browser on HTTPS (whether agent-browser 0.36.0 honoursAGENT_BROWSER_ARGSis unverified); bypass proof from inside a container (curl --noproxy '*',getent hosts,/proc/net/devonlylo, bridge gateway and metadata refused); placeholder unswapped on an undeclared host; live rotation and revocation; network switch flip; fail-closed when the service is down; legacycontainerEgressMode=bridgeboth ways; restart restore; personal pause (SSH and another author); SSH session gates (7); Codex gates (9, incl. Codex with network OFF and the 48 h refresh path).Rollout notes
npm run build:imageafter merge; every existing container recreates once (network + mount fingerprint changed).containerEgressMode(proxydefault,bridgelegacy escape),egressSecretsStrict(on for NEW installs only). Per-channel admin meta:rawNetwork,egressRawHosts(API only, no UI yet).egress_grants).ANTHROPIC_API_KEY/OPENAI_API_KEY/CODEX_API_KEY, the Qwen provider key, unruled secrets on upgraded installs, Postgres passwords (raw TCP).🤖 Generated with Claude Code