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

- Let authorized channel members with write access approve public SSH destinations on a durable card, enabling container SSH without `/sudo`.

- Skip granted folders without a regular `SKILL.md`, warn with the folder name, and continue with valid skills instead of failing the request.

- Make completed `/delete` commands silent, including partial deletion; retain audit counts without posting a report.
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

- `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.

- Invalid legacy skill-folder grants are omitted from Claude plugins and the Codex personal catalog. The reply names folders missing a regular `SKILL.md`; remaining skills continue loading and source folders/grants remain intact.

## Company email domain auto-approval
Expand Down
6 changes: 6 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# ChannelGate — Test Plan

## Channel SSH egress approval (2026-11-04)

- 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.

## Invalid skill-folder grants (2026-11-04)

- Automated: `test/run-grant-isolation.test.js` covers personal/shared parent-folder grants, alone and alongside a valid skill; verifies warnings, source preservation, and exclusion from both Claude plugins and the Codex catalog.
Expand Down
5 changes: 4 additions & 1 deletion docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,10 @@ channel's policy to every request:
- **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/<id>/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
and 6543. An agent may request one exact public host using `allow_channel_ssh_host`: its durable
card can be approved by any currently authorized channel member with write access, even in Auto
mode. Approval appends rather than replaces hosts; Network must be on, and private-address
checks still apply on every connection. Admins retain the direct API path. 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
Expand Down
13 changes: 9 additions & 4 deletions src/gateway/control-plane-approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@
// unasked call would silently hand attacker-controlled content or a secret to OTHER channels and a
// later admin message could not undo it — the gateway guide, admitting a skill source, granting a
// skill organization-wide (directly, through a template, or by approving a proposal), approving a
// server for a hidden secret, making a secret readable, and Admin mode on/off. Every other
// server for a hidden secret, making a secret readable, Admin mode on/off, and a channel
// raw egress destination (authorized workers may approve that narrow channel grant). Every other
// control-plane change is automatic for the author who holds its authority.
import { getChannelEntry, getChannelMeta, isAdminPrincipal, isApproved } from "../config/store.js";
import { isChannelWorker } from "./egress-hosts.js";
import { canManage, isAuthorized } from "./modes.js";

export const CONTROL_PLANE_ACTION = "control_plane";
// Who may click Approve: a gateway admin, a channel manager, the requester themself (a personal
// secret's owner), or anyone authorized to work in the channel ("").
export const CONTROL_PLANE_TIERS = Object.freeze(["admin", "manage", "owner", ""]);
// secret's owner), an authorized worker, or anyone authorized to work in the channel ("").
export const CONTROL_PLANE_TIERS = Object.freeze(["admin", "manage", "owner", "worker", ""]);

// Arguments that must never be persisted with the saved action. They are held in daemon memory
// under the approval id until the click; a restart in between loses them, and the executor then
Expand Down Expand Up @@ -73,6 +75,7 @@ export function setControlPlaneInvoker(fn) {
export function describeControlPlaneTier(tier = "") {
if (tier === "admin") return "a gateway admin";
if (tier === "manage") return "one of this channel's managers (or an admin)";
if (tier === "worker") return "an authorized channel member with write access";
if (tier === "owner") return "you (the requester)";
return "anyone working in this channel";
}
Expand Down Expand Up @@ -108,14 +111,16 @@ export async function executeControlPlaneApproval(record) {
// posted is not evidence of holding the authority now.
const requester = await rank(action.authorId, meta, entry.isDM);
const tier = action.tier || "";
if (tier === "worker" && !(await isChannelWorker(action.channelId, action.slug, action.authorId))) throw new Error("The requester no longer has write access in this channel.");
if (tier === "admin" && !requester.admin) throw new Error("The requester is no longer an admin.");
if (tier === "manage" && !requester.manager) throw new Error("The requester no longer manages this channel.");
if (!requester.admin && !requester.member) throw new Error("The requester is no longer authorized in this channel.");
// The human factor: the admin UI's session IS an admin; a decision link is bound to the
// requester and its route re-validates them on every POST; a chat click is checked here.
if (record.decidedBy !== "admin UI" && record.decidedBy !== "link") {
const clicker = await rank(record.decidedBy, meta, entry.isDM);
const meets = tier === "admin" ? clicker.admin
const meets = tier === "worker" ? await isChannelWorker(action.channelId, action.slug, record.decidedBy)
: tier === "admin" ? clicker.admin
: tier === "manage" ? clicker.manager
: tier === "owner" ? clicker.admin || record.decidedBy === action.authorId
: clicker.admin || clicker.member;
Expand Down
47 changes: 47 additions & 0 deletions src/gateway/egress-hosts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Narrow, channel-scoped raw egress grants. Human approval is supplied by the durable
// control-plane gate; neither Auto mode nor an engine can approve this mutation.
import { getChannelEntry, getChannelMeta, isAdminPrincipal, isApproved, patchChannelMeta } from "../config/store.js";
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";

export async function isChannelWorker(channelId, slug, userId) {
if (!userId) return false;
const entry = await getChannelEntry(channelId);
const meta = await getChannelMeta(slug);
if (!entry || entry.slug !== slug || !meta || meta.channelId !== channelId || channelMode(meta) === "read") return false;
return isAuthorized(meta, userId, Boolean(entry.isDM), {
isAdminUser: await isAdminPrincipal(userId), isApprovedUser: await isApproved(userId),
});
}

export async function prepareEgressHost(ctx, value, { lookup } = {}) {
if (!ctx.principalTrusted || !(await isChannelWorker(ctx.channelId, ctx.slug, ctx.createdBy))) {
throw new Error("Only an authorized channel member with write access can request SSH egress.");
}
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.");
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]);
return { host, alreadyAllowed: (meta.egressRawHosts || []).includes(host) };
}

export async function addApprovedEgressHost(ctx, value, options) {
const { host } = await prepareEgressHost(ctx, value, options);
if (!(await isChannelWorker(ctx.channelId, ctx.slug, ctx.createdBy))) throw new Error("The requester no longer has write access in this channel.");
let before;
const after = await patchChannelMeta(ctx.slug, (current) => {
if (!current || current.channelId !== ctx.channelId || channelMode(current) === "read" || current.allowNetwork !== true) {
throw new Error("This channel no longer permits SSH egress.");
}
before = current;
return { egressRawHosts: assertValidEgressRawHosts([...(current.egressRawHosts || []), host]) };
});
await logChannelPolicyChange({ channelId: ctx.channelId, slug: ctx.slug, actor: ctx.createdBy, before, after, source: "mcp" });
return host;
}
8 changes: 6 additions & 2 deletions src/gateway/gateway-usage/references/administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,12 @@ These channel modes do not leave the container. The separate organization-admin-
switch on every request — off: only the engine endpoints and this channel's selected connectors
(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 an admin declared (`egressRawHosts`, admin API) and
need a client pointed at the proxy — for `ssh`/`git@…`:
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
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
or permits private addresses. Do not switch to `/sudo` just to bypass this approval. Point raw clients at the proxy — for `ssh`/`git@…`:
`GIT_SSH_COMMAND="ssh -o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p'"`. Where the access note says the switch is *advisory* (the
operator's legacy bridge mode, a channel given `rawNetwork`, a `/sudo` thread) a request may
still succeed while it is off — that is not permission. If the switch is off and a task needs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ private database from the agent container. A provider profile outside the import
contract, a different protocol, or a database requiring unsupported TLS settings
calls for Option 2 or an operator handoff; never weaken validation to make it fit.

## Public SSH from an ordinary channel container

For an exact public SSH hostname 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.
Once approved, retry with the existing key and
`-o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p'`.
The existing host list enables TCP ports 22, 5432 and 6543; the card names that scope.
Network must remain on and private/loopback/metadata addresses remain blocked. Approval
adds no SSH key, secret host grant, private route, or open network. Use the isolated VPN or
operator paths below when the destination is private.

## Option 2: Agent procedure — VPN or SSH tunnel access

Use this when a task needs a private network or database and the supported
Expand Down
19 changes: 18 additions & 1 deletion src/mcp/gateway-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import { register as registerSkills } from "./tools/skills.js";
import { register as registerAgentDirectory } from "./tools/agent-directory.js";
import { register as registerQuestions } from "./tools/questions.js";
import { register as registerSshAccess } from "./tools/ssh-access.js";
import { register as registerEgressHosts } from "./tools/egress-hosts.js";
import { isChannelWorker, prepareEgressHost } from "../gateway/egress-hosts.js";
import { sshRequestTarget } from "../gateway/ssh-access.js";
import { register as registerFileSharing, describeDuration } from "./tools/file-sharing.js";
import { register as registerEmail } from "./tools/email.js";
Expand Down Expand Up @@ -312,6 +314,7 @@ export function buildControlPlane({ loadMeta, createdBy, principalTrusted, chann
// authors in this channel, which is the posture every other card relies on — so it keeps its card.
["set_channel_admin_mode", { authz: "admin", details: ({ enabled }) => `Turn ADMIN MODE (no sandbox, no prompts for admin authors) ${onOff(enabled)} for this channel.` }],
["set_channel_vpn", { authz: "manage", details: AUTOMATIC }],
["allow_channel_ssh_host", { authz: "worker", details: ({ host }) => `Allow raw TCP connections to ${host} for THIS channel on ports 22 (SSH), 5432 and 6543 (PostgreSQL)? This persists for future channel runs. Network must remain on; private addresses stay blocked. No SSH key or secret access is granted.` }],
["set_channel_network", { authz: "admin", details: AUTOMATIC }],
["set_channel_bash", { authz: "manage", details: AUTOMATIC }],
["set_channel_auto_mode", { authz: "manage", details: AUTOMATIC }],
Expand Down Expand Up @@ -419,6 +422,7 @@ export function createGatewayMcpServer(ctx) {
const CONTROL_PLANE = buildControlPlane(ctx);

async function passesAuthzPrecheck(authz) {
if (authz === "worker") return ctx.principalTrusted && isChannelWorker(ctx.channelId, ctx.slug, ctx.createdBy);
if (authz === "admin") return ctx.requireAdmin();
if (authz === "manage") return ctx.requireManage();
return true;
Expand Down Expand Up @@ -491,9 +495,21 @@ export function createGatewayMcpServer(ctx) {
if (!(await passesAuthzPrecheck(authz))) {
return text(authz === "admin"
? `🚫 Only admins can run \`${name}\`. Nothing was changed.`
: authz === "worker" ? `🚫 Only authorized channel members with write access can run \`${name}\`. Nothing was changed.`
: `🚫 Only this channel's managers (or an admin) can run \`${name}\`. Nothing was changed.`);
}
let details = await gate.details(args ?? {});
// Validate before posting a card, and use canonical args for the exact saved action.
let hostAlreadyAllowed = false;
if (name === "allow_channel_ssh_host") {
try {
const prepared = await prepareEgressHost(ctx, args?.host);
args = { host: prepared.host };
hostAlreadyAllowed = prepared.alreadyAllowed;
} catch (error) {
return text(`Couldn't request SSH egress: ${error.message}`);
}
}
let details = hostAlreadyAllowed ? null : await gate.details(args ?? {});
if (details !== null) {
// The clicker must independently hold the card's tier ("" needs no extra rank): the
// human factor for an admin-tier change must come from an admin, never a bystander.
Expand Down Expand Up @@ -546,6 +562,7 @@ export function createGatewayMcpServer(ctx) {
registerSchedules(server, ctx);
if (ctx.toolset === "full") registerBackground(server, ctx);
registerChannelAdmin(server, ctx);
registerEgressHosts(server, ctx);
registerChannelDatabase(server, ctx);
registerTokens(server, ctx);
registerApiTokens(server, ctx);
Expand Down
16 changes: 16 additions & 0 deletions src/mcp/tools/egress-hosts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { z } from "zod";
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.",
inputSchema: { host: z.string().min(1).max(253) },
}, async ({ host }) => {
try {
const saved = await addApprovedEgressHost(ctx, host);
return ctx.text(`✅ ${saved} is allowed in this channel's egress list (TCP 22, 5432, 6543). Retry SSH using -o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p' and your existing key. Network must remain on; private addresses remain blocked.`);
} catch (error) {
return { ...ctx.text(`❌ ${error.message}`), isError: true };
}
});
}
Loading
Loading