Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions documentation/src/content/docs/controls/ssh.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,14 @@ 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
{
"host": "ssh.example.com"
}
```

**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'`.
4 changes: 2 additions & 2 deletions src/gateway/egress-hosts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]);
Expand Down
21 changes: 16 additions & 5 deletions src/gateway/egress/catalog-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
// list (bearer = `<scheme> <token>`, 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";

Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions src/gateway/egress/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/gateway/gateway-usage/references/administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/tools/egress-hosts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 34 additions & 1 deletion test/egress-hosts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down Expand Up @@ -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));
}
});
Loading
Loading