diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a65fe38..da5fe45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog — ChannelGate +- Accept public IPv4/IPv6 destinations in SSH approval cards and raw tunnels, fixing IP-only customer connections. + - Show host skill folders missing `SKILL.md` in the library with an issue badge, path, personal grant owners and a missing-file filter. - Let authorized channel members with write access approve public SSH destinations on a durable card, enabling container SSH without `/sudo`. diff --git a/FEATURES.md b/FEATURES.md index 4531ddb9..9a97109a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,7 @@ # ChannelGate — Features +- SSH egress approval accepts exact public IPv4 and IPv6 addresses as well as DNS hostnames. Approved literals are retained by the proxy’s raw-tunnel filter; private/loopback/metadata addresses remain blocked, and secret-host declarations remain DNS-only. + - The admin skill library includes read-only diagnostics for host folders without a root `SKILL.md`, including folder paths and personal grant owners. Skill file status filters isolate missing files. Native plugins remain valid, and diagnostic rows cannot be enabled or assigned as catalog skills. - `allow_channel_ssh_host` posts a durable approval for one public SSH destination. Any currently authorized channel member with write access can approve it; no admin or `/sudo` is needed. Approval appends to this channel’s `egressRawHosts` without replacing other entries. Cards disclose supported TCP ports 22, 5432 and 6543; Auto never approves them, and network-off/private-address protections remain enforced. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 231e7e04..83e31df1 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -11,7 +11,7 @@ - Automated: `test/egress-hosts.test.js` covers exact public destinations, denied private/malformed hosts, current worker authority, network/mode changes, and atomic append. `test/mcp-control-plane-approval.test.js` exercises real MCP tools in Claude/Codex contexts, pending durable cards in Auto mode, no mutation before approval, member approval and live authorization. Existing durable-approval and approval-link suites cover restart persistence, replay and single-use decisions. - Live Claude and Codex acceptance (pending): in `cg-testing-claude-bash` and `cg-testing-codex-bash`, use an approved non-admin member and a disposable public SSH fixture with a verified host key and existing test key. Ensure Network on, Worker mode and no existing host entry. Ask “Connect to the test SSH server; request approval if it is not allowed.” Require `allow_channel_ssh_host`, a card naming the host and TCP 22/5432/6543, and no append before the human click. Approve as a second authorized non-admin member; require only that host appended, unrelated settings preserved, and successful SSH via `cg-egress-connect` without `/sudo`. Repeat in Auto mode: still requires a click. -- Deny a fresh destination and verify no append. Request another destination, restart the disposable gateway, then approve the saved card once; replay must not execute twice. Revoke requester/approver access, switch to Read-only or turn Network off before approval: no grant. Private IP, wildcard and URL requests must refuse without posting a card. Remove fixture grants/keys after testing. No live pass is implied by automated results. +- Deny a fresh destination and verify no append. Request another destination, restart the disposable gateway, then approve the saved card once; replay must not execute twice. Revoke requester/approver access, switch to Read-only or turn Network off before approval: no grant. Public IPv4 and IPv6 literals must create approval cards and tunnel SSH bytes without TLS after approval; IP destinations require no DNS lookup. Private IP, wildcard and URL requests must refuse without posting a card. Remove fixture grants/keys after testing. No live pass is implied by automated results. ## Invalid skill-folder grants (2026-11-04) diff --git a/documentation/src/content/docs/controls/ssh.md b/documentation/src/content/docs/controls/ssh.md index 84662ec3..46df2d03 100644 --- a/documentation/src/content/docs/controls/ssh.md +++ b/documentation/src/content/docs/controls/ssh.md @@ -84,7 +84,7 @@ SSH gives a person a full interactive shell inside a conversation’s shared con ## allow_channel_ssh_host -**Required:** `host` (one exact public DNS hostname, without a URL, wildcard or port). +**Required:** `host` (one exact public DNS hostname or IPv4/IPv6 address, without a URL, wildcard or port). ```json { @@ -92,6 +92,6 @@ SSH gives a person a full interactive shell inside a conversation’s shared con } ``` -**Scope and result:** Requests a durable human approval card for outbound SSH from this conversation's container. Any currently authorized conversation member with write access may approve; manager/admin rights and `/sudo` are not required. The gateway appends the approved hostname to this conversation's `egressRawHosts`, preserving other entries and settings. An existing exact entry needs no additional card. +**Scope and result:** Requests a durable human approval card for outbound SSH from this conversation's container. Any currently authorized conversation member with write access may approve; manager/admin rights and `/sudo` are not required. The gateway appends the approved hostname or IP address to this conversation's `egressRawHosts`, preserving other entries and settings. An existing exact entry needs no additional card. **Limits:** Network must be on and the conversation must permit writes. Auto mode never approves the card. The saved request survives restarts and applies once; the requester and approver must still be authorized when it is decided. The card discloses that the existing egress list permits raw TCP ports 22, 5432 and 6543. Private, loopback and metadata addresses remain blocked, including after DNS changes. Approval supplies no SSH key or secret permission. Once approved, retry SSH with the existing key and `-o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p'`. diff --git a/src/gateway/egress-hosts.js b/src/gateway/egress-hosts.js index 9f85c732..89a50e6e 100644 --- a/src/gateway/egress-hosts.js +++ b/src/gateway/egress-hosts.js @@ -5,7 +5,7 @@ import { logChannelPolicyChange } from "../config/channel-audit.js"; import { channelMode, isAuthorized } from "./modes.js"; import { checkDestination, isValidDestinationHost } from "./egress/policy.js"; import { normalizeHost } from "./egress/rules.js"; -import { isValidRuleHost, assertValidEgressRawHosts } from "./egress/catalog-rules.js"; +import { assertValidEgressRawHosts } from "./egress/catalog-rules.js"; export async function isChannelWorker(channelId, slug, userId) { if (!userId) return false; @@ -24,7 +24,7 @@ export async function prepareEgressHost(ctx, value, { lookup } = {}) { const meta = await getChannelMeta(ctx.slug); if (meta.allowNetwork !== true) throw new Error("Network access is off for this channel."); const host = normalizeHost(value); - if (!isValidDestinationHost(host) || !isValidRuleHost(host)) throw new Error("Use one exact hostname, without wildcards, a URL, or a port."); + if (!isValidDestinationHost(host)) throw new Error("Use one exact hostname or public IP address, without wildcards, a URL, or a port."); const result = await checkDestination({ hostname: host, port: 22, mode: "on", rawPassthrough: [{ host, port: 22 }], lookup }); if (!result.ok) throw new Error(result.reason); assertValidEgressRawHosts([...(meta.egressRawHosts || []), host]); diff --git a/src/gateway/egress/catalog-rules.js b/src/gateway/egress/catalog-rules.js index 8c7808dd..9f36cbef 100644 --- a/src/gateway/egress/catalog-rules.js +++ b/src/gateway/egress/catalog-rules.js @@ -15,8 +15,11 @@ // list (bearer = ` `, raw = the whole value, basic-password / basic-user = one half // of `Basic base64(user:password)`). // -// PURE: imports only the CLI catalog, so the config layer can derive "protected: yes/no" for a +// PURE: no database or network I/O, so the config layer can derive "protected: yes/no" for a // listing without importing the proxy or the database. +import { isIP } from "node:net"; +import { classifyAddress } from "../../web/ip-policy.js"; +import { normalizeHost } from "./rules.js"; import { CLI_INTEGRATIONS } from "../../config/cli-catalog.js"; import { PERPLEXITY_HOST, PERPLEXITY_RELAY_SECRET_NAME, PERPLEXITY_SESSION_COOKIE } from "../perplexity-research-contract.js"; @@ -152,15 +155,23 @@ export function assertValidSwapRuleFields({ hosts, headers, format } = {}) { return out; } -// The channel meta's `egressRawHosts` (admin-set): hosts whose ports 22/5432/6543 get a raw CONNECT +// The channel meta's `egressRawHosts` (admin API or approved worker request): destinations whose +// ports 22/5432/6543 get a raw CONNECT // tunnel through the proxy (SSH, Postgres) while the channel's network is on. Strict, for the // admin API; the proxy's policy re-filters tolerantly on read. +// Raw tunnels carry SSH/PostgreSQL bytes, not credential substitutions. Public IP literals +// are valid here without widening the DNS-only secret-host rule above. +export function isValidRawHost(value) { + const host = normalizeHost(value); + return isIP(host) ? !host.includes("%") && classifyAddress(host) === "" : isValidRuleHost(host); +} + export const MAX_RAW_HOSTS = 16; export function assertValidEgressRawHosts(value) { - const list = [...new Set(listOf(value).map(normalizeRuleHost))]; + const list = [...new Set(listOf(value).map(normalizeHost))]; if (list.length > MAX_RAW_HOSTS) throw new Error(`At most ${MAX_RAW_HOSTS} raw hosts per channel.`); - const bad = list.find((host) => !isValidRuleHost(host)); - if (bad) throw new Error(`"${bad}" is not a host name — use a DNS name like db.example.com or a wildcard like *.pooler.supabase.com.`); + const bad = list.find((host) => !isValidRawHost(host)); + if (bad) throw new Error(`"${bad}" is not a raw destination — use a DNS name, a public IPv4/IPv6 address, or a DNS wildcard.`); return list; } diff --git a/src/gateway/egress/service.js b/src/gateway/egress/service.js index cb867f75..73a67bf4 100644 --- a/src/gateway/egress/service.js +++ b/src/gateway/egress/service.js @@ -43,7 +43,7 @@ import { isChannelLive, isOwnerLive, otherOwnerActive, otherSshOpen } from "./li import { activeEditorLeases } from "../../runtimes/container/editor-lease.js"; import { containerName } from "../../runtimes/container/names.js"; import { engineHostsFor, hostOfUrl } from "./engine-hosts.js"; -import { isValidRuleHost } from "./catalog-rules.js"; +import { isValidRawHost } from "./catalog-rules.js"; // sockaddr_un.sun_path is 108 bytes including the terminating NUL. export const MAX_SOCKET_PATH_BYTES = 107; @@ -124,7 +124,7 @@ export function rawPassthroughFor(meta = {}) { const declared = Array.isArray(meta?.egressRawHosts) ? meta.egressRawHosts : []; for (const raw of declared) { const host = normalizeHost(raw); - if (!host || !isValidRuleHost(host)) continue; + if (!host || !isValidRawHost(host)) continue; for (const port of RAW_PASSTHROUGH_PORTS) out.push({ host, port }); } return out; diff --git a/src/gateway/gateway-usage/references/administration.md b/src/gateway/gateway-usage/references/administration.md index 7e86ddef..64e6de05 100644 --- a/src/gateway/gateway-usage/references/administration.md +++ b/src/gateway/gateway-usage/references/administration.md @@ -157,7 +157,7 @@ These channel modes do not leave the container. The separate organization-admin- (anything else answers HTTP 403 `network-off`); on: public hosts, never private, loopback or cloud-metadata addresses. A flip applies to the next request. Raw sockets (`ssh`, `psql`) are tunnelled only to `github.com:22` and hosts in this channel's `egressRawHosts`. For a new - public SSH server, call `allow_channel_ssh_host` with the exact hostname. It posts a durable + public SSH server, call `allow_channel_ssh_host` with the exact hostname or public IP address. It posts a durable approval card: any authorized channel member with write access may approve, without `/sudo` or an admin. End the turn while pending; after approval retry with the existing key. The card adds this host for TCP 22, 5432 and 6543, preserves existing hosts, and never enables Network diff --git a/src/gateway/skills/bundled/channelgate/references/vpn-ssh-access.md b/src/gateway/skills/bundled/channelgate/references/vpn-ssh-access.md index 78a2a5ef..c6dd6f96 100644 --- a/src/gateway/skills/bundled/channelgate/references/vpn-ssh-access.md +++ b/src/gateway/skills/bundled/channelgate/references/vpn-ssh-access.md @@ -28,7 +28,7 @@ calls for Option 2 or an operator handoff; never weaken validation to make it fi ## Public SSH from an ordinary channel container -For an exact public SSH hostname not yet in this channel's `egressRawHosts`, use +For an exact public SSH hostname or IPv4/IPv6 address not yet in this channel's `egressRawHosts`, use `allow_channel_ssh_host`. The gateway posts a durable card that any currently authorized channel member with write access may approve; neither admin rights nor `/sudo` are needed. The card persists across restarts and is never auto-approved. End the turn while it is pending. diff --git a/src/mcp/tools/egress-hosts.js b/src/mcp/tools/egress-hosts.js index e812970c..9285e8fd 100644 --- a/src/mcp/tools/egress-hosts.js +++ b/src/mcp/tools/egress-hosts.js @@ -3,7 +3,7 @@ import { addApprovedEgressHost } from "../../gateway/egress-hosts.js"; export function register(server, ctx) { server.registerTool("allow_channel_ssh_host", { - description: "Request human approval to add one exact public SSH server to THIS channel's egressRawHosts. Any authorized channel member with write access may approve; no admin or /sudo is needed. The durable card survives restarts and is never auto-approved. The existing host list permits raw TCP on ports 22, 5432 and 6543, while Network must remain on and private addresses remain blocked. After approval retry SSH with -o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p' and the existing key. Does not grant keys, enable Network, or enable rawNetwork.", + description: "Request human approval to add one exact public SSH hostname or IPv4/IPv6 address to THIS channel's egressRawHosts. Any authorized channel member with write access may approve; no admin or /sudo is needed. The durable card survives restarts and is never auto-approved. The existing host list permits raw TCP on ports 22, 5432 and 6543, while Network must remain on and private addresses remain blocked. After approval retry SSH with -o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p' and the existing key. Does not grant keys, enable Network, or enable rawNetwork.", inputSchema: { host: z.string().min(1).max(253) }, }, async ({ host }) => { try { diff --git a/test/egress-hosts.test.js b/test/egress-hosts.test.js index f4528d6d..4a655f2c 100644 --- a/test/egress-hosts.test.js +++ b/test/egress-hosts.test.js @@ -54,9 +54,12 @@ test("untrusted principals, unauthorized users, read mode and network-off cannot test("wildcards, URLs and ports are refused before DNS; private and mixed DNS answers never grant raw egress", async () => { const ctx = await fixture(); const unexpectedLookup = async () => { assert.fail("invalid host must not reach DNS"); }; - for (const host of ["*.example.com", "https://ssh.example.com", "ssh.example.com:22", "ssh.example.com/path", "8.8.8.8", "127.0.0.1", "::1"]) { + for (const host of ["*.example.com", "https://ssh.example.com", "ssh.example.com:22", "ssh.example.com/path"]) { await assert.rejects(prepareEgressHost(ctx, host, { lookup: unexpectedLookup }), /exact hostname/); } + for (const host of ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fc00::1", "::ffff:127.0.0.1"]) { + await assert.rejects(prepareEgressHost(ctx, host, { lookup: unexpectedLookup }), /which channels may not reach/); + } for (const addresses of [ [{ address: "192.168.1.5", family: 4 }], [{ address: "127.0.0.1", family: 4 }], @@ -123,3 +126,33 @@ test("durable worker executor revalidates the requester and approver before invo cp.__resetControlPlaneApprovals(); } }); + + +test("public IPv4 and IPv6 literals are approved without DNS and become raw tunnels", async () => { + const ctx = await fixture(); + const { rawPassthroughFor } = await import("../src/gateway/egress/service.js"); + const { checkDestination } = await import("../src/gateway/egress/policy.js"); + const { assertValidEgressRawHosts, isValidRuleHost, assertValidSwapRuleFields } = await import("../src/gateway/egress/catalog-rules.js"); + const lookup = async () => assert.fail("IP literals must not require DNS"); + for (const host of ["8.8.8.8", "2001:4860:4860::8888"]) { + const prepared = await prepareEgressHost(ctx, host, { lookup }); + assert.equal(prepared.host, host); + assert.equal(prepared.alreadyAllowed, false); + await addApprovedEgressHost(ctx, host, { lookup }); + const meta = await store.getChannelMeta(ctx.slug); + const rawPassthrough = rawPassthroughFor(meta); + assert.ok(rawPassthrough.some(rule => rule.host === host && rule.port === 22)); + const on = await checkDestination({ hostname: host, port: 22, mode: "on", rawPassthrough, lookup }); + assert.equal(on.ok, true); + assert.equal(on.tunnel, true, "the proxy must bypass TLS for an approved SSH IP"); + const off = await checkDestination({ hostname: host, port: 22, mode: "off", rawPassthrough, lookup }); + assert.equal(off.ok, false); + assert.equal(isValidRuleHost(host), false, "secret-host declarations remain DNS-only"); + assert.throws(() => assertValidSwapRuleFields({ hosts: [host] })); + } + assert.deepEqual(assertValidEgressRawHosts(["[2001:4860:4860::8888]", "8.8.8.8"]), ["2001:4860:4860::8888", "8.8.8.8"]); + for (const host of ["10.0.0.1", "127.0.0.1", "169.254.169.254", "fc00::1", "::1", "::ffff:127.0.0.1", "999.1.1.1", "8.8.8.8:22", "2001:4860:4860::8888%eth0"]) { + assert.throws(() => assertValidEgressRawHosts([host]), /not a raw destination/); + assert.ok(!rawPassthroughFor({ egressRawHosts: [host] }).some(rule => rule.host === host)); + } +}); diff --git a/test/mcp-control-plane-approval.test.js b/test/mcp-control-plane-approval.test.js index 45cc5e1d..40c543be 100644 --- a/test/mcp-control-plane-approval.test.js +++ b/test/mcp-control-plane-approval.test.js @@ -49,7 +49,7 @@ const CUSTOM_WORKDIR = tempDir("cg-ctrl-custom-workdir-"); mkdirSync(ISOLATED_HOME, { recursive: true }); mkdirSync(CUSTOM_WORKDIR, { recursive: true }); -function gatewayClient({ author = "U_CTRL_ADMIN", port = stubPort, engine = "claude", preload = "" } = {}) { +function gatewayClient({ author = "U_CTRL_ADMIN", port = stubPort, engine = "claude" } = {}) { const secret = "ctrl-test-secret"; const transport = new StdioClientTransport({ command: process.execPath, @@ -59,7 +59,6 @@ function gatewayClient({ author = "U_CTRL_ADMIN", port = stubPort, engine = "cla env: { PATH: process.env.PATH || "", NODE_ENV: "test", - ...(preload ? { NODE_OPTIONS: `--import=${preload}` } : {}), CHANNELGATE_LICENSE_PUBLIC_KEY: process.env.CHANNELGATE_LICENSE_PUBLIC_KEY, CHANNELGATE_LICENSE_PAYLOAD: process.env.CHANNELGATE_LICENSE_PAYLOAD, CHANNELGATE_LICENSE_KEY: process.env.CHANNELGATE_LICENSE_KEY, @@ -897,41 +896,36 @@ test("list_channel_mcps names the conversation's custom MCP servers, never their test("both engines request durable worker approval for SSH egress, including Auto mode", async () => { const { patchChannelMeta } = await import("../src/config/store.js"); - const preload = path.join(scratch, "egress-dns.mjs"); - writeFileSync(preload, `import dns from "node:dns"; const orig = dns.promises.lookup; dns.promises.lookup = (host, ...args) => host === "ssh.example.com" ? Promise.resolve([{address:"8.8.8.8", family:4}]) : orig(host, ...args);`); - const dns = (await import("node:dns")).default; - const originalLookup = dns.promises.lookup; - dns.promises.lookup = async () => [{ address: "8.8.8.8", family: 4 }]; const original = await getChannelMeta(SLUG); try { for (const engine of ["claude", "codex"]) { await patchChannelMeta(SLUG, { allowBash: true, allowNetwork: true, autoMode: true, egressRawHosts: ["existing.example"] }); approvalRequests.length = 0; approvalResponse = { allow: false, pending: true, approvalId: `egress-${engine}` }; - await withGateway({ engine, author: "U_CTRL_MEMBER", preload }, async (client) => { - const result = await client.callTool({ name: "allow_channel_ssh_host", arguments: { host: "ssh.example.com" } }); + await withGateway({ engine, author: "U_CTRL_MEMBER" }, async (client) => { + const result = await client.callTool({ name: "allow_channel_ssh_host", arguments: { host: "8.8.8.8" } }); assert.match(resultText(result), awaitingCard); }); assert.equal(approvalRequests.length, 1); const request = approvalRequests[0].body; assert.equal(request.approvalType, "agent"); assert.equal(request.requiredTier, "worker"); - assert.deepEqual(request.durableAction.args, { host: "ssh.example.com" }); + assert.deepEqual(request.durableAction.args, { host: "8.8.8.8" }); assert.match(request.toolInput.details, /22.*5432.*6543/); assert.deepEqual((await getChannelMeta(SLUG)).egressRawHosts, ["existing.example"]); const bad = await applySaved(request, "U_UNAUTHORIZED"); assert.equal(bad.ok, false); const saved = await applySaved(request, "U_CTRL_MEMBER"); assert.equal(saved.ok, true, saved.error); - assert.deepEqual((await getChannelMeta(SLUG)).egressRawHosts, ["existing.example", "ssh.example.com"]); + assert.deepEqual((await getChannelMeta(SLUG)).egressRawHosts, ["existing.example", "8.8.8.8"]); approvalRequests.length = 0; - await withGateway({ engine, author: "U_CTRL_MEMBER", preload }, async (client) => { - assert.match(resultText(await client.callTool({ name: "allow_channel_ssh_host", arguments: { host: "ssh.example.com" } })), /allowed/); + await withGateway({ engine, author: "U_CTRL_MEMBER" }, async (client) => { + assert.match(resultText(await client.callTool({ name: "allow_channel_ssh_host", arguments: { host: "8.8.8.8" } })), /allowed/); }); assert.equal(approvalRequests.length, 0, "an existing host needs no further approval"); await patchChannelMeta(SLUG, { allowBash: false, adminMode: false, autoMode: false }); const stale = await applySaved(request, "admin UI"); assert.equal(stale.ok, false, "even admin UI cannot apply a worker approval in a read-only channel"); } - } finally { dns.promises.lookup = originalLookup; await saveChannelMeta(SLUG, original); } + } finally { await saveChannelMeta(SLUG, original); } });