diff --git a/CHANGELOG.md b/CHANGELOG.md index 06fb15ca..5ac716b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/FEATURES.md b/FEATURES.md index d8d99c0e..da2519ae 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 641acf6d..bb145460 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -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. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index ece303a1..78576ef3 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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//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 diff --git a/src/gateway/control-plane-approvals.js b/src/gateway/control-plane-approvals.js index 0d649b8e..f7bb5b65 100644 --- a/src/gateway/control-plane-approvals.js +++ b/src/gateway/control-plane-approvals.js @@ -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 @@ -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"; } @@ -108,6 +111,7 @@ 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."); @@ -115,7 +119,8 @@ export async function executeControlPlaneApproval(record) { // 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; diff --git a/src/gateway/egress-hosts.js b/src/gateway/egress-hosts.js new file mode 100644 index 00000000..9f85c732 --- /dev/null +++ b/src/gateway/egress-hosts.js @@ -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; +} diff --git a/src/gateway/gateway-usage/references/administration.md b/src/gateway/gateway-usage/references/administration.md index 604a2e59..7e86ddef 100644 --- a/src/gateway/gateway-usage/references/administration.md +++ b/src/gateway/gateway-usage/references/administration.md @@ -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 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 3ade8771..78a2a5ef 100644 --- a/src/gateway/skills/bundled/channelgate/references/vpn-ssh-access.md +++ b/src/gateway/skills/bundled/channelgate/references/vpn-ssh-access.md @@ -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 diff --git a/src/mcp/gateway-server.js b/src/mcp/gateway-server.js index 108f11c6..2252ba6e 100644 --- a/src/mcp/gateway-server.js +++ b/src/mcp/gateway-server.js @@ -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"; @@ -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 }], @@ -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; @@ -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. @@ -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); diff --git a/src/mcp/tools/egress-hosts.js b/src/mcp/tools/egress-hosts.js new file mode 100644 index 00000000..e812970c --- /dev/null +++ b/src/mcp/tools/egress-hosts.js @@ -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 }; + } + }); +} diff --git a/src/slack/approvals.js b/src/slack/approvals.js index d10832a2..728f641b 100644 --- a/src/slack/approvals.js +++ b/src/slack/approvals.js @@ -30,6 +30,7 @@ import { platformOfConversation } from "../platforms/ids.js"; import { platformOr } from "../platforms/registry.js"; import { postPrivately } from "../platforms/notify.js"; import { INSTRUCTION_ACTION } from "../gateway/instruction-approvals.js"; +import { isChannelWorker } from "../gateway/egress-hosts.js"; import { SECRET_HOST_ACTION } from "../gateway/secret-host-approvals.js"; // The live Slack client of the currently-connected app (set from connectAndWire); the approval @@ -125,6 +126,7 @@ function requesterLabel(authorId) { // The authority a card needs, stated ON the card: a member who could only ever Deny must learn // that before clicking, not from the ephemeral refusal after (the Symphonia finding of 2026-10-06). function tierNotice(requiredTier = "") { + if (requiredTier === "worker") return " Any authorized channel member with *write access* may approve this; no admin is required."; if (requiredTier === "admin") return " Only a gateway *admin* can approve this; anyone eligible may Deny or Comment."; if (requiredTier === "manage") return " Only a channel *manager* (or a gateway admin) can approve this; anyone eligible may Deny or Comment."; if (requiredTier === "owner") return " Only the requester (or a gateway admin) can approve this; anyone eligible may Deny or Comment."; @@ -677,7 +679,9 @@ export async function canResolveApproval(entry, clicker) { const tier = entry.requiredTier || ""; const clickerCanManage = clickerIsAdmin || canManage(m || {}, { authorId: clicker, isAdminUser: clickerIsAdmin, isApprovedUser: clickerIsApproved }); const meetsTier = - tier === "admin" + tier === "worker" + ? await isChannelWorker(entry.channelId, entry.slug, clicker) + : tier === "admin" ? clickerIsAdmin : tier === "manage" ? clickerCanManage @@ -726,7 +730,7 @@ export async function handleApprovalClick({ ack, body, action, client }) { channel, user: clicker, thread_ts: ts, - text: `This approval needs ${tier === "admin" ? "a gateway *admin*" : tier === "owner" ? "the requester (or an admin)" : "a channel *manager*"} to click Approve — you can still Deny or Comment.`, + text: `This approval needs ${tier === "worker" ? "an authorized channel member with write access" : tier === "admin" ? "a gateway *admin*" : tier === "owner" ? "the requester (or an admin)" : "a channel *manager*"} to click Approve — you can still Deny or Comment.`, }); } catch { /* no ephemeral scope — ignore */ diff --git a/test/egress-hosts.test.js b/test/egress-hosts.test.js new file mode 100644 index 00000000..f4528d6d --- /dev/null +++ b/test/egress-hosts.test.js @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); + +const store = await import("../src/config/store.js"); +const { prepareEgressHost, addApprovedEgressHost } = await import("../src/gateway/egress-hosts.js"); +const { canResolveApproval } = await import("../src/slack/approvals.js"); +const cp = await import("../src/gateway/control-plane-approvals.js"); +const MEMBER = "U_EGRESS_MEMBER"; +const TEAMMATE = "U_EGRESS_TEAMMATE"; +const STRANGER = "U_EGRESS_STRANGER"; +await store.setUser(MEMBER, { approved: true, isAdmin: false }); +await store.setUser(TEAMMATE, { approved: true, isAdmin: false }); +let sequence = 0; +const publicLookup = async () => [{ address: "8.8.8.8", family: 4 }]; + +async function fixture(patch = {}) { + const channelId = `C_EGRESS_HOST_${++sequence}`; + const entry = await store.upsertChannelEntry(channelId, { name: `egress-host-${sequence}`, type: "channel", isDM: false }); + await store.saveChannelMeta(entry.slug, { + channelId, access: "approved", allowBash: true, allowNetwork: true, + managers: "admins", egressRawHosts: ["existing.example.com"], model: "preserved-model", ...patch, + }); + return { channelId, slug: entry.slug, createdBy: MEMBER, principalTrusted: true, threadKey: "1700000000.000100" }; +} + +test("a non-admin worker can request and append an exact host while preserving channel settings", async () => { + const ctx = await fixture(); + const prepared = await prepareEgressHost(ctx, "SSH.EXAMPLE.COM", { lookup: publicLookup }); + assert.deepEqual(prepared, { host: "ssh.example.com", alreadyAllowed: false }); + assert.deepEqual((await store.getChannelMeta(ctx.slug)).egressRawHosts, ["existing.example.com"], "preparation never grants access"); + assert.equal(await addApprovedEgressHost(ctx, prepared.host, { lookup: publicLookup }), prepared.host); + await addApprovedEgressHost(ctx, prepared.host, { lookup: publicLookup }); + const meta = await store.getChannelMeta(ctx.slug); + assert.deepEqual(meta.egressRawHosts, ["existing.example.com", "ssh.example.com"]); + assert.equal(meta.model, "preserved-model"); + assert.equal(meta.managers, "admins"); +}); + +test("untrusted principals, unauthorized users, read mode and network-off cannot request or add hosts", async () => { + for (const [patch, context] of [ + [{}, { createdBy: STRANGER }], [{}, { principalTrusted: false }], + [{ allowBash: false }, {}], [{ allowNetwork: false }, {}], + ]) { + const ctx = { ...await fixture(patch), ...context }; + await assert.rejects(prepareEgressHost(ctx, "ssh.example.com", { lookup: publicLookup }), /write access|Network access is off/); + await assert.rejects(addApprovedEgressHost(ctx, "ssh.example.com", { lookup: publicLookup }), /write access|Network access is off/); + assert.deepEqual((await store.getChannelMeta(ctx.slug)).egressRawHosts, ["existing.example.com"]); + } +}); + +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"]) { + await assert.rejects(prepareEgressHost(ctx, host, { lookup: unexpectedLookup }), /exact hostname/); + } + for (const addresses of [ + [{ address: "192.168.1.5", family: 4 }], + [{ address: "127.0.0.1", family: 4 }], + [{ address: "169.254.169.254", family: 4 }], + [{ address: "8.8.8.8", family: 4 }, { address: "10.0.0.1", family: 4 }], + ]) { + await assert.rejects(addApprovedEgressHost(ctx, "mixed.example.com", { lookup: async () => addresses }), /which channels may not reach/); + } + assert.deepEqual((await store.getChannelMeta(ctx.slug)).egressRawHosts, ["existing.example.com"]); +}); + +test("concurrent approved hosts append atomically and retain both grants", async () => { + const ctx = await fixture(); + await Promise.all([ + addApprovedEgressHost(ctx, "first.example.com", { lookup: publicLookup }), + addApprovedEgressHost(ctx, "second.example.com", { lookup: publicLookup }), + ]); + const meta = await store.getChannelMeta(ctx.slug); + assert.deepEqual(new Set(meta.egressRawHosts), new Set(["existing.example.com", "first.example.com", "second.example.com"])); + assert.equal(meta.model, "preserved-model"); +}); + +test("mode, network and requester-access changes during DNS resolution abort the approved append", async () => { + for (const patch of [{ allowBash: false }, { allowNetwork: false }, { excludedUsers: [MEMBER] }]) { + const ctx = await fixture(); + const lookup = async () => { + await store.patchChannelMeta(ctx.slug, patch); + return publicLookup(); + }; + await assert.rejects(addApprovedEgressHost(ctx, "stale.example.com", { lookup }), /no longer permits SSH egress|no longer has write access/); + assert.deepEqual((await store.getChannelMeta(ctx.slug)).egressRawHosts, ["existing.example.com"]); + } +}); + +test("worker approval tier admits another authorized member without channel-management rights and rechecks write access", async () => { + const ctx = await fixture(); + const entry = { channelId: ctx.channelId, slug: ctx.slug, authorId: MEMBER, requiredTier: "worker" }; + const teammate = await canResolveApproval(entry, TEAMMATE); + assert.equal(teammate.allowed, true); + assert.equal(teammate.clickerCanManage, false); + assert.equal(teammate.meetsTier, true); + assert.equal((await canResolveApproval(entry, STRANGER)).meetsTier, false); + await store.patchChannelMeta(ctx.slug, { allowBash: false }); + assert.equal((await canResolveApproval(entry, TEAMMATE)).meetsTier, false); +}); + +test("durable worker executor revalidates the requester and approver before invoking the saved action", async () => { + const ctx = await fixture(); + const action = cp.buildControlPlaneAction(ctx, { tool: "allow_channel_ssh_host", args: { host: "ssh.example.com" }, tier: "worker" }); + const record = { id: "egress-worker-approval", status: "executing", channelId: ctx.channelId, slug: ctx.slug, authorId: MEMBER, decidedBy: TEAMMATE, action }; + const calls = []; + cp.setControlPlaneInvoker(async (call) => { calls.push(call); return { ok: true, text: "Allowed" }; }); + try { + assert.equal((await cp.executeControlPlaneApproval(record)).ok, true); + assert.equal(calls.length, 1); + assert.equal((await cp.executeControlPlaneApproval({ ...record, decidedBy: STRANGER })).ok, false); + await store.patchChannelMeta(ctx.slug, { excludedUsers: [MEMBER] }); + assert.equal((await cp.executeControlPlaneApproval(record)).ok, false); + assert.equal(calls.length, 1, "revoked principals never reach the invoker"); + await store.patchChannelMeta(ctx.slug, { excludedUsers: [], allowBash: false }); + assert.equal((await cp.executeControlPlaneApproval({ ...record, decidedBy: "admin UI" })).ok, false, "admin UI cannot revive a read-only request"); + assert.equal(calls.length, 1); + } finally { + cp.__resetControlPlaneApprovals(); + } +}); diff --git a/test/mcp-control-plane-approval.test.js b/test/mcp-control-plane-approval.test.js index eecfbfaf..45cc5e1d 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" } = {}) { +function gatewayClient({ author = "U_CTRL_ADMIN", port = stubPort, engine = "claude", preload = "" } = {}) { const secret = "ctrl-test-secret"; const transport = new StdioClientTransport({ command: process.execPath, @@ -59,6 +59,7 @@ 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, @@ -556,7 +557,7 @@ test("every registered gateway tool is consciously classified as gated or open ( // unclassified tool fails here until it is added to exactly one of these lists. const GATED = new Set([ "create_schedule", "update_schedule", // ordinary schedules return null; sudo work keeps an admin card - "set_channel_vpn", "set_channel_admin_mode", "set_channel_network", "set_channel_bash", "set_channel_auto_mode", + "set_channel_vpn", "set_channel_admin_mode", "allow_channel_ssh_host", "set_channel_network", "set_channel_bash", "set_channel_auto_mode", "set_channel_workdir", "clear_channel_workdir", "set_channel_drive_folder", "clear_channel_drive_folder", "add_channel_mcps", "remove_channel_mcps", "update_channel_instructions", "update_gateway", "restart_gateway", "update_gateway_guide", "reset_gateway_guide", @@ -892,3 +893,45 @@ test("list_channel_mcps names the conversation's custom MCP servers, never their await saveChannelMeta(SLUG, original); } }); + + +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" } }); + 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.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"]); + 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/); + }); + 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); } +});